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.
Files changed (108) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +2 -0
  3. data/.simplecov +23 -0
  4. data/.yardopts +4 -0
  5. data/CHANGELOG.md +34 -0
  6. data/CHANGELOG.org +38 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +31 -0
  9. data/README.org +166 -0
  10. data/Rakefile +15 -0
  11. data/TODO.org +163 -0
  12. data/examples/markdown/native-markdown.md +370 -0
  13. data/examples/markdown/ox-gfm-markdown.md +373 -0
  14. data/examples/markdown/ox-gfm-markdown.org +376 -0
  15. data/exe/fatty +275 -0
  16. data/fatty.gemspec +42 -0
  17. data/lib/fatty/accept_env.rb +32 -0
  18. data/lib/fatty/action.rb +103 -0
  19. data/lib/fatty/action_environment.rb +42 -0
  20. data/lib/fatty/actionable.rb +73 -0
  21. data/lib/fatty/alert.rb +93 -0
  22. data/lib/fatty/ansi/renderer.rb +168 -0
  23. data/lib/fatty/ansi.rb +352 -0
  24. data/lib/fatty/colors/color.rb +379 -0
  25. data/lib/fatty/colors/pairs.rb +73 -0
  26. data/lib/fatty/colors/palette.rb +73 -0
  27. data/lib/fatty/colors/rgb.txt +788 -0
  28. data/lib/fatty/colors.rb +5 -0
  29. data/lib/fatty/config.rb +86 -0
  30. data/lib/fatty/config_files/config.yml +50 -0
  31. data/lib/fatty/config_files/help.md +120 -0
  32. data/lib/fatty/config_files/help.org +124 -0
  33. data/lib/fatty/config_files/keybindings.yml +49 -0
  34. data/lib/fatty/config_files/keydefs.yml +23 -0
  35. data/lib/fatty/config_files/themes/mono.yml +76 -0
  36. data/lib/fatty/config_files/themes/nordic.yml +77 -0
  37. data/lib/fatty/config_files/themes/solarized_dark.yml +77 -0
  38. data/lib/fatty/config_files/themes/terminal.yml +90 -0
  39. data/lib/fatty/config_files/themes/wordperfect.yml +77 -0
  40. data/lib/fatty/config_files/themes/wordperfect_light.yml +77 -0
  41. data/lib/fatty/core_ext/string.rb +21 -0
  42. data/lib/fatty/core_ext.rb +3 -0
  43. data/lib/fatty/counter.rb +81 -0
  44. data/lib/fatty/curses/context.rb +279 -0
  45. data/lib/fatty/curses/curses_coder.rb +684 -0
  46. data/lib/fatty/curses/event_source.rb +230 -0
  47. data/lib/fatty/curses/key_decoder.rb +183 -0
  48. data/lib/fatty/curses/patch.rb +116 -0
  49. data/lib/fatty/curses/window_styling.rb +32 -0
  50. data/lib/fatty/curses.rb +16 -0
  51. data/lib/fatty/env.rb +100 -0
  52. data/lib/fatty/help.rb +41 -0
  53. data/lib/fatty/history/entry.rb +71 -0
  54. data/lib/fatty/history.rb +289 -0
  55. data/lib/fatty/input_buffer.rb +998 -0
  56. data/lib/fatty/input_field.rb +507 -0
  57. data/lib/fatty/key_event.rb +342 -0
  58. data/lib/fatty/key_map.rb +392 -0
  59. data/lib/fatty/keymaps/emacs.rb +189 -0
  60. data/lib/fatty/log_formats/json.rb +47 -0
  61. data/lib/fatty/log_formats/text.rb +67 -0
  62. data/lib/fatty/logger.rb +142 -0
  63. data/lib/fatty/markdown/ansi_renderer.rb +373 -0
  64. data/lib/fatty/markdown/render.rb +22 -0
  65. data/lib/fatty/markdown.rb +4 -0
  66. data/lib/fatty/menu_env.rb +22 -0
  67. data/lib/fatty/mouse_event.rb +32 -0
  68. data/lib/fatty/output_buffer.rb +78 -0
  69. data/lib/fatty/pager.rb +801 -0
  70. data/lib/fatty/prompt.rb +40 -0
  71. data/lib/fatty/renderer/curses.rb +697 -0
  72. data/lib/fatty/renderer/truecolor.rb +607 -0
  73. data/lib/fatty/renderer.rb +419 -0
  74. data/lib/fatty/screen.rb +96 -0
  75. data/lib/fatty/search.rb +43 -0
  76. data/lib/fatty/session/alert_session.rb +52 -0
  77. data/lib/fatty/session/input_session.rb +99 -0
  78. data/lib/fatty/session/isearch_session.rb +172 -0
  79. data/lib/fatty/session/keytest_session.rb +236 -0
  80. data/lib/fatty/session/modal_session.rb +61 -0
  81. data/lib/fatty/session/output_session.rb +105 -0
  82. data/lib/fatty/session/popup_session.rb +540 -0
  83. data/lib/fatty/session/prompt_session.rb +157 -0
  84. data/lib/fatty/session/search_session.rb +136 -0
  85. data/lib/fatty/session/shell_session.rb +566 -0
  86. data/lib/fatty/session.rb +173 -0
  87. data/lib/fatty/sessions.rb +14 -0
  88. data/lib/fatty/terminal/popup_owner.rb +26 -0
  89. data/lib/fatty/terminal/progress.rb +374 -0
  90. data/lib/fatty/terminal.rb +1067 -0
  91. data/lib/fatty/themes/loader.rb +136 -0
  92. data/lib/fatty/themes/manager.rb +71 -0
  93. data/lib/fatty/themes/registry.rb +64 -0
  94. data/lib/fatty/themes/resolver.rb +224 -0
  95. data/lib/fatty/themes/themes.rb +131 -0
  96. data/lib/fatty/themes.rb +6 -0
  97. data/lib/fatty/version.rb +5 -0
  98. data/lib/fatty/view/alert_view.rb +14 -0
  99. data/lib/fatty/view/cursor_view.rb +18 -0
  100. data/lib/fatty/view/input_view.rb +9 -0
  101. data/lib/fatty/view/output_view.rb +9 -0
  102. data/lib/fatty/view/status_view.rb +14 -0
  103. data/lib/fatty/view.rb +33 -0
  104. data/lib/fatty/viewport.rb +90 -0
  105. data/lib/fatty/views.rb +9 -0
  106. data/lib/fatty.rb +55 -0
  107. data/sig/fatty.rbs +4 -0
  108. metadata +250 -0
@@ -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