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,1067 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'terminal/progress'
4
+ require_relative 'terminal/popup_owner'
5
+
6
+ module Fatty
7
+ class Terminal
8
+ SCROLL_RENDER_THROTTLE = 0.05
9
+ DEFAULT_STATUS_MAX_ROWS = 4
10
+
11
+ # Commands are plain Ruby arrays for now.
12
+ #
13
+ # Suggested shapes:
14
+ #
15
+ # Terminal/runtime commands:
16
+ # [:terminal, :quit]
17
+ # [:terminal, :push, session]
18
+ # [:terminal, :pop]
19
+ #
20
+ # Session-targeted commands (no special casing):
21
+ # [:send, :alert, :show, { level: :warn, message: "No matches" }]
22
+ # [:send, :alert, :clear, {}]
23
+ #
24
+ # You can add more later; Terminal only needs a small dispatcher.
25
+
26
+ attr_reader :screen, :renderer, :event_source, :status_text, :status_role, :env
27
+
28
+ def initialize(prompt: "> ",
29
+ on_accept: nil,
30
+ completion_proc: nil,
31
+ history_path: :default,
32
+ history_ctx: nil,
33
+ env: nil)
34
+ @prompt = Prompt.ensure(prompt)
35
+ @on_accept = on_accept
36
+ @completion_proc = completion_proc
37
+ @history_path = history_path
38
+ @history_ctx = history_ctx
39
+ @env = env
40
+
41
+ @running = false
42
+ @stack = []
43
+ @pinned = []
44
+ @sessions = []
45
+ @sessions_by_id = {}
46
+ @modal_stack = []
47
+ @status_text = nil
48
+ @status_role = :info
49
+ @status_transient = false
50
+ end
51
+
52
+ # --- Status line management ------------------------------------------------
53
+
54
+ def set_status(text, role: :info, transient: false)
55
+ old_rows = status_rows
56
+ str =
57
+ if text.is_a?(Array)
58
+ text.map { |part| part.is_a?(Hash) && part.key?(:text) ? part[:text] : part }.join
59
+ else
60
+ text.to_s
61
+ end
62
+
63
+ if str.empty?
64
+ @status_text = nil
65
+ @status_role = :info
66
+ @status_transient = false
67
+ else
68
+ @status_text = text
69
+ @status_role = role
70
+ @status_transient = transient
71
+ end
72
+ refresh_layout! if @screen && old_rows != status_rows
73
+ end
74
+
75
+ def clear_status
76
+ old_rows = status_rows
77
+ @status_text = nil
78
+ @status_role = :info
79
+ @status_transient = false
80
+ refresh_layout! if @screen && old_rows != status_rows
81
+ end
82
+
83
+ def transient_status?
84
+ !!@status_transient
85
+ end
86
+
87
+ # Display a message to the user in the status line, colored according to
88
+ # the Config for "info".
89
+ def info(text)
90
+ return $stderr.puts(text) unless @ctx
91
+
92
+ set_status(text.to_s, role: :info, transient: true)
93
+ end
94
+
95
+ # Display a message to the user in the status line, colored according to
96
+ # the Config for "good," i.e., success.
97
+ def good(text)
98
+ return $stderr.puts(text) unless @ctx
99
+
100
+ set_status(text.to_s, role: :good, transient: true)
101
+ end
102
+
103
+ # Display a message to the user in the status line, colored according to
104
+ # the Config for "warn," i.e., short of an error but not complete
105
+ # success either.
106
+ def warn(text)
107
+ return $stderr.puts(text) unless @ctx
108
+
109
+ set_status(text.to_s, role: :warn, transient: true)
110
+ end
111
+
112
+ # Display a message to the user in the status line, colored according to
113
+ # the Config for "oops," i.e., a soft failure.
114
+ def oops(text)
115
+ return $stderr.puts(text) unless @ctx
116
+
117
+ set_status(text.to_s, role: :error, transient: true)
118
+ end
119
+
120
+ # --- Session management ------------------------------------------------
121
+
122
+ def push(session)
123
+ Fatty.debug("Terminal#push(#{session})", tag: :session)
124
+ @stack << session
125
+ register(session)
126
+ commands = session.init(terminal: self)
127
+ apply_commands(commands)
128
+ session
129
+ end
130
+
131
+ def pop
132
+ Fatty.debug("Terminal#pop -> #{@stack.last}", tag: :session)
133
+ @stack.pop
134
+ end
135
+
136
+ def pin(session)
137
+ Fatty.debug("Terminal#pin(#{session})", tag: :session)
138
+ @pinned << session
139
+ register(session)
140
+ commands = session.init(terminal: self)
141
+ apply_commands(commands)
142
+ session
143
+ end
144
+
145
+ def active_session
146
+ top = @modal_stack.last
147
+ return top[:session] if top
148
+
149
+ focused_session
150
+ end
151
+
152
+ def focused_session
153
+ top = @stack.last
154
+ return top[:session] if top.is_a?(Hash)
155
+
156
+ top
157
+ end
158
+
159
+ def register(session)
160
+ return unless session.respond_to?(:id)
161
+ return if session.id.nil?
162
+
163
+ @sessions_by_id[session.id] = session
164
+ end
165
+
166
+ def find_session(id)
167
+ @sessions_by_id[id]
168
+ end
169
+
170
+ def push_modal(session, owner:)
171
+ @modal_stack << { session: session, owner: owner }
172
+ msg = "Terminal#push_modal: size=#{@modal_stack.length} session=#{session.class} object_id=#{session.object_id}"
173
+ Fatty.debug(msg, tag: :session)
174
+ register(session)
175
+ @renderer.invalidate! if defined?(@renderer) && @renderer
176
+ commands = session.init(terminal: self)
177
+ apply_commands(commands)
178
+ end
179
+
180
+ def pop_modal
181
+ top = @modal_stack.pop
182
+ msg = "Terminal#pop_modal: size=#{@modal_stack.length} popped=#{top && top[:session].class}"
183
+ Fatty.debug(msg, tag: :session)
184
+ session = top && top[:session]
185
+
186
+ session.close if session&.respond_to?(:close)
187
+ @renderer&.invalidate!
188
+ nil
189
+ end
190
+
191
+ # Return the owner of the top modal session without modifying the stack.
192
+ def modal_owner
193
+ top = @modal_stack.last
194
+ top && top[:owner]
195
+ end
196
+
197
+ # --- Runtime -----------------------------------------------------------
198
+
199
+ def go
200
+ preflight!
201
+ start_curses!
202
+ install_default_sessions!
203
+
204
+ @running = true
205
+ last_render = Process.clock_gettime(Process::CLOCK_MONOTONIC)
206
+ pending_scroll_render = false
207
+ render_frame
208
+
209
+ # For performance logging
210
+ perf_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
211
+ loop_count = 0
212
+ event_count = 0
213
+ tick_dirty_count = 0
214
+ render_count = 0
215
+ deferred_count = 0
216
+ frame_ms = 0.0
217
+
218
+ while @running
219
+ loop_count += 1
220
+
221
+ dirty = false
222
+ immediate = false
223
+
224
+ msg = event_source.next_event
225
+ if msg
226
+ dispatch_message(msg)
227
+ dirty = true
228
+ immediate = true
229
+ end
230
+
231
+ s = active_session
232
+ begin
233
+ tick_dirty = !!s&.tick
234
+ tick_dirty_count += 1 if tick_dirty
235
+ dirty ||= tick_dirty
236
+ rescue StandardError => e
237
+ Fatty.error("Terminal#go tick failed: #{e.class}: #{e.message}", tag: :terminal)
238
+ dirty = true
239
+ immediate = true
240
+ end
241
+
242
+ if dirty
243
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
244
+
245
+ if immediate || renderer.context.truecolor || !scrolling_output?
246
+ render_frame
247
+ last_render = now
248
+ pending_scroll_render = false
249
+ render_count += 1
250
+ elsif now - last_render >= SCROLL_RENDER_THROTTLE
251
+ render_frame
252
+ last_render = now
253
+ pending_scroll_render = false
254
+ render_count += 1
255
+ else
256
+ pending_scroll_render = true
257
+ deferred_count += 1
258
+ end
259
+ elsif pending_scroll_render
260
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
261
+ if now - last_render >= SCROLL_RENDER_THROTTLE
262
+ render_frame
263
+ last_render = now
264
+ pending_scroll_render = false
265
+ render_count += 1
266
+ end
267
+ end
268
+
269
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
270
+ if now - perf_started_at >= 1.0
271
+ avg_frame_ms =
272
+ if render_count.zero?
273
+ 0.0
274
+ else
275
+ (frame_ms / render_count).round(2)
276
+ end
277
+
278
+ # Performance logging
279
+ Fatty.debug(
280
+ "perf loops=#{loop_count} events=#{event_count} " \
281
+ "tick_dirty=#{tick_dirty_count} renders=#{render_count} " \
282
+ "deferred=#{deferred_count} avg_frame_ms=#{avg_frame_ms} " \
283
+ "scrolling=#{scrolling_output?}",
284
+ tag: :perf,
285
+ )
286
+ perf_started_at = now
287
+ loop_count = 0
288
+ event_count = 0
289
+ tick_dirty_count = 0
290
+ render_count = 0
291
+ deferred_count = 0
292
+ frame_ms = 0.0
293
+ end
294
+ end
295
+ rescue => e
296
+ Fatty.error("Terminal#go fatal error: #{e.class}: #{e.message}", tag: :terminal)
297
+ Fatty.error(e.backtrace.join("\n"), tag: :terminal) if e.backtrace
298
+ raise
299
+ ensure
300
+ begin
301
+ stop_curses!
302
+ rescue => e
303
+ Fatty.error("Terminal#go stop_curses! failed: #{e.class}: #{e.message}", tag: :terminal)
304
+ Fatty.error(e.backtrace.join("\n"), tag: :terminal) if e.backtrace
305
+ end
306
+
307
+ begin
308
+ persist_sessions!
309
+ rescue => e
310
+ Fatty.error("Terminal#go persist_sessions! failed: #{e.class}: #{e.message}", tag: :terminal)
311
+ Fatty.error(e.backtrace.join("\n"), tag: :terminal) if e.backtrace
312
+ end
313
+ end
314
+
315
+ # The consumer can call #choose to cause an interactive popup session to
316
+ # present the user with a series of choices to select from.
317
+ def choose(prompt, choices:, initial_choice_idx: 0, quit_value: nil)
318
+ items = normalize_choices(choices)
319
+ raise ArgumentError, "choices must not be empty" if items.empty?
320
+
321
+ labels = items.map(&:first)
322
+ popup = Fatty::PopUpSession.new(
323
+ source: labels,
324
+ kind: :terminal_choose,
325
+ title: "Choose",
326
+ message: prompt,
327
+ prompt: "> ",
328
+ selection: :top,
329
+ validate_unique_labels: true,
330
+ )
331
+
332
+ popup.instance_variable_set(:@selected, initial_choice_idx.to_i.clamp(0, labels.length - 1))
333
+
334
+ done = false
335
+ result = nil
336
+
337
+ acc_proc = ->(payload) do
338
+ item = payload[:item]
339
+ idx = labels.index(item)
340
+ result = idx ? items[idx][1] : quit_value
341
+ done = true
342
+ end
343
+
344
+ cancel_proc = -> do
345
+ result = quit_value
346
+ done = true
347
+ end
348
+
349
+ owner = PopupOwner.new(on_result: acc_proc, on_cancel: cancel_proc)
350
+ begin
351
+ push_modal(popup, owner: owner)
352
+ render_frame
353
+
354
+ while !done && @running
355
+ dirty = false
356
+ msg = event_source.next_event
357
+ if msg
358
+ dispatch_message(msg)
359
+ dirty = true
360
+ end
361
+
362
+ s = active_session
363
+ begin
364
+ tick_dirty = !!s&.tick
365
+ dirty ||= tick_dirty
366
+ rescue StandardError => e
367
+ Fatty.error("Terminal#choose tick failed: #{e.class}: #{e.message}", tag: :terminal)
368
+ dirty = true
369
+ end
370
+
371
+ render_frame if dirty
372
+ end
373
+ ensure
374
+ render_frame
375
+ end
376
+
377
+ result
378
+ end
379
+
380
+ # A simple Yes/No chooser.
381
+ def confirm(prompt, default: true)
382
+ idx = default ? 0 : 1
383
+ choose(
384
+ prompt,
385
+ choices: [["Yes", true], ["No", false]],
386
+ initial_choice_idx: idx,
387
+ quit_value: false,
388
+ )
389
+ end
390
+
391
+ # The consumer can call #choose_multi to cause an interactive popup session to
392
+ # present the user with a series of choices to select from.
393
+ def choose_multi(prompt, choices:, quit_value: nil)
394
+ items = normalize_choices(choices)
395
+ raise ArgumentError, "choices must not be empty" if items.empty?
396
+
397
+ labels = items.map(&:first)
398
+ popup = Fatty::PopUpSession.new(
399
+ source: labels,
400
+ kind: :terminal_choose_multi,
401
+ title: "Choose Many",
402
+ message: prompt,
403
+ prompt: "> ",
404
+ selection: :top,
405
+ selection_mode: :multiple,
406
+ validate_unique_labels: true,
407
+ )
408
+
409
+ done = false
410
+ result = nil
411
+
412
+ label_to_value = items.to_h
413
+ acc_proc = ->(payload) do
414
+ selected = payload[:items] || {}
415
+
416
+ result =
417
+ selected.each_with_object({}) do |(label, _), h|
418
+ h[label] = label_to_value.fetch(label, quit_value)
419
+ end
420
+
421
+ done = true
422
+ end
423
+
424
+ cancel_proc = -> do
425
+ result = quit_value
426
+ done = true
427
+ end
428
+
429
+ owner = PopupOwner.new(on_result: acc_proc, on_cancel: cancel_proc)
430
+
431
+ begin
432
+ push_modal(popup, owner: owner)
433
+ render_frame
434
+
435
+ while !done && @running
436
+ dirty = false
437
+ msg = event_source.next_event
438
+ if msg
439
+ dispatch_message(msg)
440
+ dirty = true
441
+ end
442
+
443
+ s = active_session
444
+ begin
445
+ tick_dirty = !!s&.tick
446
+ dirty ||= tick_dirty
447
+ rescue StandardError => e
448
+ Fatty.error("Terminal#choose_multi tick failed: #{e.class}: #{e.message}", tag: :terminal)
449
+ dirty = true
450
+ end
451
+
452
+ render_frame if dirty
453
+ end
454
+ ensure
455
+ render_frame
456
+ end
457
+
458
+ result
459
+ end
460
+
461
+ # Create a popup to ask the user to enter an arbitrary string. These
462
+ # prompts will keep their own history based on the history_key, or if not
463
+ # history_key is given, the prompt text.
464
+ def prompt(prompt, initial: "", quit_value: nil, history_key: nil)
465
+ history_ctx = { prompt: (history_key || prompt).to_s }
466
+
467
+ popup = Fatty::PromptSession.new(
468
+ title: "Prompt",
469
+ message: prompt,
470
+ prompt: "> ",
471
+ initial: initial,
472
+ kind: :terminal_prompt,
473
+ history_ctx: history_ctx,
474
+ )
475
+ done = false
476
+ result = nil
477
+
478
+ acc_proc = ->(payload) do
479
+ result = payload[:text]
480
+ done = true
481
+ end
482
+
483
+ cancel_proc = -> do
484
+ result = quit_value
485
+ done = true
486
+ end
487
+
488
+ owner = PopupOwner.new(on_result: acc_proc, on_cancel: cancel_proc)
489
+
490
+ begin
491
+ push_modal(popup, owner: owner)
492
+ render_frame
493
+
494
+ while !done && @running
495
+ dirty = false
496
+ msg = event_source.next_event
497
+ if msg
498
+ dispatch_message(msg)
499
+ dirty = true
500
+ end
501
+
502
+ s = active_session
503
+ begin
504
+ tick_dirty = !!s&.tick
505
+ dirty ||= tick_dirty
506
+ rescue StandardError => e
507
+ Fatty.error("Terminal#prompt tick failed: #{e.class}: #{e.message}", tag: :terminal)
508
+ dirty = true
509
+ end
510
+
511
+ render_frame if dirty
512
+ end
513
+ ensure
514
+ render_frame
515
+ end
516
+
517
+ result
518
+ end
519
+
520
+ # Create a transient status-line progress indicator.
521
+ # For style :spinner, total may be omitted for indeterminate progress.
522
+ def progress(label:, total: nil, style: :percent, role: :info, width: 40)
523
+ Progress.new(
524
+ terminal: self,
525
+ label: label,
526
+ total: total,
527
+ style: style,
528
+ role: role,
529
+ width: width,
530
+ )
531
+ end
532
+
533
+ # Present a chooser whose selected value is executed.
534
+ #
535
+ # choices may be:
536
+ # [["Label", proc { ... }], ...]
537
+ # or:
538
+ # { "Label" => proc { ... } }
539
+ #
540
+ # The proc may accept:
541
+ # 0 args
542
+ # terminal:
543
+ # terminal:, label:
544
+ # terminal:, label:, payload:
545
+ def menu(prompt, choices:, initial_choice_idx: 0, quit_value: nil)
546
+ items = normalize_choices(choices)
547
+ raise ArgumentError, "choices must not be empty" if items.empty?
548
+
549
+ labels = items.map(&:first)
550
+ popup = Fatty::PopUpSession.new(
551
+ source: labels,
552
+ kind: :terminal_menu,
553
+ title: "Menu",
554
+ message: prompt,
555
+ prompt: "> ",
556
+ selection: :top,
557
+ validate_unique_labels: true,
558
+ )
559
+
560
+ popup.instance_variable_set(:@selected, initial_choice_idx.to_i.clamp(0, labels.length - 1))
561
+
562
+ done = false
563
+ result = nil
564
+ menu_session = active_session
565
+ acc_proc = ->(payload) do
566
+ label = payload[:item]
567
+ idx = labels.index(label)
568
+ action = idx ? items[idx][1] : nil
569
+ result = call_menu_action(
570
+ action,
571
+ session: menu_session,
572
+ label: label,
573
+ payload: payload,
574
+ )
575
+ done = true
576
+ end
577
+ cancel_proc = -> do
578
+ result = quit_value
579
+ done = true
580
+ end
581
+
582
+ owner = PopupOwner.new(on_result: acc_proc, on_cancel: cancel_proc)
583
+ begin
584
+ push_modal(popup, owner: owner)
585
+ render_frame
586
+
587
+ while !done && @running
588
+ dirty = false
589
+ msg = event_source.next_event
590
+
591
+ if msg
592
+ dispatch_message(msg)
593
+ dirty = true
594
+ end
595
+
596
+ s = active_session
597
+ begin
598
+ tick_dirty = !!s&.tick
599
+ dirty ||= tick_dirty
600
+ rescue StandardError => e
601
+ Fatty.error("Terminal#menu tick failed: #{e.class}: #{e.message}", tag: :terminal)
602
+ dirty = true
603
+ end
604
+
605
+ render_frame if dirty
606
+ end
607
+ ensure
608
+ render_frame
609
+ end
610
+ result
611
+ end
612
+
613
+ def call_menu_action(action, session:, label:, payload:)
614
+ if action.respond_to?(:call)
615
+ env = MenuEnv.new(
616
+ terminal: self,
617
+ session: session,
618
+ label: label,
619
+ payload: payload,
620
+ )
621
+ if action.arity.zero?
622
+ action.call
623
+ else
624
+ action.call(env)
625
+ end
626
+ else
627
+ action
628
+ end
629
+ end
630
+
631
+ def render_now
632
+ render_frame
633
+ end
634
+
635
+ def status_visible?
636
+ @status_text && !@status_text.empty?
637
+ end
638
+
639
+ def status_rows
640
+ return 0 unless status_visible?
641
+
642
+ status_lines.length.clamp(1, status_max_rows)
643
+ end
644
+
645
+ def status_max_rows
646
+ Fatty::Config.config.dig(:status, :max_rows)&.to_i || DEFAULT_STATUS_MAX_ROWS
647
+ end
648
+
649
+ def status_lines
650
+ width = screen&.cols || 80
651
+
652
+ @status_text.to_s
653
+ .lines
654
+ .flat_map { |line| wrap_status_line(line.chomp, width) }
655
+ .last(status_max_rows)
656
+ end
657
+
658
+ private
659
+
660
+ def wrap_status_line(line, width)
661
+ text = Fatty::Ansi.strip(line.to_s)
662
+ return [""] if text.empty?
663
+
664
+ # Good enough first pass: no soft wrap within words.
665
+ # Later this should use visible-width-aware wrapping.
666
+ text.scan(/.{1,#{[width, 1].max}}/)
667
+ end
668
+
669
+ def preflight!
670
+ Fatty::Config.config
671
+ Fatty::Logger.configure
672
+ if Fatty::Logger.logger
673
+ Fatty.info("Logger configured to log to #{Logger.path}")
674
+ Fatty.info("Read config from #{Config.user_config_path}", tag: :config)
675
+ Fatty.info("Config", config: Config.config, tag: :config)
676
+ end
677
+ Fatty::Config.keydefs
678
+ Fatty::Config.keybindings
679
+ Fatty::Config.install_default_themes!
680
+ Fatty::Themes::Manager.load!
681
+ Thread.report_on_exception = true
682
+ rescue FatConfig::ParseError => ex
683
+ msg = "Terminal#preflight!: configuration error: #{ex.class}: #{ex.message}"
684
+ warn msg
685
+ begin
686
+ Fatty.error(msg, tag: :config)
687
+ rescue StandardError
688
+ nil
689
+ end
690
+ exit(1)
691
+ end
692
+
693
+ def start_curses!
694
+ @ctx = Fatty::Curses::Context.new
695
+ @ctx.start
696
+
697
+ @screen = Fatty::Screen.new(rows: ::Curses.lines, cols: ::Curses.cols, status_rows: status_rows)
698
+ @ctx.apply_layout(@screen)
699
+
700
+ @renderer =
701
+ if @ctx.truecolor
702
+ Fatty::Renderer::Truecolor.new(context: @ctx, screen: @screen, palette: @ctx.palette)
703
+ else
704
+ Fatty::Renderer::Curses.new(context: @ctx, screen: @screen, palette: @ctx.palette)
705
+ end
706
+ @renderer.sync_backgrounds! if @ctx.truecolor
707
+
708
+ @env ||= Fatty::Env.detect
709
+ key_decoder = Fatty::Curses::KeyDecoder.new(env: @env)
710
+ @event_source =
711
+ Fatty::Curses::EventSource.new(context: @ctx, key_decoder: key_decoder, poll_ms: 50)
712
+ self
713
+ end
714
+
715
+ def stop_curses!
716
+ @ctx&.close
717
+ ensure
718
+ begin
719
+ $stdout.write("\e[0m") # SGR reset
720
+ $stdout.write("\e[0 q") # DECSCUSR: restore terminal default cursor
721
+ $stdout.flush
722
+ rescue StandardError
723
+ # best-effort cleanup
724
+ end
725
+ end
726
+
727
+ def install_default_sessions!
728
+ pin(Fatty::AlertSession.new)
729
+ push(Fatty::ShellSession.new(
730
+ prompt: @prompt,
731
+ on_accept: @on_accept,
732
+ completion_proc: @completion_proc,
733
+ history_path: @history_path,
734
+ history_ctx: @history_ctx,
735
+ ))
736
+ end
737
+
738
+ def scrolling_output?
739
+ session = active_session
740
+ pager =
741
+ if session.respond_to?(:pager)
742
+ session.pager
743
+ end
744
+
745
+ pager && pager.mode == :scrolling
746
+ end
747
+
748
+ def refresh_layout!
749
+ screen.resize(
750
+ rows: @ctx.rows,
751
+ cols: @ctx.cols,
752
+ status_rows: status_rows,
753
+ )
754
+ @ctx.apply_layout(screen)
755
+ renderer.screen = screen
756
+ if (session = focused_session)
757
+ session.resize_output! if session.respond_to?(:resize_output!)
758
+ end
759
+ if (top = @modal_stack.last)
760
+ session = top[:session]
761
+ apply_commands(session.handle_resize) if session.respond_to?(:handle_resize)
762
+ end
763
+ renderer.sync_backgrounds! if renderer.context.truecolor
764
+ renderer.invalidate!
765
+ end
766
+
767
+ def resize_message?(message)
768
+ kind, event = message
769
+ kind == :key && event.respond_to?(:key) && event.key == :resize
770
+ end
771
+
772
+ def handle_resize
773
+ rows = ::Curses.lines
774
+ cols = ::Curses.cols
775
+ size = [rows, cols]
776
+
777
+ return [] if size == @last_handled_resize_size
778
+
779
+ @last_handled_resize_size = size
780
+
781
+ did_resize_term = false
782
+
783
+ # ncurses must be told to finalize internal resize state before we
784
+ # draw ANSI output. Without this, ANSI writes after resize may be
785
+ # ignored or clipped. Guarded to avoid recursive resize storms.
786
+ unless @inside_resize_term
787
+ @inside_resize_term = true
788
+ did_resize_term = true
789
+
790
+ # Use ncurses' high-level resize finalizer. This updates stdscr/curscr
791
+ # and ncurses bookkeeping before we rebuild Fatty's own layout and draw
792
+ # the ANSI overlay. resize_term is lower-level and is not equivalent here.
793
+ ::Curses.resizeterm(rows, cols)
794
+ end
795
+
796
+ renderer.clear_physical_screen! if renderer.context.truecolor
797
+
798
+ screen.resize(rows: rows, cols: cols, status_rows: status_rows)
799
+ renderer.context.apply_layout(screen)
800
+ renderer.screen = screen
801
+ renderer.sync_backgrounds! if renderer.context.truecolor
802
+ renderer.invalidate!
803
+
804
+ if (out = focused_session)
805
+ out.resize_output! if out.respond_to?(:resize_output!)
806
+ end
807
+
808
+ if (top = @modal_stack.last)
809
+ session = top[:session]
810
+ cmds = session.handle_resize
811
+ apply_commands(cmds)
812
+ end
813
+ []
814
+ ensure
815
+ @inside_resize_term = false if did_resize_term
816
+ end
817
+
818
+ def persist_sessions!
819
+ sessions = []
820
+ sessions << @focused_session if @focused_session
821
+ sessions.concat(@sessions) if defined?(@sessions) && @sessions
822
+
823
+ sessions.uniq.each do |s|
824
+ next unless s.respond_to?(:persist!)
825
+
826
+ s.persist!
827
+ end
828
+ end
829
+
830
+ def quit
831
+ @running = false
832
+ end
833
+
834
+ # --- Dispatch ----------------------------------------------------------
835
+
836
+ def dispatch_message(message)
837
+ s = active_session
838
+ return [] unless s
839
+
840
+ # Clear transient alerts on the next user keypress.
841
+ if key_event_message?(message) && !resize_message?(message) && find_session(:alert)
842
+ apply_command([:send, :alert, :clear, {}])
843
+ end
844
+
845
+ # Clear transient status line on the next user keypress.
846
+ if key_event_message?(message) && transient_status?
847
+ clear_status
848
+ end
849
+
850
+ Fatty.debug("Terminal#dispatch_message: #{message.inspect}", tag: :session)
851
+ commands = s.update(message)
852
+ Fatty.debug("Terminal#dispatch_message: session=#{s.class} -> cmds=#{commands.inspect}", tag: :session)
853
+
854
+ apply_commands(commands)
855
+ end
856
+
857
+ # Return whether message is a key message
858
+ def key_event_message?(message)
859
+ message.is_a?(Array) && message[0] == :key
860
+ end
861
+
862
+ def apply_commands(commands)
863
+ Array(commands).each do |cmd|
864
+ apply_command(cmd)
865
+ end
866
+ end
867
+
868
+ # A command is either bound for this Terminal (first element :terminal) or
869
+ # it's meant to be forwarded to a Session (first element :send). This
870
+ # method routes the command to its proper destination.
871
+ def apply_command(cmd)
872
+ Fatty.debug("Terminal#apply_command(#{cmd})", tag: :session)
873
+ return if cmd.nil?
874
+
875
+ unless cmd.is_a?(Array) && cmd.first.is_a?(Symbol)
876
+ raise ArgumentError, "command must be an Array starting with a Symbol, got: #{cmd.inspect}"
877
+ end
878
+
879
+ case cmd[0]
880
+ when :terminal
881
+ apply_terminal_command(cmd)
882
+ when :send
883
+ apply_send_command(cmd)
884
+ else
885
+ raise ArgumentError, "unknown command domain #{cmd[0].inspect} (cmd=#{cmd.inspect})"
886
+ end
887
+ end
888
+
889
+ # Apply a command meant to be applied by this Terminal
890
+ def apply_terminal_command(cmd)
891
+ _, name, *rest = cmd
892
+ case name
893
+ when :quit
894
+ persist_sessions!
895
+ quit
896
+ when :push
897
+ session = rest.fetch(0)
898
+ push(session)
899
+ when :pop
900
+ pop
901
+ when :push_modal
902
+ session = rest.fetch(0)
903
+ Fatty.debug("Terminal#apply_terminal_command(:push_modal) before size=#{@modal_stack.length}", tag: :session)
904
+ push_modal(session, owner: focused_session)
905
+ Fatty.debug("Terminal#apply_terminal_command(:push_modal) after size=#{@modal_stack.length}", tag: :session)
906
+ when :pop_modal
907
+ Fatty.debug("Terminal#apply_terminal_command(:pop_modal) before size=#{@modal_stack.length}", tag: :session)
908
+ pop_modal
909
+ Fatty.debug("Terminal#apply_terminal_command(:pop_modal) aftersize=#{@modal_stack.length}", tag: :session)
910
+ when :send_modal_owner
911
+ msg = rest.fetch(0)
912
+ owner = modal_owner
913
+ # cmds = owner ? owner.update(msg, terminal: self) : []
914
+ cmds = owner ? owner.update(msg) : []
915
+ apply_commands(cmds)
916
+ when :cycle_theme
917
+ new_theme = Fatty::Themes::Manager.cycle
918
+ renderer.apply_theme!(new_theme)
919
+ apply_command([:send, :alert, :show, { level: :info, message: "Theme: #{new_theme}" }])
920
+ when :set_theme
921
+ theme = rest.fetch(0)
922
+ Fatty::Themes::Manager.set(theme)
923
+ renderer.apply_theme!(theme)
924
+ apply_command([:send, :alert, :show, { level: :info, message: "Theme: #{theme}" }])
925
+ when :handle_resize
926
+ handle_resize
927
+ else
928
+ raise ArgumentError, "unknown terminal command #{name.inspect} (cmd=#{cmd.inspect})"
929
+ end
930
+ end
931
+
932
+ # Forward a command meant to be applied by a
933
+ # [:send, recipient, message_name, payload_hash]
934
+ def apply_send_command(cmd)
935
+ _, recipient, message_name, payload = cmd
936
+
937
+ session = find_session(recipient)
938
+ raise ArgumentError, "no session registered with id=#{recipient.inspect}" unless session
939
+
940
+ payload ||= {}
941
+ unless payload.is_a?(Hash)
942
+ raise ArgumentError, "send payload must be a Hash, got: #{payload.inspect}"
943
+ end
944
+
945
+ # Deliver as a uniform "command message" array for now.
946
+ # Sessions can pattern-match it in #update.
947
+ message = [:cmd, message_name, payload]
948
+
949
+ commands = session.update(message)
950
+ apply_commands(commands)
951
+ end
952
+
953
+ def prompt_history
954
+ @prompt_history ||= Fatty::History.new(path: :default)
955
+ end
956
+
957
+ # --- Choose helpers ---------------------------------------------------------
958
+
959
+ def normalize_choices(choices)
960
+ Array(choices).map do |choice|
961
+ if choice.is_a?(Array) && choice.length == 2
962
+ [choice[0].to_s, choice[1]]
963
+ else
964
+ [choice.to_s, choice]
965
+ end
966
+ end
967
+ end
968
+
969
+ # --- Rendering ---------------------------------------------------------
970
+
971
+ def render_frame
972
+ renderer.begin_frame
973
+ sessions = @pinned + @stack
974
+ sessions.each do |s|
975
+ s.view(screen: screen, renderer: renderer)
976
+ end
977
+ Fatty::StatusView.new.render(
978
+ screen: screen,
979
+ renderer: renderer,
980
+ terminal: self,
981
+ )
982
+ if (top = @modal_stack.last)
983
+ top[:session].view(screen: screen, renderer: renderer)
984
+ end
985
+
986
+ restore_active_cursor
987
+ renderer.finish_frame
988
+ end
989
+
990
+ def restore_active_cursor
991
+ if @modal_stack && !@modal_stack.empty?
992
+ session = @modal_stack.last[:session]
993
+
994
+ if session.respond_to?(:pager_active?) && session.pager_active?
995
+ ::Curses.curs_set(0)
996
+ return
997
+ end
998
+
999
+ if session.respond_to?(:win) && session.respond_to?(:field) && session.field
1000
+ win = session.win
1001
+ unless win
1002
+ ::Curses.curs_set(0)
1003
+ return
1004
+ end
1005
+
1006
+ begin
1007
+ maxy = win.maxy
1008
+ maxx = win.maxx
1009
+ rescue RuntimeError
1010
+ ::Curses.curs_set(0)
1011
+ return
1012
+ end
1013
+
1014
+ # If the popup is too small to place a cursor safely, just hide it.
1015
+ if maxy < 3 || maxx < 3
1016
+ ::Curses.curs_set(0)
1017
+ return
1018
+ end
1019
+
1020
+ ::Curses.curs_set(1)
1021
+
1022
+ cursor_x = session.field.cursor_x
1023
+ cursor_x = 0 if cursor_x.nil?
1024
+
1025
+ if session.is_a?(Fatty::PopUpSession)
1026
+ input_row = maxy - 2
1027
+ cursor_x = cursor_x.clamp(0, [maxx - 3, 0].max)
1028
+ begin
1029
+ win.setpos(input_row, 1 + cursor_x)
1030
+ rescue RuntimeError
1031
+ ::Curses.curs_set(0)
1032
+ end
1033
+ elsif session.is_a?(Fatty::PromptSession)
1034
+ message_rows = session.message && !session.message.empty? ? 1 : 0
1035
+ input_row = 1 + message_rows
1036
+ cursor_x = cursor_x.clamp(0, [maxx - 3, 0].max)
1037
+ begin
1038
+ win.setpos(input_row, 1 + cursor_x)
1039
+ rescue RuntimeError
1040
+ ::Curses.curs_set(0)
1041
+ end
1042
+ else
1043
+ ::Curses.curs_set(0)
1044
+ end
1045
+
1046
+ return
1047
+ end
1048
+
1049
+ ::Curses.curs_set(0)
1050
+ return
1051
+ end
1052
+
1053
+ session = active_session
1054
+ return unless session
1055
+
1056
+ if session.respond_to?(:pager_active?) && session.pager_active?
1057
+ ::Curses.curs_set(0)
1058
+ return
1059
+ end
1060
+
1061
+ return unless session.respond_to?(:field) && session.field
1062
+
1063
+ ::Curses.curs_set(1)
1064
+ renderer.restore_cursor(session.field)
1065
+ end
1066
+ end
1067
+ end