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,697 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/fatty/renderer/curses.rb
4
+
5
+ module Fatty
6
+ class Renderer
7
+ class Curses < Fatty::Renderer
8
+ include Fatty::Curses::WindowStyling
9
+
10
+ def initialize(...)
11
+ super
12
+ end
13
+
14
+ def render_status(text, role: :status_info)
15
+ state = status_state(text, role)
16
+ return if state == @last_status_state
17
+
18
+ @last_status_state = state
19
+
20
+ win = context.status_win
21
+ return unless win
22
+
23
+ rows = screen.status_rect.rows
24
+ cols = screen.status_rect.cols
25
+ base_attr = pair_attr(role, fallback: ::Curses::A_REVERSE)
26
+
27
+ win.bkgdset(base_attr) if win.respond_to?(:bkgdset)
28
+ win.erase
29
+ win.attrset(base_attr)
30
+
31
+ status_render_lines(text, width: cols, max_rows: rows).each_with_index do |line, row|
32
+ win.setpos(row, 0)
33
+ rendered = Fatty::Ansi.truncate_visible(line, cols)
34
+ padding = [cols - Fatty::Ansi.visible_length(rendered), 0].max
35
+ win.addstr(rendered + (" " * padding))
36
+ end
37
+ stage_window(win)
38
+ end
39
+
40
+ def render_output(output, viewport:, highlights: nil)
41
+ lines = viewport.slice(output.lines)
42
+ normalized = normalized_highlights(highlights)
43
+
44
+ curr = output_state(
45
+ viewport: viewport,
46
+ lines: lines,
47
+ highlights: normalized,
48
+ )
49
+
50
+ prev = @last_output_state
51
+
52
+ if prev && can_incrementally_scroll_output?(prev, curr)
53
+ scroll_output_window_delta!(prev: prev, curr: curr)
54
+ else
55
+ draw_output_lines(lines, viewport: viewport, highlights: normalized)
56
+ end
57
+
58
+ @last_output_state = curr
59
+ end
60
+
61
+ def render_input_field(field, role: :input)
62
+ state = input_field_state(field)
63
+ return if state == @last_input_state
64
+
65
+ @last_input_state = state
66
+
67
+ win = context.input_win
68
+ return unless win
69
+
70
+ width = win.respond_to?(:maxx) ? win.maxx : screen.cols
71
+ base_attr = pair_attr(role, fallback: ::Curses::A_NORMAL)
72
+ region_attr = pair_attr(:region, fallback: ::Curses::A_REVERSE)
73
+ suggestion_attr = pair_attr(:input_suggestion, fallback: base_attr)
74
+
75
+ win.bkgdset(base_attr) if win.respond_to?(:bkgdset)
76
+ win.erase
77
+ win.attrset(base_attr)
78
+
79
+ render_field_into(
80
+ win: win,
81
+ field: field,
82
+ row: 0,
83
+ width: width,
84
+ base_role: role,
85
+ base_attr: base_attr,
86
+ region_attr: region_attr,
87
+ suggestion_attr: suggestion_attr,
88
+ )
89
+ cursor_x = field.cursor_x.to_i.clamp(0, [width - 1, 0].max)
90
+ win.setpos(0, cursor_x)
91
+ stage_window(win)
92
+ end
93
+
94
+ def render_alert(alert)
95
+ state = alert_state(alert)
96
+ return if state == @last_alert_state
97
+
98
+ @last_alert_state = state
99
+
100
+ win = context.alert_win
101
+ return unless win
102
+
103
+ text = alert ? alert.format : ""
104
+ text = Fatty::Ansi.strip(text)
105
+ role = alert ? alert.role : :alert
106
+ attr = pair_attr(role, fallback: pair_attr(:alert, fallback: ::Curses::A_REVERSE))
107
+ cols = win.respond_to?(:maxx) ? win.maxx : screen.alert_rect.cols
108
+
109
+ win.bkgdset(attr) if win.respond_to?(:bkgdset)
110
+ win.erase
111
+ win.attrset(attr)
112
+ win.setpos(0, 0)
113
+ win.addstr(text.ljust(cols)[0, cols])
114
+
115
+ stage_window(win)
116
+ end
117
+
118
+ # Render an InputField-like status line.
119
+ #
120
+ # Used for pager mode ("--More--" etc.). It intentionally does not move
121
+ # the cursor; ShellSession decides whether to show a cursor in paging
122
+ # vs input mode.
123
+ #
124
+ # Curses uses a derived one-line window to isolate the pager/search field
125
+ # from output_win scrolling/background effects.
126
+ #
127
+ # Truecolor renders the same field as an ANSI overlay at absolute coordinates,
128
+ # so no derived window is needed there.
129
+ def render_pager_field(field, row:, role: :pager_status)
130
+ win = context.output_win
131
+ return unless win
132
+
133
+ cols = win.respond_to?(:maxx) ? win.maxx : @screen.cols
134
+ attr = pair_attr(role, fallback: ::Curses::A_REVERSE)
135
+
136
+ field_win = win.derwin(1, cols, row, 0)
137
+ field_win.bkgdset(attr) if field_win.respond_to?(:bkgdset)
138
+ field_win.erase
139
+ field_win.attrset(attr)
140
+
141
+ render_field_into(
142
+ win: field_win,
143
+ field: field,
144
+ row: 0,
145
+ width: cols,
146
+ base_attr: attr,
147
+ region_attr: pair_attr(:region, fallback: ::Curses::A_REVERSE),
148
+ suggestion_attr: pair_attr(:input_suggestion, fallback: attr),
149
+ base_role: role,
150
+ )
151
+
152
+ field_win.noutrefresh
153
+ stage_window(win)
154
+ ensure
155
+ field_win&.close if field_win&.respond_to?(:close)
156
+ end
157
+
158
+ def render_popup(session:)
159
+ state = popup_state(session)
160
+ return if state == @last_popup_state
161
+
162
+ @last_popup_state = state
163
+
164
+ win = session.win
165
+ return unless win
166
+
167
+ width = win.maxx
168
+ height = win.maxy
169
+ return if width < 2 || height < 2
170
+
171
+ win.erase
172
+
173
+ frame_attr = pair_attr(:popup_frame, fallback: ::Curses::A_NORMAL)
174
+ popup_attr = pair_attr(:popup, fallback: ::Curses::A_NORMAL)
175
+ input_attr = pair_attr(:popup_input, fallback: ::Curses::A_REVERSE)
176
+ selected_attr = pair_attr(:popup_selection, fallback: ::Curses::A_REVERSE)
177
+ counts_attr = pair_attr(:popup_counts, fallback: popup_attr)
178
+
179
+ win.attrset(frame_attr)
180
+ draw_popup_frame(win, width: width, height: height)
181
+
182
+ if session.title && !session.title.empty?
183
+ title = Fatty::Ansi.strip(session.title.to_s).tr("\r\n", " ")
184
+ title = Fatty::Ansi.truncate_visible(" #{title} ", [width - 4, 0].max)
185
+
186
+ unless title.empty?
187
+ win.setpos(0, 2)
188
+ win.addstr(title)
189
+ end
190
+ end
191
+
192
+ inner_h = height - 2
193
+ inner_w = width - 2
194
+ return if inner_h <= 0 || inner_w <= 0
195
+
196
+ inner = win.derwin(inner_h, inner_w, 1, 1)
197
+ inner.bkgdset(popup_attr) if inner.respond_to?(:bkgdset)
198
+ inner.erase
199
+
200
+ row = 0
201
+
202
+ if session.message && !session.message.empty?
203
+ message = Fatty::Ansi.strip(session.message.to_s).tr("\r\n", " ")
204
+ message = Fatty::Ansi.truncate_visible(message, inner_w)
205
+
206
+ inner.attrset(popup_attr)
207
+ inner.setpos(row, 0)
208
+ inner.addstr(message.ljust(inner_w))
209
+ row += 1
210
+ end
211
+
212
+ counts_present = !!session.counts
213
+ input_row = inner_h - 1
214
+ counts_row = counts_present ? input_row - 1 : nil
215
+
216
+ list_row = row
217
+ list_h = input_row - list_row
218
+ list_h -= 1 if counts_present
219
+ list_h = [list_h, 0].max
220
+
221
+ items = session.displayed
222
+ selected = session.selected
223
+ start = session.scroll_start(list_h: list_h)
224
+
225
+ (0...list_h).each do |offset|
226
+ item_index = start + offset
227
+ item_selected = item_index == selected
228
+ attr = item_selected ? selected_attr : popup_attr
229
+
230
+ line = ""
231
+ if item_index < items.length
232
+ item = items[item_index]
233
+ gutter = session.gutter_for(item: item, selected: item_selected)
234
+ text = Fatty::Ansi.strip(item.to_s).tr("\r\n", " ")
235
+ available = [inner_w - Fatty::Ansi.visible_length(gutter), 0].max
236
+ text = Fatty::Ansi.truncate_visible(text, available)
237
+ line = Fatty::Ansi.truncate_visible(gutter + text, inner_w)
238
+ end
239
+
240
+ inner.attrset(attr)
241
+ inner.setpos(list_row + offset, 0)
242
+ inner.addstr(line.ljust(inner_w))
243
+ end
244
+
245
+ if counts_present && counts_row && counts_row >= 0
246
+ counts_text = Fatty::Ansi.truncate_visible(popup_counts_text(session), inner_w)
247
+
248
+ inner.attrset(counts_attr)
249
+ inner.setpos(counts_row, 0)
250
+ inner.addstr(counts_text.ljust(inner_w))
251
+ end
252
+
253
+ render_field_into(
254
+ win: inner,
255
+ field: session.field,
256
+ row: input_row,
257
+ width: inner_w,
258
+ base_role: :popup_input,
259
+ base_attr: input_attr,
260
+ region_attr: pair_attr(:region, fallback: ::Curses::A_REVERSE),
261
+ suggestion_attr: pair_attr(:input_suggestion, fallback: input_attr),
262
+ )
263
+
264
+ cursor_x = session.field.cursor_x.to_i.clamp(0, [inner_w - 1, 0].max)
265
+ win.setpos(1 + input_row, 1 + cursor_x)
266
+
267
+ stage_window(win)
268
+ rescue RuntimeError => e
269
+ raise unless e.message.include?("closed window") ||
270
+ e.message.include?("already closed window")
271
+
272
+ nil
273
+ ensure
274
+ inner&.close if inner&.respond_to?(:close)
275
+ end
276
+
277
+ def render_prompt_popup(session:)
278
+ state = prompt_popup_state(session)
279
+ return if state == @last_prompt_popup_state
280
+
281
+ @last_prompt_popup_state = state
282
+
283
+ win = session.win
284
+ return unless win
285
+
286
+ width = win.maxx
287
+ height = win.maxy
288
+
289
+ win.erase
290
+
291
+ frame_attr = pair_attr(:popup_frame, fallback: ::Curses::A_NORMAL)
292
+ popup_attr = pair_attr(:popup, fallback: ::Curses::A_NORMAL)
293
+ input_attr = pair_attr(:popup_input, fallback: ::Curses::A_REVERSE)
294
+
295
+ win.attrset(frame_attr)
296
+ draw_popup_frame(win, width: width, height: height)
297
+
298
+ if session.title && !session.title.empty?
299
+ win.setpos(0, 2)
300
+ win.addstr(" #{Fatty::Ansi.strip(session.title)} ")
301
+ end
302
+
303
+ inner_h = height - 2
304
+ inner_w = width - 2
305
+ return if inner_h <= 0 || inner_w <= 0
306
+
307
+ inner = win.derwin(inner_h, inner_w, 1, 1)
308
+ inner.bkgdset(popup_attr) if inner.respond_to?(:bkgdset)
309
+ inner.erase
310
+ inner.attrset(popup_attr)
311
+
312
+ input_row = inner_h - 1
313
+
314
+ if session.message && !session.message.empty?
315
+ message = Fatty::Ansi.strip(session.message.to_s).tr("\r\n", " ")
316
+ message = Fatty::Ansi.truncate_visible(message, inner_w)
317
+
318
+ inner.setpos(0, 0)
319
+ inner.addstr(message.ljust(inner_w))
320
+ end
321
+
322
+ render_field_into(
323
+ win: inner,
324
+ field: session.field,
325
+ row: input_row,
326
+ width: inner_w,
327
+ base_role: :popup_input,
328
+ base_attr: input_attr,
329
+ region_attr: pair_attr(:region, fallback: ::Curses::A_REVERSE),
330
+ suggestion_attr: pair_attr(:input_suggestion, fallback: input_attr),
331
+ )
332
+
333
+ cursor_x = session.field.cursor_x.to_i.clamp(0, [inner_w - 1, 0].max)
334
+ win.setpos(1 + input_row, 1 + cursor_x)
335
+
336
+ stage_window(inner)
337
+ stage_window(win)
338
+ ensure
339
+ inner&.close if inner&.respond_to?(:close)
340
+ end
341
+
342
+ def restore_cursor(field)
343
+ win = context.input_win
344
+ return unless win
345
+
346
+ cols = win.respond_to?(:maxx) ? win.maxx : @screen.input_rect.cols
347
+ x = field.cursor_x.to_i.clamp(0, [cols - 1, 0].max)
348
+ win.setpos(0, x)
349
+ stage_window(win)
350
+ end
351
+
352
+ def begin_frame
353
+ end
354
+
355
+ def finish_frame
356
+ ::Curses.doupdate
357
+ end
358
+
359
+ # Restore cursor into the output window at a specific *output-win* row.
360
+ # `row:` is 0..(screen.output_rect.rows-1), NOT an absolute screen row.
361
+ def restore_output_cursor(field, row:)
362
+ win = context.output_win
363
+ cols = @screen.cols
364
+
365
+ x = field.cursor_x.to_i
366
+ x = x.clamp(0, [cols - 1, 0].max)
367
+
368
+ if context.truecolor
369
+ row0 = @screen.output_rect.row
370
+ col0 = @screen.output_rect.col
371
+ cols = @screen.output_rect.cols
372
+
373
+ @pending_ansi_draws << {
374
+ type: :cursor,
375
+ row: row0 + row,
376
+ col: col0 + x.clamp(0, [cols - 1, 0].max),
377
+ }
378
+ return
379
+ end
380
+ @frame_touched = true
381
+ win.setpos(row, x)
382
+ stage_window(win)
383
+ end
384
+
385
+ private
386
+
387
+ def available_colors
388
+ ::Curses.colors
389
+ end
390
+
391
+ def after_apply_theme!
392
+ @ansi_pair_cache = {}
393
+ @next_ansi_pair_id = nil
394
+ context.apply_palette(palette)
395
+ end
396
+
397
+ def can_incrementally_scroll_output?(prev, curr)
398
+ delta = curr[:top] - prev[:top]
399
+ output_rows =
400
+ if context.output_win.respond_to?(:maxy)
401
+ context.output_win.maxy
402
+ else
403
+ @screen.output_rect.rows
404
+ end
405
+
406
+ curr[:height] == output_rows &&
407
+ curr[:height] == prev[:height] &&
408
+ curr[:highlights] == prev[:highlights] &&
409
+ delta != 0 && delta.abs < curr[:height]
410
+ end
411
+
412
+ def scroll_output_window_delta!(prev:, curr:)
413
+ Fatty.debug("calling scroll_output_window_delta!", tag: :scrolling)
414
+ win = context.output_win
415
+ delta = curr[:top] - prev[:top]
416
+ base_attr = pair_attr(:output, fallback: ::Curses::A_NORMAL)
417
+
418
+ win.attrset(base_attr)
419
+ win.scrl(delta)
420
+
421
+ if delta > 0
422
+ start_y = curr[:height] - delta
423
+ start_y = 0 if start_y < 0
424
+
425
+ (start_y...curr[:height]).each do |y|
426
+ line = curr[:lines][y]
427
+ abs_line = curr[:top] + y
428
+ draw_output_row(
429
+ win,
430
+ line: line,
431
+ y: y,
432
+ abs_line: abs_line,
433
+ highlights: curr[:highlights],
434
+ )
435
+ end
436
+ else
437
+ count = -delta
438
+ count = curr[:height] if count > curr[:height]
439
+ (0...count).each do |y|
440
+ line = curr[:lines][y]
441
+ abs_line = curr[:top] + y
442
+ draw_output_row(
443
+ win,
444
+ line: line,
445
+ y: y,
446
+ abs_line: abs_line,
447
+ highlights: curr[:highlights],
448
+ )
449
+ end
450
+ end
451
+ stage_window(win)
452
+ end
453
+
454
+ def draw_output_lines(lines, viewport:, highlights: nil)
455
+ win = context.output_win
456
+ base_attr = pair_attr(:output, fallback: ::Curses::A_NORMAL)
457
+
458
+ win.attrset(base_attr)
459
+ win.bkgdset(base_attr) if win.respond_to?(:bkgdset)
460
+ win.erase
461
+
462
+ lines.each_with_index do |line, y|
463
+ abs_line = viewport.top + y
464
+ draw_output_row(
465
+ win,
466
+ line: line,
467
+ y: y,
468
+ abs_line: abs_line,
469
+ highlights: highlights,
470
+ )
471
+ end
472
+ stage_window(win)
473
+ end
474
+
475
+ def draw_output_row(win, line:, y:, abs_line:, highlights:)
476
+ base_attr = pair_attr(:output, fallback: ::Curses::A_NORMAL)
477
+ hi_attr = pair_attr(:match_current, fallback: ::Curses::A_REVERSE)
478
+ hi2_attr = pair_attr(:match_other, fallback: hi_attr)
479
+
480
+ semantic_ranges = highlight_ranges_for_line(highlights, abs_line)
481
+ win.setpos(y, 0)
482
+ win.attrset(base_attr)
483
+ curses_ranges =
484
+ Array(semantic_ranges).map do |from, to, role|
485
+ attr =
486
+ case role
487
+ when :secondary then hi2_attr
488
+ else hi_attr
489
+ end
490
+ [from.to_i, to.to_i, attr]
491
+ end
492
+ # plain = Fatty::Ansi.plain_text(line.to_s)
493
+ # slices = build_line_slices(plain, ranges: curses_ranges) do |_style|
494
+ # base_attr
495
+ # end
496
+ slices = build_line_slices(line.to_s, ranges: curses_ranges) do |style|
497
+ ansi_style_attr(style, fallback: base_attr)
498
+ end
499
+ render_slices(win, slices)
500
+ win.clrtoeol
501
+ end
502
+
503
+ # Build a draw plan for a single output line.
504
+ #
505
+ # Returns an array of [attr, text] slices.
506
+ #
507
+ # Yields each ANSI style hash and expects an attr back for non-highlight text.
508
+ # ranges are plain-text indices:
509
+ # [[from, to, attr], ...]
510
+ #
511
+ # Yields each ANSI style hash and expects an attr back for non-highlight text.
512
+ def build_line_slices(line, ranges:)
513
+ slices = []
514
+ return slices if line.nil?
515
+
516
+ ranges = Array(ranges)
517
+ pos = 0
518
+ ri = 0
519
+
520
+ Fatty::Ansi.segment(line).each do |text, style|
521
+ text = text.to_s
522
+ seg_attr = yield(style)
523
+
524
+ seg_from = pos
525
+ seg_to = pos + text.length
526
+
527
+ # advance past ranges that end before this segment
528
+ ri += 1 while ri < ranges.length && ranges[ri][1] <= seg_from
529
+
530
+ if ri >= ranges.length
531
+ emit_slice(slices, seg_attr, text)
532
+ pos = seg_to
533
+ next
534
+ end
535
+
536
+ cursor = 0
537
+ while cursor < text.length
538
+ gpos = seg_from + cursor
539
+ r = (ri < ranges.length ? ranges[ri] : nil)
540
+
541
+ if r && gpos < r[0]
542
+ # normal until next range starts
543
+ upto = [r[0], seg_to].min - seg_from
544
+ upto = text.length if upto > text.length
545
+ emit_slice(slices, seg_attr, text.slice(cursor, upto - cursor).to_s)
546
+ cursor = upto
547
+ elsif r && gpos >= r[0] && gpos < r[1]
548
+ # highlighted portion
549
+ upto = [r[1], seg_to].min - seg_from
550
+ upto = text.length if upto > text.length
551
+ emit_slice(slices, r[2], text.slice(cursor, upto - cursor).to_s)
552
+ cursor = upto
553
+
554
+ # consume this range if we reached/passed its end
555
+ if ri < ranges.length && ranges[ri][1] <= (seg_from + cursor)
556
+ ri += 1
557
+ end
558
+ else
559
+ # no active range; remainder is normal
560
+ emit_slice(slices, seg_attr, text.slice(cursor, text.length - cursor).to_s)
561
+ cursor = text.length
562
+ end
563
+ end
564
+ pos = seg_to
565
+ end
566
+ slices
567
+ end
568
+
569
+ def render_slices(win, slices)
570
+ Array(slices).each do |attr, text|
571
+ win.attrset(attr)
572
+ win.addstr(text.to_s)
573
+ end
574
+ end
575
+
576
+ def emit_slice(slices, attr, text)
577
+ return if text.nil? || text.empty?
578
+
579
+ last = slices[-1]
580
+ if last && last[0] == attr
581
+ last[1] << text
582
+ else
583
+ slices << [attr, text.dup]
584
+ end
585
+ end
586
+
587
+ def stage_window(win)
588
+ win.noutrefresh
589
+ @frame_touched = true
590
+ end
591
+
592
+ def draw_popup_frame(win, width:, height:)
593
+ b = popup_border
594
+ # top
595
+ win.setpos(0, 0)
596
+ win.addstr(b[:tl] + (b[:h] * (width - 2)) + b[:tr])
597
+ # sides
598
+ (1...(height - 1)).each do |y|
599
+ win.setpos(y, 0)
600
+ win.addstr(b[:v])
601
+ win.setpos(y, width - 1)
602
+ win.addstr(b[:v])
603
+ end
604
+ # bottom
605
+ win.setpos(height - 1, 0)
606
+ win.addstr(b[:bl] + (b[:h] * (width - 2)) + b[:br])
607
+ end
608
+
609
+ def render_field_into(win:, field:, row:, width:, base_attr:, region_attr:, suggestion_attr:, base_role: :input)
610
+ safe_width = [width - 1, 0].max
611
+ return if safe_width <= 0
612
+
613
+ win.attrset(base_attr)
614
+ win.setpos(row, 0)
615
+ win.addstr(" " * safe_width)
616
+ win.setpos(row, 0)
617
+
618
+ remaining = safe_width
619
+ field_segments(
620
+ field,
621
+ base_role: base_role,
622
+ suggestion_role: :input_suggestion,
623
+ region_role: :region,
624
+ ).each do |segment|
625
+ break if remaining <= 0
626
+
627
+ attr =
628
+ case segment[:role]
629
+ when :region then region_attr
630
+ when :input_suggestion then suggestion_attr
631
+ else base_attr
632
+ end
633
+
634
+ text = Fatty::Ansi.strip(segment[:text].to_s).tr("\r\n", " ")
635
+ text = Fatty::Ansi.truncate_visible(text, remaining)
636
+ next if text.empty?
637
+
638
+ win.attrset(attr)
639
+ win.addstr(text)
640
+ remaining -= Fatty::Ansi.visible_length(text)
641
+ end
642
+ end
643
+
644
+ def ansi_style_attr(style, fallback:)
645
+ attr = fallback
646
+ if style.fg || style.bg
647
+ fg = curses_color_for_style(style.fg)
648
+ bg = curses_color_for_style(style.bg)
649
+
650
+ output_spec = palette[:output] || {}
651
+ fg = output_spec[:fg] if fg.nil?
652
+ bg = output_spec[:bg] if bg.nil?
653
+
654
+ attr = ::Curses.color_pair(ansi_pair_for(fg, bg))
655
+ end
656
+ attr |= ::Curses::A_BOLD if style.bold
657
+ attr |= ::Curses::A_DIM if style.respond_to?(:dim) && style.dim
658
+ attr |= ::Curses::A_UNDERLINE if style.underline
659
+ attr |= ::Curses::A_REVERSE if style.reverse
660
+ attr
661
+ end
662
+
663
+ def curses_color_for_style(color)
664
+ case color
665
+ when nil
666
+ nil
667
+ when Integer
668
+ Fatty::Color.clamp_index(color, available_colors: available_colors)
669
+ when Array
670
+ Fatty::Color.resolve(
671
+ Fatty::Color.xterm_index_for_rgb(color[0], color[1], color[2]),
672
+ available_colors: available_colors,
673
+ )
674
+ end
675
+ end
676
+
677
+ def ansi_pair_for(fg, bg)
678
+ @ansi_pair_cache ||= {}
679
+ key = [fg.to_i, bg.to_i]
680
+
681
+ @ansi_pair_cache[key] ||=
682
+ begin
683
+ pair_id = next_ansi_pair_id
684
+ ::Curses.init_pair(pair_id, fg.to_i, bg.to_i)
685
+ pair_id
686
+ end
687
+ end
688
+
689
+ def next_ansi_pair_id
690
+ @next_ansi_pair_id ||= Fatty::Colors::Pairs::ROLE_TO_PAIR.values.max + 1
691
+ id = @next_ansi_pair_id
692
+ @next_ansi_pair_id += 1
693
+ id
694
+ end
695
+ end
696
+ end
697
+ end