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,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ class SearchSession < Session
5
+ action_on :session
6
+
7
+ attr_reader :field, :direction, :regex
8
+
9
+ DEFAULT_SEARCH_HISTORY_FILE = File.expand_path("~/.fatty_search_history")
10
+ DEFAULT_SEARCH_HISTORY_MAX = 200
11
+
12
+ def initialize(direction: :backward, regex: false, history: nil, prefix: nil)
13
+ super(keymap: Keymaps.emacs, views: [])
14
+
15
+ @direction = direction.to_sym
16
+ @regex = !!regex
17
+
18
+ prompt = search_prompt(direction: @direction, regex: @regex)
19
+
20
+ @field = Fatty::InputField.new(
21
+ prompt: prompt,
22
+ history: history,
23
+ history_kind: -> { @regex ? :search_regex : :search_string },
24
+ )
25
+ text = prefix.to_s
26
+ @field.buffer.replace(text) unless text.empty?
27
+ end
28
+
29
+ #########################################################################################
30
+ # Framework and Session Hooks
31
+ #########################################################################################
32
+
33
+ def keymap_contexts
34
+ [:search, :text, :terminal]
35
+ end
36
+
37
+ def view(screen:, renderer:)
38
+ row = screen.output_rect.rows - 1
39
+
40
+ ::Curses.curs_set(1)
41
+ renderer.render_pager_field(
42
+ @field,
43
+ row: row,
44
+ role: :search_input,
45
+ )
46
+ renderer.restore_output_cursor(@field, row: row)
47
+ end
48
+
49
+ ############################################################################################
50
+ # Actions
51
+ ############################################################################################
52
+
53
+ desc "Return the line so far as the prompt input"
54
+ action :search_accept do
55
+ search_accept
56
+ end
57
+
58
+ desc "End the search session"
59
+ action :search_cancel do
60
+ cancel!
61
+ end
62
+
63
+ desc "Toggle between string i-search and regex search"
64
+ action :search_toggle_regex do
65
+ toggle_regex
66
+ end
67
+
68
+ desc "Move to the next search match"
69
+ action :search_step_forward do
70
+ step_search(:forward)
71
+ end
72
+
73
+ desc "Move to the prior search match"
74
+ action :search_step_backward do
75
+ step_search(:backward)
76
+ end
77
+
78
+ def handle_action(action, args, event:)
79
+ env = ActionEnvironment.new(
80
+ session: self,
81
+ counter: counter,
82
+ event: event,
83
+ buffer: @field.buffer,
84
+ field: @field,
85
+ )
86
+
87
+ if Fatty::Actions.lookup(action)&.fetch(:on) == :session
88
+ Fatty::Actions.call(action, env, *args)
89
+ else
90
+ @field.act_on(action, *args, env: env)
91
+ end
92
+ rescue Fatty::ActionError
93
+ []
94
+ end
95
+
96
+ def toggle_regex
97
+ @regex = !@regex
98
+ @field.prompt = search_prompt(direction: @direction, regex: @regex)
99
+ []
100
+ end
101
+
102
+ def cancel!
103
+ [[:terminal, :pop_modal]]
104
+ end
105
+
106
+ def search_prompt(direction:, regex:)
107
+ label =
108
+ if regex
109
+ "Regex: "
110
+ else
111
+ "Search string: "
112
+ end
113
+
114
+ # If you want a subtle direction hint right at the prompt:
115
+ dir = (direction == :backward ? "↑ " : "↓ ")
116
+ dir + label
117
+ end
118
+
119
+ def search_accept
120
+ pattern = @field.accept_line.to_s
121
+
122
+ cmds = []
123
+ cmds << [
124
+ :terminal,
125
+ :send_modal_owner,
126
+ [:cmd, :pager_search_set, { pattern: pattern, direction: direction, regex: regex }]
127
+ ]
128
+ cmds << [:terminal, :pop_modal]
129
+ cmds
130
+ end
131
+
132
+ def step_search(dir)
133
+ [[:terminal, :send_modal_owner, [:cmd, :pager_search_step, { direction: dir }]]]
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,566 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Fatty
6
+ class ShellSession < OutputSession
7
+ action_on :session
8
+
9
+ attr_reader :field, :history
10
+
11
+ def initialize(prompt: "sh> ", on_accept: nil, completion_proc: nil, history_ctx: nil, history_path: :default)
12
+ super(
13
+ keymap: Keymaps.emacs,
14
+ views: [
15
+ Fatty::OutputView.new(z: 0),
16
+ Fatty::InputView.new(z: 10),
17
+ Fatty::CursorView.new(z: 100),
18
+ ]
19
+ )
20
+ @history = Fatty::History.for_path(history_path)
21
+ @field = Fatty::InputField.new(
22
+ prompt: prompt,
23
+ history: @history,
24
+ completion_proc: completion_proc,
25
+ history_kind: :command,
26
+ history_ctx: history_ctx,
27
+ )
28
+
29
+ @on_accept = on_accept
30
+ @completion_proc = completion_proc
31
+ end
32
+
33
+ #########################################################################################
34
+ # Framework and Session Hooks
35
+ #########################################################################################
36
+
37
+ def init(terminal:)
38
+ super
39
+ resize_output!
40
+ []
41
+ end
42
+
43
+ def keymap_contexts
44
+ pager_active? ? [:paging, :terminal] : [:input, :text, :terminal]
45
+ end
46
+
47
+ def update_key(ev)
48
+ return [] unless ev.is_a?(Fatty::KeyEvent)
49
+
50
+ key_str = "key=#{ev} raw=#{ev.raw}"
51
+ Fatty.debug("ShellSession.update_key: #{key_str}", tag: :session)
52
+ case ev.key
53
+ when :resize
54
+ [[:terminal, :handle_resize]]
55
+ when :enter, :return
56
+ # safety: if somehow not bound, still accept
57
+ submit_line
58
+ else
59
+ [alert_cmd(:warn, "Unbound key: #{ev} (edit config in 'keybindings.yml' to bind)", ev: ev)]
60
+ end
61
+ end
62
+
63
+ def view(screen:, renderer:)
64
+ if pager_active?
65
+ ::Curses.curs_set(0)
66
+ viewport = pager_status_viewport(screen)
67
+ highlights = pager.search_visible_highlights(viewport: viewport)
68
+
69
+ renderer.render_output(output, viewport: viewport, highlights: highlights)
70
+ renderer.render_pager_field(
71
+ pager_field,
72
+ row: screen.output_rect.rows - 1,
73
+ role: :pager_status,
74
+ )
75
+ else
76
+ ::Curses.curs_set(1)
77
+ renderer.render_output(output, viewport: pager_viewport, highlights: nil)
78
+ renderer.render_input_field(field)
79
+ renderer.restore_cursor(field)
80
+ end
81
+ end
82
+
83
+ def pager_status_viewport(screen)
84
+ vp = pager_viewport.dup
85
+ vp.height = [screen.output_rect.rows - 1, 1].max
86
+ vp
87
+ end
88
+
89
+ # Save any state we want saved on quit, error, etc.
90
+ def persist!
91
+ return unless @history.respond_to?(:save!)
92
+
93
+ Fatty.debug("ShellSession#persist!: saving history", tag: :history)
94
+ @history.save!
95
+ rescue => e
96
+ Fatty.error("ShellSession#persist!: failed to save history: #{e.class}: #{e.message}", tag: :history)
97
+ end
98
+
99
+ # Called by Terminal#go on every loop iteration.
100
+ # Returns true if any visible state changed (dirty).
101
+ def tick
102
+ dirty = false
103
+ # Animated autoscroll (e.g. after M-s in paging mode).
104
+ if pager.autoscroll?
105
+ step = [(viewport.height * 3) / 4, 1].max
106
+ dirty ||= pager.autoscroll_step?(max_lines: step)
107
+ end
108
+ dirty
109
+ end
110
+
111
+ ############################################################################################
112
+ # Actions
113
+ ############################################################################################
114
+
115
+ def action_env(event:)
116
+ Fatty::ActionEnvironment.new(
117
+ session: self,
118
+ counter: counter,
119
+ event: event,
120
+ buffer: @field.buffer,
121
+ field: @field,
122
+ pager: pager,
123
+ )
124
+ end
125
+
126
+ desc "Pass the current shell input line to the proc"
127
+ action :submit_line do
128
+ submit_line
129
+ end
130
+
131
+ # Perform the on_accept action if defined, but catch a few special
132
+ # commands for quiting and clearing.
133
+ def submit_line
134
+ line = @field.accept_line.to_s.strip
135
+ return [] if line.empty?
136
+
137
+ Fatty.info("ShellSession: accept_line: #{line}")
138
+
139
+ case line
140
+ when "exit", "quit"
141
+ [[:terminal, :quit]]
142
+ when "clear"
143
+ reset_output!
144
+ [[:send, :alert, :clear, {}]]
145
+ else
146
+ reset_for_command!
147
+ anchor = output.lines.length
148
+ pager.begin_command!(anchor: anchor)
149
+
150
+ commands =
151
+ if @on_accept
152
+ @on_accept.call(line, accept_env)
153
+ else
154
+ run_default_command(line)
155
+ end
156
+ normalize_accept_commands(commands)
157
+ end
158
+ rescue Errno::ENOENT
159
+ [[:send, :alert, :show, { level: :error, message: "Command not found (#{line})" }]]
160
+ end
161
+
162
+ desc "Accept the current shell input line and switch output to scrolling"
163
+ action :submit_and_scroll do
164
+ before = output.lines.length
165
+ cmds = submit_line
166
+ pager.paging_to_scrolling if output.lines.length > before
167
+ cmds
168
+ end
169
+
170
+ desc "Interrupt scrolling, otherwise quit Fatty"
171
+ action :interrupt do
172
+ if pager.mode == :scrolling
173
+ pager.quit_paging
174
+ []
175
+ else
176
+ [[:terminal, :quit]]
177
+ end
178
+ end
179
+
180
+ desc "Quit if input is empty, otherwise delete forward"
181
+ action :interrupt_if_empty do
182
+ if pager.mode == :scrolling
183
+ pager.quit_paging
184
+ []
185
+ elsif @field.empty?
186
+ [[:terminal, :quit]]
187
+ else
188
+ @field.act_on(:delete_char_forward, env: action_env(event: nil))
189
+ []
190
+ end
191
+ end
192
+
193
+ desc "Clear shell output"
194
+ action :clear_output do
195
+ reset_output!
196
+ []
197
+ end
198
+
199
+ desc "Cycle to the next theme"
200
+ action :cycle_theme do
201
+ [[:terminal, :cycle_theme]]
202
+ end
203
+
204
+ desc "Choose a theme from a popup"
205
+ action :choose_theme do
206
+ current = Fatty::Themes::Manager.current
207
+ names = Fatty::Themes::Manager.theme_names
208
+ ordered = [current] + (names - [current])
209
+ @theme_popup_restore = current
210
+
211
+ popup = Fatty::PopUpSession.new(
212
+ source: ordered,
213
+ kind: :theme_chooser,
214
+ title: "Themes",
215
+ prompt: "Theme: ",
216
+ selection: :top,
217
+ )
218
+
219
+ [[:terminal, :push_modal, popup]]
220
+ end
221
+
222
+ desc "Add a digit to the current numeric prefix"
223
+ action :prefix_digit do |digit|
224
+ counter.push_digit(digit)
225
+ []
226
+ end
227
+
228
+ desc "Complete the current input prefix"
229
+ action :complete do
230
+ with_virtual_suffix_sync do
231
+ @field.cycle_completion!
232
+ []
233
+ end
234
+ end
235
+
236
+ desc "Open completion popup"
237
+ action :completion_popup do
238
+ with_virtual_suffix_sync do
239
+ candidates = @field.popup_completion_candidates
240
+
241
+ if candidates.empty?
242
+ []
243
+ elsif candidates.length == 1
244
+ apply_completion(candidates.first, range: @field.popup_completion_range)
245
+ else
246
+ @completion_range = @field.popup_completion_range
247
+ # NOTE: we don't pass a matcher so that the default_matcher is used.
248
+ popup = Fatty::PopUpSession.new(
249
+ source: candidates,
250
+ kind: :completion,
251
+ title: "Completions",
252
+ prompt: "Complete: ",
253
+ order: :as_given,
254
+ selection: :top,
255
+ initial_query: @field.popup_completion_query.to_s,
256
+ )
257
+ [[:terminal, :push_modal, popup]]
258
+ end
259
+ end
260
+ end
261
+
262
+ desc "Search pager forward"
263
+ action :pager_search_forward do
264
+ regex = consume_search_regex_flag
265
+ [[
266
+ :terminal,
267
+ :push_modal,
268
+ Fatty::SearchSession.new(direction: :forward, regex: regex, history: @history)
269
+ ]]
270
+ end
271
+
272
+ desc "Search pager backward"
273
+ action :pager_search_backward do
274
+ regex = consume_search_regex_flag
275
+ [[
276
+ :terminal,
277
+ :push_modal,
278
+ Fatty::SearchSession.new(direction: :backward, regex: regex, history: @history)
279
+ ]]
280
+ end
281
+
282
+ desc "Start incremental pager search forward"
283
+ action :pager_isearch_forward do
284
+ [[
285
+ :terminal,
286
+ :push_modal,
287
+ Fatty::ISearchSession.new(direction: :forward, history: @history, last_pattern: pager.search_pattern)
288
+ ]]
289
+ end
290
+
291
+ desc "Start incremental pager search backward"
292
+ action :pager_isearch_backward do
293
+ [[
294
+ :terminal,
295
+ :push_modal,
296
+ Fatty::ISearchSession.new(direction: :backward, history: @history, last_pattern: pager.search_pattern)
297
+ ]]
298
+ end
299
+
300
+ desc "Repeat pager search forward"
301
+ action :pager_search_next do
302
+ handle_search_result(pager.search_repeat_next!)
303
+ end
304
+
305
+ desc "Repeat pager search backward"
306
+ action :pager_search_prev do
307
+ handle_search_result(pager.search_repeat_prev!)
308
+ end
309
+
310
+ desc "Open command history search"
311
+ action :history_search do
312
+ src = ->(_q = nil) { @history.entries.select(&:command?).last(500).map(&:text) }
313
+
314
+ popup = Fatty::PopUpSession.new(
315
+ source: src,
316
+ kind: :history_search,
317
+ title: "History",
318
+ prompt: "I-search: ",
319
+ order: :as_given,
320
+ selection: :bottom,
321
+ initial_query: @field.buffer.text,
322
+ )
323
+
324
+ [[:terminal, :push_modal, popup]]
325
+ end
326
+
327
+ #########################################################################################
328
+ # Completion
329
+ #########################################################################################
330
+
331
+ def completion_candidates
332
+ path_candidates = @field.path_completion_candidates
333
+ return path_candidates if path_candidates.any?
334
+
335
+ return [] unless @completion_proc
336
+
337
+ prefix = completion_prefix
338
+ Array(@completion_proc.call(@field.buffer))
339
+ .compact
340
+ .map(&:to_s)
341
+ .reject(&:empty?)
342
+ .select { |s| s.start_with?(prefix) }
343
+ .uniq
344
+ end
345
+
346
+ def completion_prefix
347
+ @field.buffer.completion_prefix
348
+ end
349
+
350
+ def completion_prefix_range
351
+ buffer = @field.buffer
352
+ prefix = completion_prefix
353
+ finish = buffer.cursor
354
+ start = finish - prefix.length
355
+ start...finish
356
+ end
357
+
358
+ def longest_common_prefix(strings)
359
+ result = ""
360
+ if strings.any?
361
+ result = strings.first.to_s.dup
362
+ strings.drop(1).each do |s|
363
+ other = s.to_s
364
+ i = 0
365
+ max = [result.length, other.length].min
366
+ i += 1 while i < max && result[i] == other[i]
367
+ result = result[0...i]
368
+ break if result.empty?
369
+ end
370
+ end
371
+ result
372
+ end
373
+
374
+ def apply_completion_prefix(prefix)
375
+ text = prefix.to_s
376
+ commands = []
377
+ unless text.empty?
378
+ buffer = @field.buffer
379
+ buffer.replace_range(completion_prefix_range, text)
380
+ end
381
+ commands
382
+ end
383
+
384
+ def apply_completion(candidate, range: nil)
385
+ candidate = candidate.to_s
386
+ return [] if candidate.empty?
387
+
388
+ buffer = @field.buffer
389
+ target = range || buffer.completion_replace_range
390
+ old_text = buffer.text.dup
391
+ old_end = target.end
392
+ append_space =
393
+ !candidate.match?(/\s\z/) &&
394
+ (old_end >= old_text.length || old_text[old_end]&.match?(/\s/))
395
+ inserted = append_space ? "#{candidate} " : candidate
396
+ buffer.replace_range(target, inserted)
397
+ []
398
+ end
399
+
400
+ private
401
+
402
+ def accept_env
403
+ Fatty::AcceptEnv.new(session: self)
404
+ end
405
+
406
+ def normalize_accept_commands(commands)
407
+ if commands.is_a?(Array) && commands.first.is_a?(Array) && commands.first.first.is_a?(Symbol)
408
+ commands
409
+ else
410
+ [[:send, :alert, :clear, {}]]
411
+ end
412
+ end
413
+
414
+ def update_cmd(name, payload)
415
+ cmds = []
416
+ case name
417
+ when :popup_result
418
+ case payload[:kind]&.to_sym
419
+ when :history_search
420
+ item = payload.fetch(:item, "").to_s
421
+ env = action_env(event: nil)
422
+ with_virtual_suffix_sync do
423
+ Fatty::Actions.call(:replace, env, item)
424
+ end
425
+ when :theme_chooser
426
+ theme = payload.fetch(:item).to_sym
427
+ @theme_popup_restore = nil
428
+ cmds << [:terminal, :set_theme, theme]
429
+ cmds << [:send, :alert, :show, { level: :info, message: "Theme: #{theme}" }]
430
+ when :completion
431
+ apply_completion(payload.fetch(:item, "").to_s, range: @completion_range)
432
+ @completion_range = nil
433
+ end
434
+ when :popup_changed
435
+ if payload[:kind]&.to_sym == :theme_chooser
436
+ theme = payload.fetch(:item).to_sym
437
+ cmds << [:terminal, :set_theme, theme]
438
+ end
439
+ when :popup_cancelled
440
+ if payload[:kind]&.to_sym == :theme_chooser && @theme_popup_restore
441
+ cmds << [:terminal, :set_theme, @theme_popup_restore]
442
+ @theme_popup_restore = nil
443
+ end
444
+ when :pager_search_set
445
+ pattern = payload.fetch(:pattern, "").to_s
446
+ regex = !!payload[:regex]
447
+ direction = (payload[:direction] || :forward).to_sym
448
+ result = pager.search_set!(pattern: pattern, regex: regex, direction: direction)
449
+ cmds.concat(handle_search_result(result))
450
+ when :pager_search_step
451
+ direction = (payload[:direction] || :forward).to_sym
452
+ result = pager.search_step!(direction: direction)
453
+ cmds.concat(handle_search_result(result))
454
+ when :pager_isearch_update
455
+ pattern = payload.fetch(:pattern, "").to_s
456
+ direction = (payload[:direction] || :forward).to_sym
457
+ result = pager.isearch_update!(pattern: pattern, direction: direction)
458
+ cmds.concat(handle_search_result(result))
459
+ cmds << [:send, :isearch, :isearch_set_failed, { failed: result[:status] != :moved }]
460
+ when :pager_isearch_step
461
+ direction = (payload[:direction] || :forward).to_sym
462
+ result = pager.isearch_step!(direction: direction)
463
+ cmds.concat(handle_search_result(result))
464
+ cmds << [:send, :isearch, :isearch_set_failed, { failed: result[:status] != :moved }]
465
+ when :pager_isearch_cancel
466
+ pager.isearch_cancel!
467
+ cmds << [:send, :isearch, :isearch_set_failed, { failed: false }]
468
+ when :pager_isearch_commit
469
+ pattern = payload.fetch(:pattern, "").to_s
470
+ direction = (payload[:direction] || :forward).to_sym
471
+ result = pager.isearch_commit!(pattern: pattern, direction: direction)
472
+ cmds.concat(handle_search_result(result))
473
+ cmds << [:send, :isearch, :isearch_set_failed, { failed: false }]
474
+ when :paste
475
+ text = payload.fetch(:text, "").to_s
476
+ env = action_env(event: nil)
477
+ field.act_on(:paste, text, env: env)
478
+ end
479
+ cmds
480
+ end
481
+
482
+ def handle_action(action, args, event:)
483
+ which =
484
+ if event&.respond_to?(:key)
485
+ event.key.inspect
486
+ elsif event&.respond_to?(:mouse)
487
+ event.mouse.inspect
488
+ end
489
+ Fatty.debug("ShellSession#handle_action: #{which}", tag: :keymap)
490
+ env = action_env(event: event)
491
+ apply_action(action, args, event, env: env)
492
+ end
493
+
494
+ # Centralized dispatch: actions declare their target (:buffer/:field/:pager)
495
+ # and Fatty::Actions routes through ActionEnvironment.
496
+ def apply_action(action, args, ev, env:)
497
+ @field.reset_completion_cycle!
498
+ defn = Fatty::Actions.lookup(action)
499
+ result = Fatty::Actions.call(action, env, *args)
500
+ if defn && [:field, :buffer].include?(defn[:on])
501
+ @field.sync_virtual_suffix!
502
+ end
503
+ result.is_a?(Array) ? result : []
504
+ rescue Fatty::ActionError => e
505
+ [alert_cmd(:error, e.message, ev: ev)]
506
+ end
507
+
508
+ def run_default_command(line)
509
+ append_output("$ #{line}\n", follow: true)
510
+ out, status = Open3.capture2e(line)
511
+ # optional: include exit status line
512
+ out << "\n[exit #{status.exitstatus}]\n" if status&.exitstatus && status.exitstatus != 0
513
+ out
514
+ rescue Errno::ENOENT
515
+ "Command not found: #{line}\n"
516
+ end
517
+
518
+ def consume_search_regex_flag
519
+ regex = false
520
+ if counter.digits.to_s == "4"
521
+ regex = true
522
+ counter.clear!
523
+ end
524
+ regex
525
+ end
526
+
527
+ def handle_search_result(result)
528
+ return unless result.is_a?(Hash)
529
+
530
+ case result[:status]
531
+ when :boundary
532
+ msg = result[:message].to_s
533
+ msg = "Search reached boundary" if msg.empty?
534
+ [[:send, :alert, :show, { level: :info, message: msg }]]
535
+ when :not_found
536
+ [[:send, :alert, :show, { level: :info, message: "No matches" }]]
537
+ else
538
+ []
539
+ end
540
+ end
541
+
542
+ def with_virtual_suffix_sync
543
+ @field.sync_virtual_suffix!
544
+ result = yield
545
+ @field.sync_virtual_suffix!
546
+ result
547
+ end
548
+
549
+ def alert_cmd(level, message, ev: nil)
550
+ details =
551
+ if ev
552
+ {
553
+ key: ev.key,
554
+ ctrl: ev.respond_to?(:ctrl?) ? ev.ctrl? : ev.ctrl,
555
+ meta: ev.respond_to?(:meta?) ? ev.meta? : ev.meta,
556
+ shift: ev.respond_to?(:shift?) ? ev.shift? : ev.shift,
557
+ text: ev.text
558
+ }
559
+ else
560
+ {}
561
+ end
562
+
563
+ [:send, :alert, :show, { level: level, message: message, details: details }]
564
+ end
565
+ end
566
+ end