fatty 0.99.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.envrc +2 -0
- data/.simplecov +23 -0
- data/.yardopts +4 -0
- data/CHANGELOG.md +34 -0
- data/CHANGELOG.org +38 -0
- data/LICENSE.txt +21 -0
- data/README.md +31 -0
- data/README.org +166 -0
- data/Rakefile +15 -0
- data/TODO.org +163 -0
- data/examples/markdown/native-markdown.md +370 -0
- data/examples/markdown/ox-gfm-markdown.md +373 -0
- data/examples/markdown/ox-gfm-markdown.org +376 -0
- data/exe/fatty +275 -0
- data/fatty.gemspec +42 -0
- data/lib/fatty/accept_env.rb +32 -0
- data/lib/fatty/action.rb +103 -0
- data/lib/fatty/action_environment.rb +42 -0
- data/lib/fatty/actionable.rb +73 -0
- data/lib/fatty/alert.rb +93 -0
- data/lib/fatty/ansi/renderer.rb +168 -0
- data/lib/fatty/ansi.rb +352 -0
- data/lib/fatty/colors/color.rb +379 -0
- data/lib/fatty/colors/pairs.rb +73 -0
- data/lib/fatty/colors/palette.rb +73 -0
- data/lib/fatty/colors/rgb.txt +788 -0
- data/lib/fatty/colors.rb +5 -0
- data/lib/fatty/config.rb +86 -0
- data/lib/fatty/config_files/config.yml +50 -0
- data/lib/fatty/config_files/help.md +120 -0
- data/lib/fatty/config_files/help.org +124 -0
- data/lib/fatty/config_files/keybindings.yml +49 -0
- data/lib/fatty/config_files/keydefs.yml +23 -0
- data/lib/fatty/config_files/themes/mono.yml +76 -0
- data/lib/fatty/config_files/themes/nordic.yml +77 -0
- data/lib/fatty/config_files/themes/solarized_dark.yml +77 -0
- data/lib/fatty/config_files/themes/terminal.yml +90 -0
- data/lib/fatty/config_files/themes/wordperfect.yml +77 -0
- data/lib/fatty/config_files/themes/wordperfect_light.yml +77 -0
- data/lib/fatty/core_ext/string.rb +21 -0
- data/lib/fatty/core_ext.rb +3 -0
- data/lib/fatty/counter.rb +81 -0
- data/lib/fatty/curses/context.rb +279 -0
- data/lib/fatty/curses/curses_coder.rb +684 -0
- data/lib/fatty/curses/event_source.rb +230 -0
- data/lib/fatty/curses/key_decoder.rb +183 -0
- data/lib/fatty/curses/patch.rb +116 -0
- data/lib/fatty/curses/window_styling.rb +32 -0
- data/lib/fatty/curses.rb +16 -0
- data/lib/fatty/env.rb +100 -0
- data/lib/fatty/help.rb +41 -0
- data/lib/fatty/history/entry.rb +71 -0
- data/lib/fatty/history.rb +289 -0
- data/lib/fatty/input_buffer.rb +998 -0
- data/lib/fatty/input_field.rb +507 -0
- data/lib/fatty/key_event.rb +342 -0
- data/lib/fatty/key_map.rb +392 -0
- data/lib/fatty/keymaps/emacs.rb +189 -0
- data/lib/fatty/log_formats/json.rb +47 -0
- data/lib/fatty/log_formats/text.rb +67 -0
- data/lib/fatty/logger.rb +142 -0
- data/lib/fatty/markdown/ansi_renderer.rb +373 -0
- data/lib/fatty/markdown/render.rb +22 -0
- data/lib/fatty/markdown.rb +4 -0
- data/lib/fatty/menu_env.rb +22 -0
- data/lib/fatty/mouse_event.rb +32 -0
- data/lib/fatty/output_buffer.rb +78 -0
- data/lib/fatty/pager.rb +801 -0
- data/lib/fatty/prompt.rb +40 -0
- data/lib/fatty/renderer/curses.rb +697 -0
- data/lib/fatty/renderer/truecolor.rb +607 -0
- data/lib/fatty/renderer.rb +419 -0
- data/lib/fatty/screen.rb +96 -0
- data/lib/fatty/search.rb +43 -0
- data/lib/fatty/session/alert_session.rb +52 -0
- data/lib/fatty/session/input_session.rb +99 -0
- data/lib/fatty/session/isearch_session.rb +172 -0
- data/lib/fatty/session/keytest_session.rb +236 -0
- data/lib/fatty/session/modal_session.rb +61 -0
- data/lib/fatty/session/output_session.rb +105 -0
- data/lib/fatty/session/popup_session.rb +540 -0
- data/lib/fatty/session/prompt_session.rb +157 -0
- data/lib/fatty/session/search_session.rb +136 -0
- data/lib/fatty/session/shell_session.rb +566 -0
- data/lib/fatty/session.rb +173 -0
- data/lib/fatty/sessions.rb +14 -0
- data/lib/fatty/terminal/popup_owner.rb +26 -0
- data/lib/fatty/terminal/progress.rb +374 -0
- data/lib/fatty/terminal.rb +1067 -0
- data/lib/fatty/themes/loader.rb +136 -0
- data/lib/fatty/themes/manager.rb +71 -0
- data/lib/fatty/themes/registry.rb +64 -0
- data/lib/fatty/themes/resolver.rb +224 -0
- data/lib/fatty/themes/themes.rb +131 -0
- data/lib/fatty/themes.rb +6 -0
- data/lib/fatty/version.rb +5 -0
- data/lib/fatty/view/alert_view.rb +14 -0
- data/lib/fatty/view/cursor_view.rb +18 -0
- data/lib/fatty/view/input_view.rb +9 -0
- data/lib/fatty/view/output_view.rb +9 -0
- data/lib/fatty/view/status_view.rb +14 -0
- data/lib/fatty/view.rb +33 -0
- data/lib/fatty/viewport.rb +90 -0
- data/lib/fatty/views.rb +9 -0
- data/lib/fatty.rb +55 -0
- data/sig/fatty.rbs +4 -0
- metadata +250 -0
data/lib/fatty/pager.rb
ADDED
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fatty
|
|
4
|
+
class Pager
|
|
5
|
+
SCROLL_WHEEL_LINES = 3
|
|
6
|
+
|
|
7
|
+
include Fatty::Actionable
|
|
8
|
+
|
|
9
|
+
action_on :pager
|
|
10
|
+
attr_reader :mode
|
|
11
|
+
|
|
12
|
+
def initialize(output:, viewport:, mode: :paging)
|
|
13
|
+
@output = output
|
|
14
|
+
@viewport = viewport
|
|
15
|
+
# @mode can be :paging, or :scrolling
|
|
16
|
+
# - paging: the viewport displays one page worth of output at a time,
|
|
17
|
+
# - scrolling: the viewport displays output continuously as it is produced.
|
|
18
|
+
@mode = mode
|
|
19
|
+
@paused = false
|
|
20
|
+
@autoscroll = false
|
|
21
|
+
@last_nav_dir = nil
|
|
22
|
+
# Search state (used by SearchSession, but owned here so paging repeats and
|
|
23
|
+
# highlighting survive after the SearchSession closes).
|
|
24
|
+
@search = {
|
|
25
|
+
pattern: nil,
|
|
26
|
+
regex: false,
|
|
27
|
+
re: nil,
|
|
28
|
+
original_direction: nil,
|
|
29
|
+
last_direction: nil,
|
|
30
|
+
last: nil, # { line: Integer, from: Integer, to: Integer }
|
|
31
|
+
last_view_top: nil,
|
|
32
|
+
pending_wrap: nil, # :forward/:backward
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def paused?
|
|
37
|
+
@mode == :paging && @paused
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def reserve_prompt_row?
|
|
41
|
+
paused?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def autoscroll?
|
|
45
|
+
@autoscroll
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The pager sometimes reserves a row for a status/minibuffer line. When it
|
|
49
|
+
# does, paging calculations must use a reduced page height so that the
|
|
50
|
+
# pause threshold and page-step size match what is actually rendered.
|
|
51
|
+
def page_height
|
|
52
|
+
h = @viewport.height
|
|
53
|
+
h -= 1 if reserve_prompt_row?
|
|
54
|
+
[h, 1].max
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Called by OutputSession after new text is appended.
|
|
58
|
+
def on_append(ntrim:)
|
|
59
|
+
@viewport.adjust_for_trim(ntrim)
|
|
60
|
+
lines = @output.lines
|
|
61
|
+
total = lines.size
|
|
62
|
+
|
|
63
|
+
case @mode
|
|
64
|
+
when :scrolling
|
|
65
|
+
# In scrolling mode, the viewport is allowed to move continuously.
|
|
66
|
+
# Autoscroll animation (after switching from paging -> scrolling) is
|
|
67
|
+
# driven by #autoscroll_step? in ShellSession#tick, not by incremental
|
|
68
|
+
# scroll deltas during append.
|
|
69
|
+
@viewport.clamp!(lines)
|
|
70
|
+
when :paging
|
|
71
|
+
if @anchor
|
|
72
|
+
produced = total - @anchor
|
|
73
|
+
|
|
74
|
+
if produced <= 0
|
|
75
|
+
return
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# While producing the first page, keep the viewport pinned to the anchor.
|
|
79
|
+
@viewport.top = @anchor
|
|
80
|
+
clamp_to_page!
|
|
81
|
+
if produced >= page_height
|
|
82
|
+
@paused = true
|
|
83
|
+
clamp_to_page!
|
|
84
|
+
end
|
|
85
|
+
return
|
|
86
|
+
end
|
|
87
|
+
if @paused
|
|
88
|
+
clamp_to_page!
|
|
89
|
+
return
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# No anchor and not yet paused: once the output exceeds a single page,
|
|
93
|
+
# pause and show the first page.
|
|
94
|
+
if total > page_height
|
|
95
|
+
@paused = true
|
|
96
|
+
@viewport.page_top
|
|
97
|
+
end
|
|
98
|
+
# Otherwise user is not at bottom; don't force movement.
|
|
99
|
+
@viewport.clamp!(lines)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def act_on(action, *args, env:, **kwargs)
|
|
104
|
+
raise Fatty::ActionError, "Unknown action: #{action}" unless action
|
|
105
|
+
|
|
106
|
+
if Fatty::Actions.registered?(action)
|
|
107
|
+
Fatty::Actions.call(action, env, *args, **kwargs)
|
|
108
|
+
elsif respond_to?(action)
|
|
109
|
+
public_send(action, *args, **kwargs)
|
|
110
|
+
else
|
|
111
|
+
raise Fatty::ActionError, "Unknown action: #{action}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
desc "Page up in output"
|
|
116
|
+
action :page_up do |count: 1|
|
|
117
|
+
@mode = :paging
|
|
118
|
+
@paused = true
|
|
119
|
+
@last_nav_dir = :up
|
|
120
|
+
step = page_height
|
|
121
|
+
@viewport.top -= step * count.to_i
|
|
122
|
+
clamp_to_page!
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
desc "Page down in output"
|
|
126
|
+
action :page_down do |count: 1|
|
|
127
|
+
@mode = :paging
|
|
128
|
+
@paused = true
|
|
129
|
+
@last_nav_dir = :down
|
|
130
|
+
step = page_height
|
|
131
|
+
@viewport.top += step * count.to_i
|
|
132
|
+
clamp_to_page!
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
desc "Jump to end and follow output"
|
|
136
|
+
action :end_of_output do
|
|
137
|
+
@mode = :scrolling
|
|
138
|
+
@paused = false
|
|
139
|
+
@last_nav_dir = :down
|
|
140
|
+
@anchor = nil
|
|
141
|
+
@autoscroll = false
|
|
142
|
+
@viewport.page_bottom(@output.lines)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
desc "One line up"
|
|
146
|
+
action :line_up do |count: 1|
|
|
147
|
+
@mode = :paging
|
|
148
|
+
@paused = true
|
|
149
|
+
@last_nav_dir = :up
|
|
150
|
+
@viewport.top -= count.to_i
|
|
151
|
+
clamp_to_page!
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
desc "One line down"
|
|
155
|
+
action :line_down do |count: 1|
|
|
156
|
+
@mode = :paging
|
|
157
|
+
@paused = true
|
|
158
|
+
@last_nav_dir = :down
|
|
159
|
+
@viewport.top += count.to_i
|
|
160
|
+
clamp_to_page!
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
desc "Scroll up #{SCROLL_WHEEL_LINES} lines"
|
|
164
|
+
action :scroll_up do |count: SCROLL_WHEEL_LINES|
|
|
165
|
+
@mode = :paging
|
|
166
|
+
@paused = true
|
|
167
|
+
@last_nav_dir = :up
|
|
168
|
+
@viewport.top -= count.to_i
|
|
169
|
+
clamp_to_page!
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
desc "Scroll down #{SCROLL_WHEEL_LINES} lines"
|
|
173
|
+
action :scroll_down do |count: SCROLL_WHEEL_LINES|
|
|
174
|
+
@mode = :paging
|
|
175
|
+
@paused = true
|
|
176
|
+
@last_nav_dir = :down
|
|
177
|
+
@viewport.top += count.to_i
|
|
178
|
+
clamp_to_page!
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
desc "Page top"
|
|
182
|
+
action :page_top do
|
|
183
|
+
@mode = :paging
|
|
184
|
+
@paused = true
|
|
185
|
+
@last_nav_dir = :up
|
|
186
|
+
page_top!
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
desc "Page bottom"
|
|
190
|
+
action :page_bottom do
|
|
191
|
+
@mode = :paging
|
|
192
|
+
@paused = true
|
|
193
|
+
@last_nav_dir = :down
|
|
194
|
+
@viewport.top = max_page_top
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
desc "Switch from paging to scrolling"
|
|
198
|
+
action :paging_to_scrolling do
|
|
199
|
+
@mode = :scrolling
|
|
200
|
+
@paused = false
|
|
201
|
+
@anchor = nil
|
|
202
|
+
@last_nav_dir = :down
|
|
203
|
+
@autoscroll = true
|
|
204
|
+
@viewport.clamp!(@output.lines)
|
|
205
|
+
# Make it visibly start immediately, even if no further appends happen.
|
|
206
|
+
autoscroll_step?(max_lines: @viewport.height)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
desc "Toggle between paging and scrolling"
|
|
210
|
+
action :toggle_paging do
|
|
211
|
+
@last_nav_dir = :down
|
|
212
|
+
if @mode == :paging
|
|
213
|
+
@mode = :scrolling
|
|
214
|
+
@paused = false
|
|
215
|
+
@autoscroll = true # keep if you like the animated “catch up”
|
|
216
|
+
else
|
|
217
|
+
@mode = :paging
|
|
218
|
+
@paused = true
|
|
219
|
+
@autoscroll = false
|
|
220
|
+
@anchor = nil # ensure no command-anchored paging behavior kicks in
|
|
221
|
+
@viewport.clamp!(@output.lines)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
desc "Exit paging and return control to normal input."
|
|
226
|
+
action :quit_paging do
|
|
227
|
+
@paused = false
|
|
228
|
+
@mode = :paging
|
|
229
|
+
@anchor = nil
|
|
230
|
+
@autoscroll = false
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def at_top?
|
|
234
|
+
@viewport.at_top?
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def at_bottom?
|
|
238
|
+
@viewport.top >= max_page_top
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def nav_arrow
|
|
242
|
+
if at_top?
|
|
243
|
+
"⇓"
|
|
244
|
+
elsif at_bottom?
|
|
245
|
+
"⇑"
|
|
246
|
+
elsif @last_nav_dir == :up
|
|
247
|
+
"⇑"
|
|
248
|
+
elsif @last_nav_dir == :down
|
|
249
|
+
"⇓"
|
|
250
|
+
else
|
|
251
|
+
""
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def max_page_top
|
|
256
|
+
[@output.lines.size - page_height, 0].max
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def clamp!
|
|
260
|
+
if paused?
|
|
261
|
+
clamp_to_page!
|
|
262
|
+
else
|
|
263
|
+
@viewport.clamp!(@output.lines)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def clamp_to_page!
|
|
268
|
+
@viewport.top = @viewport.top.clamp(0, max_page_top)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def begin_command!(anchor:)
|
|
272
|
+
@mode = :paging
|
|
273
|
+
@paused = false
|
|
274
|
+
@anchor = anchor
|
|
275
|
+
@autoscroll = false
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def reset!(total_lines: 0, mode: :paging)
|
|
279
|
+
@mode = mode
|
|
280
|
+
@paused = false
|
|
281
|
+
@autoscroll = false
|
|
282
|
+
@anchor = nil
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def autoscroll_step?(max_lines: 200)
|
|
286
|
+
lines = @output.lines
|
|
287
|
+
total = lines.length
|
|
288
|
+
moved = false
|
|
289
|
+
if total > 0
|
|
290
|
+
if @viewport.at_bottom?(total)
|
|
291
|
+
@autoscroll = false
|
|
292
|
+
else
|
|
293
|
+
remaining = @viewport.max_top(total) - @viewport.top
|
|
294
|
+
n = [remaining, max_lines].min
|
|
295
|
+
before = @viewport.top
|
|
296
|
+
@viewport.scroll_down(lines, n)
|
|
297
|
+
moved = @viewport.top != before
|
|
298
|
+
|
|
299
|
+
if @viewport.at_bottom?(total)
|
|
300
|
+
@autoscroll = false
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
moved
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# --- Search -----------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
def search_active?
|
|
310
|
+
!@search[:re].nil?
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Sets the search pattern and moves the viewport to the next match.
|
|
314
|
+
# Returns a result hash:
|
|
315
|
+
# { status: :moved }
|
|
316
|
+
# { status: :not_found }
|
|
317
|
+
#
|
|
318
|
+
# The initial search includes the currently visible page:
|
|
319
|
+
# - forward starts at viewport.top
|
|
320
|
+
# - backward starts at viewport.bottom_index(total)
|
|
321
|
+
def search_set!(pattern:, regex:, direction:)
|
|
322
|
+
pattern = pattern.to_s
|
|
323
|
+
if pattern.strip.empty?
|
|
324
|
+
clear_search!
|
|
325
|
+
return { status: :not_found }
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
re = compile_search_regexp(pattern, regex: regex)
|
|
329
|
+
@search[:pattern] = pattern
|
|
330
|
+
@search[:regex] = !!regex
|
|
331
|
+
@search[:re] = re
|
|
332
|
+
@search[:last_direction] = direction.to_sym
|
|
333
|
+
@search[:original_direction] = direction.to_sym
|
|
334
|
+
@search[:last] = nil
|
|
335
|
+
@search[:pending_wrap] = nil
|
|
336
|
+
|
|
337
|
+
search_step!(direction: direction, initial: true)
|
|
338
|
+
rescue RegexpError => e
|
|
339
|
+
clear_search!
|
|
340
|
+
{ status: :not_found, message: "Invalid regexp: #{e.message}" }
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def clear_search!
|
|
344
|
+
@search[:pattern] = nil
|
|
345
|
+
@search[:regex] = false
|
|
346
|
+
@search[:re] = nil
|
|
347
|
+
@search[:last] = nil
|
|
348
|
+
@search[:original_direction] = nil
|
|
349
|
+
@search[:pending_wrap] = nil
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Steps to the next match in +direction+.
|
|
353
|
+
# If no match exists without wrapping, returns :boundary and requires a
|
|
354
|
+
# second step in the same direction to wrap.
|
|
355
|
+
# NOTE:
|
|
356
|
+
# Regex search is strictly line-local.
|
|
357
|
+
# Matches never span line boundaries even if '.' would
|
|
358
|
+
# normally match '\n'. This preserves pager navigation,
|
|
359
|
+
# viewport anchoring, and renderer simplicity.
|
|
360
|
+
def search_step!(direction:, initial: false, update_origin: true)
|
|
361
|
+
direction = direction.to_sym
|
|
362
|
+
re = @search[:re]
|
|
363
|
+
@search[:last_direction] = direction.to_sym
|
|
364
|
+
if update_origin
|
|
365
|
+
@search[:original_direction] = direction.to_sym
|
|
366
|
+
end
|
|
367
|
+
return { status: :not_found } unless re
|
|
368
|
+
|
|
369
|
+
lines = @output.lines
|
|
370
|
+
total = lines.length
|
|
371
|
+
return { status: :not_found } if total.zero?
|
|
372
|
+
|
|
373
|
+
start = search_start_position(direction: direction, total: total, initial: initial)
|
|
374
|
+
found = find_next_match(lines, re, start, direction: direction, wrap: false)
|
|
375
|
+
|
|
376
|
+
if found
|
|
377
|
+
apply_search_match(found)
|
|
378
|
+
@search[:pending_wrap] = nil
|
|
379
|
+
return { status: :moved }
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
if @search[:pending_wrap] == direction
|
|
383
|
+
# Second invocation in the same direction: wrap.
|
|
384
|
+
wrap_start = wrap_start_position(direction: direction, total: total)
|
|
385
|
+
found = find_next_match(lines, re, wrap_start, direction: direction, wrap: true)
|
|
386
|
+
@search[:pending_wrap] = nil
|
|
387
|
+
|
|
388
|
+
if found
|
|
389
|
+
apply_search_match(found)
|
|
390
|
+
return { status: :moved, wrapped: true }
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
return { status: :not_found }
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
@search[:pending_wrap] = direction
|
|
397
|
+
{ status: :boundary, message: boundary_message(direction: direction) }
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Vim/less semantics:
|
|
401
|
+
# - n repeats in the original direction (set by / ? C-s C-r)
|
|
402
|
+
# - N repeats in the opposite direction
|
|
403
|
+
def search_repeat_next!
|
|
404
|
+
dir = (@search[:original_direction] || :forward).to_sym
|
|
405
|
+
search_step!(direction: dir, update_origin: false)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def search_repeat_prev!
|
|
409
|
+
origin = (@search[:original_direction] || :forward).to_sym
|
|
410
|
+
dir = (origin == :forward ? :backward : :forward)
|
|
411
|
+
search_step!(direction: dir, update_origin: false)
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Exposes the last match for render-layer highlighting.
|
|
415
|
+
# (We only highlight the last match for now; later we can highlight all
|
|
416
|
+
# visible matches.)
|
|
417
|
+
def search_last_match
|
|
418
|
+
@search[:last]
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def search_pattern
|
|
422
|
+
@search[:pattern].to_s
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Returns highlight ranges for all matches within the given viewport.
|
|
426
|
+
#
|
|
427
|
+
# Format:
|
|
428
|
+
# {
|
|
429
|
+
# abs_line_index => [[from, to, :secondary], [from, to, :secondary], ...,
|
|
430
|
+
# [from, to, :primary]],
|
|
431
|
+
# ...
|
|
432
|
+
# }
|
|
433
|
+
#
|
|
434
|
+
# Ranges are in *visible text* coordinates (ANSI already stripped), matching
|
|
435
|
+
# the renderer’s slice planner / highlighting behavior.
|
|
436
|
+
def search_visible_highlights(viewport:)
|
|
437
|
+
return unless @search[:re]
|
|
438
|
+
|
|
439
|
+
lines = @output.lines
|
|
440
|
+
total = lines.length
|
|
441
|
+
return if total.zero?
|
|
442
|
+
|
|
443
|
+
top = viewport.top.to_i
|
|
444
|
+
bottom = viewport.bottom_index(total)
|
|
445
|
+
current = @search[:last]
|
|
446
|
+
out = {}
|
|
447
|
+
|
|
448
|
+
if @search[:regex]
|
|
449
|
+
(top..bottom).each do |i|
|
|
450
|
+
text = visible_text(lines[i])
|
|
451
|
+
next if text.nil? || text.empty?
|
|
452
|
+
|
|
453
|
+
ranges = []
|
|
454
|
+
text.to_enum(:scan, @search[:re]).each do
|
|
455
|
+
m = Regexp.last_match
|
|
456
|
+
ranges << [m.begin(0), m.end(0), :secondary]
|
|
457
|
+
end
|
|
458
|
+
next if ranges.empty?
|
|
459
|
+
|
|
460
|
+
if current && current[:line].to_i == i
|
|
461
|
+
cf = current[:from].to_i
|
|
462
|
+
ct = current[:to].to_i
|
|
463
|
+
ranges.reject! { |a, b, _role| a == cf && b == ct }
|
|
464
|
+
ranges << [cf, ct, :primary]
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
ranges.sort_by!(&:first)
|
|
468
|
+
out[i] = merge_highlight_ranges(ranges)
|
|
469
|
+
end
|
|
470
|
+
else
|
|
471
|
+
term_res = Fatty::Search.compile_term_regexps(@search[:pattern])
|
|
472
|
+
|
|
473
|
+
(top..bottom).each do |i|
|
|
474
|
+
text = visible_text(lines[i])
|
|
475
|
+
next if text.nil? || text.empty?
|
|
476
|
+
|
|
477
|
+
ranges = []
|
|
478
|
+
|
|
479
|
+
term_res.each do |term_re|
|
|
480
|
+
text.to_enum(:scan, term_re).each do
|
|
481
|
+
m = Regexp.last_match
|
|
482
|
+
ranges << [m.begin(0), m.end(0), :secondary]
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
next if ranges.empty?
|
|
487
|
+
|
|
488
|
+
if current && current[:line].to_i == i
|
|
489
|
+
cf = current[:from].to_i
|
|
490
|
+
ct = current[:to].to_i
|
|
491
|
+
ranges.reject! { |a, b, _role| a == cf && b == ct }
|
|
492
|
+
ranges << [cf, ct, :primary]
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
ranges.sort_by!(&:first)
|
|
496
|
+
out[i] = merge_highlight_ranges(ranges)
|
|
497
|
+
end
|
|
498
|
+
end
|
|
499
|
+
out.empty? ? nil : out
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def merge_highlight_ranges(ranges)
|
|
503
|
+
return [] if ranges.empty?
|
|
504
|
+
|
|
505
|
+
merged = [ranges.first.dup]
|
|
506
|
+
|
|
507
|
+
ranges.drop(1).each do |from, to, role|
|
|
508
|
+
prev = merged[-1]
|
|
509
|
+
_, prev_to, prev_role = prev
|
|
510
|
+
|
|
511
|
+
if from <= prev_to
|
|
512
|
+
prev[1] = [prev_to, to].max
|
|
513
|
+
prev[2] = :primary if prev_role == :primary || role == :primary
|
|
514
|
+
else
|
|
515
|
+
merged << [from, to, role]
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
merged
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
def plain_search?
|
|
523
|
+
@search[:re] && !@search[:regex]
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def term_regexps
|
|
527
|
+
Fatty::Search.compile_term_regexps(@search[:pattern])
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def first_term_match(text, from:)
|
|
531
|
+
best = nil
|
|
532
|
+
|
|
533
|
+
term_regexps.each do |term_re|
|
|
534
|
+
m = term_re.match(text, from)
|
|
535
|
+
next unless m
|
|
536
|
+
|
|
537
|
+
if best.nil? || m.begin(0) < best.begin(0)
|
|
538
|
+
best = m
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
best
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def last_term_match_before(text, limit:)
|
|
546
|
+
best = nil
|
|
547
|
+
|
|
548
|
+
term_regexps.each do |term_re|
|
|
549
|
+
text.to_enum(:scan, term_re).each do
|
|
550
|
+
m = Regexp.last_match
|
|
551
|
+
break if m.end(0) > limit
|
|
552
|
+
|
|
553
|
+
if best.nil? || m.begin(0) >= best.begin(0)
|
|
554
|
+
best = m
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
best
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def search_label
|
|
563
|
+
return unless @search[:re]
|
|
564
|
+
|
|
565
|
+
# Keep showing the direction of the most recent movement (nice UX),
|
|
566
|
+
# while repeat semantics depend on original_direction.
|
|
567
|
+
arrow = (@search[:last_direction] == :backward ? "↑" : "↓")
|
|
568
|
+
prefix = @search[:regex] ? "re:" : ""
|
|
569
|
+
pat = @search[:pattern].to_s
|
|
570
|
+
"#{arrow} #{prefix}#{pat}"
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def isearch_update!(pattern:, direction:)
|
|
574
|
+
ensure_isearch_snapshot!
|
|
575
|
+
pattern = pattern.to_s
|
|
576
|
+
|
|
577
|
+
result =
|
|
578
|
+
if pattern.strip.empty?
|
|
579
|
+
restore_isearch_anchor!
|
|
580
|
+
@search[:re] = nil
|
|
581
|
+
@search[:pattern] = nil
|
|
582
|
+
@search[:last] = nil
|
|
583
|
+
@search[:pending_wrap] = nil
|
|
584
|
+
{ status: :not_found }
|
|
585
|
+
else
|
|
586
|
+
re = compile_search_regexp(pattern, regex: false)
|
|
587
|
+
@search[:pattern] = pattern
|
|
588
|
+
@search[:regex] = false
|
|
589
|
+
@search[:re] = re
|
|
590
|
+
@search[:last_direction] = direction.to_sym
|
|
591
|
+
|
|
592
|
+
initial = @search[:last].nil?
|
|
593
|
+
search_step!(direction: direction, initial: initial, update_origin: false)
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
result
|
|
597
|
+
rescue RegexpError => e
|
|
598
|
+
{ status: :not_found, message: "Invalid regexp: #{e.message}" }
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
def isearch_step!(direction:)
|
|
602
|
+
ensure_isearch_snapshot!
|
|
603
|
+
search_step!(direction: direction, initial: false, update_origin: false)
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
def isearch_cancel!
|
|
607
|
+
if @isearch_snapshot
|
|
608
|
+
@viewport.top = @isearch_snapshot[:viewport_top]
|
|
609
|
+
@search = @isearch_snapshot[:search]
|
|
610
|
+
@isearch_snapshot = nil
|
|
611
|
+
end
|
|
612
|
+
nil
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
def isearch_commit!(pattern:, direction:)
|
|
616
|
+
ensure_isearch_snapshot!
|
|
617
|
+
pattern = pattern.to_s
|
|
618
|
+
if pattern.strip.empty?
|
|
619
|
+
isearch_cancel!
|
|
620
|
+
return { status: :not_found }
|
|
621
|
+
end
|
|
622
|
+
result = isearch_update!(pattern: pattern, direction: direction)
|
|
623
|
+
if result[:status] == :moved
|
|
624
|
+
@search[:original_direction] = direction.to_sym
|
|
625
|
+
@search[:pending_wrap] = nil
|
|
626
|
+
end
|
|
627
|
+
@isearch_snapshot = nil
|
|
628
|
+
result
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
def page_bottom!
|
|
632
|
+
@viewport.top = max_page_top
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def page_top!
|
|
636
|
+
@viewport.top = 0
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def preserve_after_resize!(was_at_bottom:)
|
|
640
|
+
if @mode == :paging && @output.lines.any? && was_at_bottom
|
|
641
|
+
page_bottom!
|
|
642
|
+
else
|
|
643
|
+
clamp!
|
|
644
|
+
end
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
private
|
|
648
|
+
|
|
649
|
+
def ensure_isearch_snapshot!
|
|
650
|
+
return if @isearch_snapshot
|
|
651
|
+
|
|
652
|
+
@isearch_snapshot = {
|
|
653
|
+
viewport_top: @viewport.top,
|
|
654
|
+
# Deep-ish copy of the search hash so we can restore cleanly.
|
|
655
|
+
search: @search.dup,
|
|
656
|
+
}
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
def restore_isearch_anchor!
|
|
660
|
+
if @isearch_snapshot
|
|
661
|
+
@viewport.top = @isearch_snapshot[:viewport_top]
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
# Return the visible text for a line, with ANSI escapes removed.
|
|
666
|
+
# Search offsets are computed in this coordinate space so rendering
|
|
667
|
+
# highlights align with what the user sees.
|
|
668
|
+
def visible_text(str)
|
|
669
|
+
Fatty::Ansi.segment(str.to_s).map(&:first).join
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def compile_search_regexp(pattern, regex:)
|
|
673
|
+
Fatty::Search.compile_regexp(pattern, regex: regex)
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def search_start_position(direction:, total:, initial:)
|
|
677
|
+
last = @search[:last]
|
|
678
|
+
# If the user has navigated since the last match (e.g. G/g/PageUp),
|
|
679
|
+
# repeat search should start from the current viewport edge, not from
|
|
680
|
+
# the prior match location.
|
|
681
|
+
viewport_unchanged = last && @search[:last_view_top] == @viewport.top
|
|
682
|
+
|
|
683
|
+
if last && viewport_unchanged
|
|
684
|
+
if direction == :forward
|
|
685
|
+
{ line: last[:line], col: last[:to] }
|
|
686
|
+
else
|
|
687
|
+
{ line: last[:line], col: last[:from] }
|
|
688
|
+
end
|
|
689
|
+
elsif direction == :forward
|
|
690
|
+
{ line: @viewport.top, col: 0 }
|
|
691
|
+
else
|
|
692
|
+
# Use a very large col so scan_backward includes matches on the
|
|
693
|
+
# starting line (our backward scan treats start_col as an upper bound).
|
|
694
|
+
li = @viewport.bottom_index(total)
|
|
695
|
+
{ line: li, col: search_start_col_for(li) }
|
|
696
|
+
end
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
def search_start_col_for(line_index)
|
|
700
|
+
line = @output.lines[line_index]
|
|
701
|
+
# Use end-of-line as the backward scan upper bound so the starting line
|
|
702
|
+
# is included. +1 avoids edge weirdness when match ends exactly at len.
|
|
703
|
+
if line
|
|
704
|
+
visible_text(line).chomp.length + 1
|
|
705
|
+
else
|
|
706
|
+
1_000_000_000
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
def wrap_start_position(direction:, total:)
|
|
711
|
+
if direction == :forward
|
|
712
|
+
{ line: 0, col: 0 }
|
|
713
|
+
else
|
|
714
|
+
li = total - 1
|
|
715
|
+
{ line: li, col: search_start_col_for(li) }
|
|
716
|
+
end
|
|
717
|
+
end
|
|
718
|
+
|
|
719
|
+
def boundary_message(direction:)
|
|
720
|
+
if direction == :forward
|
|
721
|
+
"Bottom reached — hit C-s (or n) again to wrap to top"
|
|
722
|
+
else
|
|
723
|
+
"Top reached — hit C-r (or N) again to wrap to bottom"
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
def find_next_match(lines, re, start, direction:, wrap:)
|
|
728
|
+
line_i = start[:line].to_i
|
|
729
|
+
col = start[:col].to_i
|
|
730
|
+
|
|
731
|
+
if direction == :forward
|
|
732
|
+
scan_forward(lines, re, line_i, col)
|
|
733
|
+
else
|
|
734
|
+
scan_backward(lines, re, line_i, col)
|
|
735
|
+
end
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
def scan_forward(lines, re, start_line, start_col)
|
|
739
|
+
i = start_line.clamp(0, lines.length - 1)
|
|
740
|
+
while i < lines.length
|
|
741
|
+
text = visible_text(lines[i])
|
|
742
|
+
from = (i == start_line ? start_col : 0)
|
|
743
|
+
|
|
744
|
+
if plain_search?
|
|
745
|
+
if re.match(text)
|
|
746
|
+
m = first_term_match(text, from: from)
|
|
747
|
+
return { line: i, from: m.begin(0), to: m.end(0) } if m
|
|
748
|
+
end
|
|
749
|
+
else
|
|
750
|
+
m = re.match(text, from)
|
|
751
|
+
return { line: i, from: m.begin(0), to: m.end(0) } if m
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
i += 1
|
|
755
|
+
end
|
|
756
|
+
nil
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
def scan_backward(lines, re, start_line, start_col)
|
|
760
|
+
i = start_line.clamp(0, lines.length - 1)
|
|
761
|
+
while i >= 0
|
|
762
|
+
text = visible_text(lines[i])
|
|
763
|
+
limit = (i == start_line ? start_col : nil)
|
|
764
|
+
|
|
765
|
+
if plain_search?
|
|
766
|
+
if re.match(text)
|
|
767
|
+
lim = limit || (text.length + 1)
|
|
768
|
+
m = last_term_match_before(text, limit: lim)
|
|
769
|
+
return { line: i, from: m.begin(0), to: m.end(0) } if m
|
|
770
|
+
end
|
|
771
|
+
else
|
|
772
|
+
last = nil
|
|
773
|
+
text.to_enum(:scan, re).each do
|
|
774
|
+
m = Regexp.last_match
|
|
775
|
+
break if limit && m.end(0) > limit
|
|
776
|
+
|
|
777
|
+
last = m
|
|
778
|
+
end
|
|
779
|
+
return { line: i, from: last.begin(0), to: last.end(0) } if last
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
i -= 1
|
|
783
|
+
end
|
|
784
|
+
nil
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
def apply_search_match(match)
|
|
788
|
+
@search[:last] = match
|
|
789
|
+
line = match[:line]
|
|
790
|
+
# Make sure we're in paging mode when navigating search results.
|
|
791
|
+
@mode = :paging
|
|
792
|
+
@paused = true
|
|
793
|
+
@last_nav_dir = :down
|
|
794
|
+
|
|
795
|
+
# Position the match line near the middle when possible.
|
|
796
|
+
half = (@viewport.height / 2)
|
|
797
|
+
@viewport.top = [line - half, 0].max
|
|
798
|
+
@search[:last_view_top] = @viewport.top
|
|
799
|
+
end
|
|
800
|
+
end
|
|
801
|
+
end
|