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,540 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ class PopUpSession < ModalSession
5
+ action_on :session
6
+
7
+ attr_reader :field, :filtered, :displayed, :selected, :title, :message
8
+
9
+ MAX_WIDTH = 120
10
+ DEFAULT_HEIGHT = 12
11
+ MIN_LIST_H = 3
12
+ MAX_LIST_H = 20
13
+ MARGIN = 2
14
+
15
+ SELECTED_GUTTER = '[X] '
16
+ UNSELECTED_GUTTER = '[ ] '
17
+
18
+ # API:
19
+ # - source: Proc that returns the candidate list. May accept (query) or be arity 0.
20
+ # - matcher: Proc (item, query) -> truthy. Defaults to substring match.
21
+ # - order: :as_given (default) or :reverse (presentation order).
22
+ # - selection: :preserve (default), :top, :bottom (how selection behaves after refresh).
23
+ def initialize(
24
+ source:,
25
+ title: nil,
26
+ message: nil,
27
+ prompt: "> ",
28
+ keymap: Keymaps.emacs,
29
+ matcher: nil,
30
+ order: :as_given,
31
+ kind: nil,
32
+ selection: :preserve,
33
+ initial_query: nil,
34
+ selection_mode: :single,
35
+ validate_unique_labels: false
36
+ )
37
+ super(keymap: keymap)
38
+ @source = source
39
+ @title = title&.to_s
40
+ @message = message&.to_s
41
+ @prompt = Prompt.ensure(prompt)
42
+ @matcher = matcher || method(:default_matcher)
43
+ @order = order.to_sym
44
+ @kind = kind&.to_sym
45
+ @selection = selection.to_sym
46
+ @selection_mode = selection_mode.to_sym
47
+ @validate_unique_labels = !!validate_unique_labels
48
+
49
+ @field = InputField.new(prompt: @prompt)
50
+ text = initial_query.to_s
51
+ @field.buffer.replace(text) unless text.empty?
52
+
53
+ @items = []
54
+ @filtered = []
55
+ @displayed = []
56
+ @selected = 0
57
+ @selected_labels = {}
58
+
59
+ @last_query = nil
60
+ @scroll_start = 0
61
+ end
62
+
63
+ #########################################################################################
64
+ # Framework and Session Hooks
65
+ #########################################################################################
66
+
67
+ def init(terminal:)
68
+ refresh_items
69
+ rebuild_windows!
70
+ notify_owner(:popup_changed)
71
+ end
72
+
73
+ def keymap_contexts
74
+ contexts = [:popup, :text]
75
+ contexts.unshift(:popup_multi) if multi_select?
76
+ contexts
77
+ end
78
+
79
+ def view(screen:, renderer:)
80
+ Fatty.debug("PopupSession#view: object_id=#{object_id} win_nil=#{@win.nil?}", tag: :session)
81
+ return unless @win
82
+
83
+ renderer.render_popup(session: self)
84
+ end
85
+
86
+ # Return the outer width and height of the window for this modal,
87
+ # including any padding and borders.
88
+ def geometry(cols:, rows:)
89
+ max_w = max_width(cols: cols, margin: MARGIN, min_width: 10)
90
+ max_h = max_height(rows: rows, margin: MARGIN, min_height: 5)
91
+
92
+ desired_list_h = @filtered.length.clamp(MIN_LIST_H, MAX_LIST_H)
93
+ height = clamp_height(
94
+ desired_list_h + popup_extra_rows,
95
+ max_height: max_h,
96
+ min_height: 6,
97
+ )
98
+ width = clamp_width(
99
+ MAX_WIDTH,
100
+ max_width: max_w,
101
+ )
102
+ [width, height]
103
+ end
104
+
105
+ ############################################################################################
106
+ # Actions
107
+ ############################################################################################
108
+
109
+ action :popup_cancel do
110
+ notify_owner(:popup_cancelled) + [[:terminal, :pop_modal]]
111
+ end
112
+
113
+ action :popup_accept do
114
+ accept_selection
115
+ end
116
+
117
+ action :popup_next do
118
+ move_selected_by(1)
119
+ ensure_scroll_visible
120
+ notify_owner(:popup_changed)
121
+ end
122
+
123
+ action :popup_prev do
124
+ move_selected_by(-1)
125
+ ensure_scroll_visible
126
+ notify_owner(:popup_changed)
127
+ end
128
+
129
+ action :popup_page_down do
130
+ move_selected_by(popup_list_height)
131
+ ensure_scroll_visible
132
+ notify_owner(:popup_changed)
133
+ end
134
+
135
+ action :popup_page_up do
136
+ move_selected_by(-popup_list_height)
137
+ ensure_scroll_visible
138
+ notify_owner(:popup_changed)
139
+ end
140
+
141
+ action :popup_top do
142
+ unless @filtered.empty?
143
+ @selected = 0
144
+ recenter_scroll
145
+ end
146
+ notify_owner(:popup_changed)
147
+ end
148
+
149
+ action :popup_bottom do
150
+ unless @filtered.empty?
151
+ @selected = [@filtered.length - 1, 0].max
152
+ recenter_scroll
153
+ end
154
+ notify_owner(:popup_changed)
155
+ end
156
+
157
+ action :popup_recenter do
158
+ recenter_scroll
159
+ []
160
+ end
161
+
162
+ action :popup_toggle_selected do
163
+ if multi_select?
164
+ toggle_selected_current!
165
+ ensure_scroll_visible
166
+ notify_owner(:popup_changed)
167
+ else
168
+ []
169
+ end
170
+ end
171
+
172
+ def handle_action(action, args, event:)
173
+ env = action_env(event: event)
174
+
175
+ if Fatty::Actions.lookup(action)&.fetch(:on) == :session
176
+ Fatty::Actions.call(action, env, *args)
177
+ else
178
+ @field.act_on(action, *args, env: env)
179
+ refresh_items_if_query_changed
180
+ ensure_scroll_visible
181
+ notify_owner(:popup_changed)
182
+ end
183
+ rescue ActionError => e
184
+ Fatty.error("PopUpSession#handle_action: ActionError #{e.message}", tag: :session)
185
+ []
186
+ end
187
+
188
+ def move_selected_by(delta)
189
+ return if @displayed.empty?
190
+
191
+ msg = "PopUpSession#move_selected_by before: selected=#{@selected.inspect} delta=#{delta} len=#{@displayed.length}"
192
+ Fatty.debug(msg)
193
+ @selected = ((@selected || 0) + delta) % @displayed.length
194
+ Fatty.debug("PopUpSession#move_selected_by after: selected=#{@selected.inspect}")
195
+ end
196
+
197
+ def accept_selection
198
+ if multi_select?
199
+ payload = popup_payload(selected_result_hash)
200
+ else
201
+ item = selected_item
202
+ query = @field.buffer.text.to_s
203
+
204
+ return [] if item.nil? && query.empty?
205
+
206
+ item = query if item.nil?
207
+ payload = popup_payload(item)
208
+ end
209
+ [
210
+ [:terminal, :send_modal_owner, [:cmd, :popup_result, payload]],
211
+ [:terminal, :pop_modal]
212
+ ]
213
+ end
214
+
215
+ def selected_item
216
+ @displayed[@selected]
217
+ end
218
+
219
+ def selected_item_label?(item)
220
+ selected_label?(item_label(item))
221
+ end
222
+
223
+ def gutter_for(item:, selected:)
224
+ if multi_select?
225
+ selected_item_label?(item) ? SELECTED_GUTTER : UNSELECTED_GUTTER
226
+ else
227
+ ' '
228
+ end
229
+ end
230
+
231
+ def refresh_items_if_query_changed
232
+ q = @field.buffer.text.to_s
233
+ return if q == @last_query
234
+
235
+ @last_query = q.dup.freeze
236
+ Fatty.debug("popup query changed", tag: :popup, q: q, last: @last_query)
237
+ refresh_items
238
+ end
239
+
240
+ def refresh_displayed_items
241
+ if multi_select?
242
+ selected_missing =
243
+ @items.select do |item|
244
+ label = item_label(item)
245
+ selected_label?(label) && !@filtered.include?(item)
246
+ end
247
+
248
+ @displayed = selected_missing + @filtered
249
+ else
250
+ @displayed = @filtered.dup
251
+ end
252
+ end
253
+
254
+ def refresh_items
255
+ q = @field.buffer.text.to_s
256
+ @items = Array(call_source(q))
257
+ apply_order!
258
+ validate_unique_labels!(@items) if @validate_unique_labels
259
+
260
+ matcher = @matcher || method(:default_matcher)
261
+
262
+ @filtered =
263
+ if q.empty?
264
+ @items
265
+ else
266
+ @items.select { |e| matcher.call(e, q) }
267
+ end
268
+
269
+ refresh_displayed_items
270
+ apply_selection_policy!
271
+ end
272
+
273
+ # Renderer calls this to determine which slice of items to display.
274
+ def scroll_start(list_h:)
275
+ max_start = @displayed.length - list_h
276
+ max_start = 0 if max_start < 0
277
+
278
+ @scroll_start = 0 if @scroll_start < 0
279
+ @scroll_start = max_start if @scroll_start > max_start
280
+ @scroll_start
281
+ end
282
+
283
+ def toggle_selected_current!
284
+ item = selected_item
285
+ return unless item
286
+
287
+ toggle_selected_item!(item)
288
+ end
289
+
290
+ def toggle_selected_item!(item)
291
+ label = item_label(item)
292
+
293
+ if selected_label?(label)
294
+ @selected_labels.delete(label)
295
+ else
296
+ @selected_labels[label] = true
297
+ end
298
+
299
+ refresh_displayed_items
300
+ item
301
+ end
302
+
303
+ # Count methods for display to user.
304
+
305
+ def total_count
306
+ @items.length
307
+ end
308
+
309
+ def matching_count
310
+ @filtered.length
311
+ end
312
+
313
+ def selected_count
314
+ if multi_select?
315
+ @selected_labels.length
316
+ else
317
+ selected_item ? 1 : 0
318
+ end
319
+ end
320
+
321
+ def showing_count
322
+ [@displayed.length - scroll_start(list_h: popup_list_height), popup_list_height].min
323
+ end
324
+
325
+ def counts
326
+ {
327
+ total: total_count,
328
+ selected: selected_count,
329
+ matching: matching_count,
330
+ showing: showing_count
331
+ }
332
+ end
333
+
334
+ def selected_labels
335
+ @selected_labels.keys
336
+ end
337
+
338
+ private
339
+
340
+ def validate_unique_labels!(items)
341
+ counts = Hash.new(0)
342
+ items.each do |item|
343
+ counts[item_label(item)] += 1
344
+ end
345
+
346
+ dupes = counts.select { |_label, count| count > 1 }.keys
347
+ return if dupes.empty?
348
+
349
+ shown = dupes.first(5).join(", ")
350
+ raise ArgumentError, "duplicate chooser labels: #{shown}"
351
+ end
352
+
353
+ def popup_payload(item = selected_item)
354
+ {
355
+ kind: @kind,
356
+ item: item,
357
+ query: @field.buffer.text.to_s,
358
+ index: @selected
359
+ }
360
+ end
361
+
362
+ def popup_payload(item = selected_item)
363
+ if multi_select?
364
+ {
365
+ kind: @kind,
366
+ items: item,
367
+ query: @field.buffer.text.to_s,
368
+ index: @selected
369
+ }
370
+ else
371
+ {
372
+ kind: @kind,
373
+ item: item,
374
+ query: @field.buffer.text.to_s,
375
+ index: @selected
376
+ }
377
+ end
378
+ end
379
+
380
+ def popup_has_message?
381
+ @message && !@message.empty?
382
+ end
383
+
384
+ def popup_extra_rows
385
+ rows = 4
386
+ rows += 1 if popup_has_message?
387
+ rows
388
+ end
389
+
390
+ def notify_owner(name)
391
+ return [] unless @kind
392
+
393
+ [[:terminal, :send_modal_owner, [:cmd, name, popup_payload]]]
394
+ end
395
+
396
+ def popup_list_height
397
+ # Visible list lines = window height minus:
398
+ # - 2 border rows
399
+ # - 1 input row
400
+ # - 1 message row when present
401
+ #
402
+ # If window isn't built yet, fall back to the historical default.
403
+ h =
404
+ begin
405
+ @win ? @win.maxy : DEFAULT_HEIGHT
406
+ rescue RuntimeError
407
+ DEFAULT_HEIGHT
408
+ end
409
+
410
+ list_h = h - popup_extra_rows
411
+ list_h = 1 if list_h < 1
412
+ list_h
413
+ end
414
+
415
+ def ensure_scroll_visible
416
+ list_h = popup_list_height
417
+
418
+ # dead-zone is top/bottom 10% of visible list, at least 1 line
419
+ band = (list_h * 0.10).floor
420
+ band = 1 if band < 1
421
+
422
+ top_zone = @scroll_start + band
423
+ bot_zone = (@scroll_start + list_h - 1) - band
424
+
425
+ if @selected < top_zone
426
+ @scroll_start = @selected - band
427
+ elsif @selected > bot_zone
428
+ @scroll_start = @selected - (list_h - 1) + band
429
+ end
430
+
431
+ max_start = @displayed.length - list_h
432
+ max_start = 0 if max_start < 0
433
+
434
+ @scroll_start = 0 if @scroll_start < 0
435
+ @scroll_start = max_start if @scroll_start > max_start
436
+ end
437
+
438
+ def recenter_scroll
439
+ list_h = popup_list_height
440
+ @scroll_start = @selected - (list_h / 2)
441
+
442
+ max_start = @displayed.length - list_h
443
+ max_start = 0 if max_start < 0
444
+
445
+ @scroll_start = 0 if @scroll_start < 0
446
+ @scroll_start = max_start if @scroll_start > max_start
447
+ end
448
+
449
+ def call_source(q)
450
+ items =
451
+ if @source.respond_to?(:call)
452
+ if @source.arity == 0
453
+ @source.call
454
+ else
455
+ @source.call(q)
456
+ end
457
+ else
458
+ @source
459
+ end
460
+ Array(items)
461
+ end
462
+
463
+ def apply_order!
464
+ case @order
465
+ when :as_given
466
+ # no-op
467
+ when :reverse
468
+ @items = @items.reverse
469
+ end
470
+ end
471
+
472
+ def apply_selection_policy!
473
+ prior = @selected
474
+ last_idx = [@displayed.length - 1, 0].max
475
+
476
+ @selected = if @displayed.empty?
477
+ 0
478
+ else
479
+ case @selection
480
+ when :top
481
+ 0
482
+ when :bottom
483
+ last_idx
484
+ else
485
+ @selected.clamp(0, last_idx)
486
+ end
487
+ end
488
+
489
+ if @selection == :top || @selection == :bottom
490
+ recenter_scroll
491
+ elsif @selected != prior
492
+ ensure_scroll_visible
493
+ end
494
+ end
495
+
496
+ def default_matcher(item, query)
497
+ match_all_query_terms?(item, query)
498
+ end
499
+
500
+ def action_env(event:)
501
+ ActionEnvironment.new(
502
+ session: self,
503
+ counter: counter,
504
+ event: event,
505
+ field: @field,
506
+ buffer: @field.buffer,
507
+ )
508
+ end
509
+
510
+ def multi_select?
511
+ @selection_mode == :multiple
512
+ end
513
+
514
+ def item_label(item)
515
+ item.to_s
516
+ end
517
+
518
+ def item_value(item)
519
+ item
520
+ end
521
+
522
+ def selected_label?(label)
523
+ @selected_labels.key?(label)
524
+ end
525
+
526
+ def selected_item?
527
+ !selected_item.nil?
528
+ end
529
+
530
+ def selected_items_in_source_order
531
+ @items.select { |item| selected_label?(item_label(item)) }
532
+ end
533
+
534
+ def selected_result_hash
535
+ selected_items_in_source_order.each_with_object({}) do |item, h|
536
+ h[item_label(item)] = item_value(item)
537
+ end
538
+ end
539
+ end
540
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ class PromptSession < ModalSession
5
+ attr_reader :field, :title, :message, :history
6
+
7
+ PROMPT_POPUP_MAX_WIDTH = 120
8
+ PROMPT_POPUP_MIN_WIDTH = 20
9
+ PROMPT_POPUP_MARGIN = 2
10
+
11
+ def id = :prompt
12
+
13
+ def initialize(initial: "", prompt: "> ", title: "Prompt", message: nil, kind: nil,
14
+ history_ctx: nil, history_path: :default)
15
+ super(keymap: Keymaps.emacs, views: [])
16
+ @title = title&.to_s
17
+ @message = message&.to_s
18
+ @kind = kind&.to_sym
19
+
20
+ @history = Fatty::History.for_path(history_path)
21
+ @field = Fatty::InputField.new(
22
+ prompt: prompt,
23
+ history: @history,
24
+ history_kind: :prompt,
25
+ history_ctx: history_ctx,
26
+ )
27
+ @field.buffer.replace(initial.to_s)
28
+
29
+ @win = nil
30
+ end
31
+
32
+ #########################################################################################
33
+ # Framework and Session Hooks
34
+ #########################################################################################
35
+
36
+ def init(terminal:)
37
+ super
38
+ rebuild_windows!
39
+ []
40
+ end
41
+
42
+ def keymap_contexts
43
+ [:prompt, :text, :terminal]
44
+ end
45
+
46
+ def view(screen:, renderer:)
47
+ return unless @win
48
+
49
+ renderer.render_prompt_popup(session: self)
50
+ end
51
+
52
+ ############################################################################################
53
+ # Actions
54
+ ############################################################################################
55
+
56
+ action_on :session
57
+
58
+ desc "Accept prompt input"
59
+ action :prompt_accept do
60
+ text = @field.accept_line.to_s
61
+ [
62
+ [:terminal, :send_modal_owner, [:cmd, :prompt_result, { kind: @kind, text: text }]],
63
+ [:terminal, :pop_modal],
64
+ ]
65
+ end
66
+
67
+ desc "Cancel prompt input"
68
+ action :prompt_cancel do
69
+ [
70
+ [:terminal, :send_modal_owner, [:cmd, :prompt_cancelled, prompt_payload]],
71
+ [:terminal, :pop_modal],
72
+ ]
73
+ end
74
+
75
+ desc "Cancel prompt if empty, otherwise delete forward"
76
+ action :prompt_cancel_if_empty do
77
+ if @field.buffer.text.to_s.empty?
78
+ prompt_cancel
79
+ else
80
+ with_virtual_suffix_sync { @field.act_on(:delete_char_forward, env: action_env(event: nil)) }
81
+ []
82
+ end
83
+ end
84
+
85
+ def handle_action(action, args, event:)
86
+ env = action_env(event: event)
87
+
88
+ result =
89
+ with_virtual_suffix_sync do
90
+ @field.act_on(action, *args, env: env)
91
+ end
92
+ result.is_a?(Array) ? result : []
93
+ rescue ActionError => e
94
+ Fatty.error("PromptSession#handle_action: ActionError #{e.message}", tag: :session)
95
+ []
96
+ end
97
+
98
+ # Return the outer width and height of the window for this modal,
99
+ # including any padding and borders.
100
+ def geometry(cols:, rows:)
101
+ max_w = max_width(
102
+ cols: cols,
103
+ margin: PROMPT_POPUP_MARGIN,
104
+ min_width: PROMPT_POPUP_MIN_WIDTH,
105
+ )
106
+ max_h = max_height(rows: rows, margin: PROMPT_POPUP_MARGIN, min_height: 5)
107
+
108
+ preferred_w = [(cols * 2 / 3).floor, 50].max
109
+
110
+ message_width = @message.to_s.length
111
+ prompt_width = @field.prompt_text.to_s.length + @field.buffer.text.to_s.length
112
+ content_width = [message_width, prompt_width].max + 4
113
+
114
+ width = clamp_width(
115
+ [preferred_w, content_width].max,
116
+ max_width: max_w,
117
+ hard_max: PROMPT_POPUP_MAX_WIDTH,
118
+ )
119
+
120
+ extra_rows = 2
121
+ extra_rows += 1 if @message && !@message.empty?
122
+ height = clamp_height(
123
+ extra_rows,
124
+ max_height: max_h,
125
+ min_height: 4,
126
+ )
127
+
128
+ [width, height]
129
+ end
130
+
131
+ def with_virtual_suffix_sync
132
+ @field.sync_virtual_suffix!
133
+ result = yield
134
+ @field.sync_virtual_suffix!
135
+ result
136
+ end
137
+
138
+ private
139
+
140
+ def prompt_payload
141
+ {
142
+ kind: @kind,
143
+ text: @field.buffer.text.to_s,
144
+ }
145
+ end
146
+
147
+ def action_env(event:)
148
+ ActionEnvironment.new(
149
+ session: self,
150
+ counter: counter,
151
+ event: event,
152
+ field: @field,
153
+ buffer: @field.buffer,
154
+ )
155
+ end
156
+ end
157
+ end