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,507 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "strscan"
5
+
6
+ module Fatty
7
+ # The =InputField= class is a thin controller around an InputBuffer that
8
+ # adds:
9
+ # - A prompt (text shown before the editable buffer)
10
+ # - Optional command history integration (previous/next)
11
+ # - A small set of "editor actions" intended to be bound to keys
12
+ #
13
+ # =InputField= intentionally does *not* perform terminal I/O or rendering.
14
+ # Higher-level UI components (e.g., Terminal/Screen/widgets) are responsible
15
+ # for:
16
+ #
17
+ # - Decoding keys and dispatching actions
18
+ # - Translating buffer/cursor state into screen coordinates
19
+ # - Drawing the prompt + buffer text and placing the cursor
20
+ #
21
+ # Word-motion and word-deletion semantics are delegated to InputBuffer so
22
+ # the definition of "word" can be configured in one place (via
23
+ # =InputBuffer='s word_chars/word_re).
24
+ class InputField
25
+ include Actionable
26
+
27
+ attr_reader :buffer, :prompt, :history
28
+
29
+ def initialize(
30
+ prompt:,
31
+ buffer: nil,
32
+ completion_proc: nil,
33
+ history: nil,
34
+ history_kind: :command,
35
+ history_ctx: nil
36
+ )
37
+ @prompt = Prompt.ensure(prompt)
38
+ @history = history
39
+ @history_kind = history_kind
40
+ @history_ctx = history_ctx
41
+ @completion_proc = completion_proc
42
+ @completion_cycle_base = nil
43
+ @completion_cycle_candidates = []
44
+ @completion_cycle_index = nil
45
+
46
+ @buffer =
47
+ if buffer
48
+ buffer
49
+ else
50
+ cfg = Fatty::Config.config
51
+ word_chars = cfg.dig(:input_buffer, :word_chars) || Fatty::InputBuffer::DEFAULT_WORD_CHARS
52
+ Fatty::InputBuffer.new(word_chars: word_chars)
53
+ end
54
+ end
55
+
56
+ # :category: Inspect
57
+
58
+ def to_s
59
+ "<InputField:#{object_id}> Prompt => #{prompt} Buffer => #{buffer}"
60
+ end
61
+ alias_method :inspect, :to_s
62
+
63
+ # :category: Queries
64
+
65
+ def empty?
66
+ buffer.text == ""
67
+ end
68
+
69
+ # Visual cursor X position in the window
70
+ def cursor_x
71
+ before_cursor = buffer.text.to_s[0...buffer.cursor].to_s
72
+ prompt_width + Fatty::Ansi.visible_length(before_cursor)
73
+ end
74
+
75
+ def prompt_text
76
+ prompt.text
77
+ end
78
+
79
+ # The prompt might use coloring, so we use the visible length stripped of
80
+ # ANSI controls.
81
+ def prompt_width
82
+ Fatty::Ansi.visible_length(prompt_text.to_s.lines.last.to_s)
83
+ end
84
+
85
+ def snapshot_input_state
86
+ [
87
+ prompt_text.to_s.dup.freeze,
88
+ buffer.text.to_s.dup.freeze,
89
+ buffer.virtual_suffix.to_s.dup.freeze,
90
+ cursor_x,
91
+ (r = buffer.region_range) ? [r.begin, r.end] : nil,
92
+ ]
93
+ end
94
+
95
+ # :category: Setters
96
+
97
+ def prompt=(prompt)
98
+ @prompt = Prompt.ensure(prompt)
99
+ end
100
+
101
+ # :category: Actions
102
+
103
+ desc "Accept the current line, add to history, and clear the buffer"
104
+ action :accept_line do
105
+ line = buffer.text.dup
106
+ if history
107
+ history.add(
108
+ line,
109
+ kind: resolve_history_kind,
110
+ ctx: resolve_history_ctx,
111
+ )
112
+ buffer.clear
113
+ line
114
+ end
115
+ end
116
+
117
+ desc "Replace buffer with the previous history entry"
118
+ action :history_prev do
119
+ return unless history
120
+
121
+ buffer.replace(history.previous_for(resolve_history_kind, current: buffer.text, ctx: resolve_history_ctx))
122
+ end
123
+
124
+ desc "Replace buffer with the next history entry"
125
+ action :history_next do
126
+ return unless history
127
+
128
+ buffer.replace(history.next_for(resolve_history_kind, ctx: resolve_history_ctx))
129
+ end
130
+
131
+ desc "Paste text into the field, normalizing to one line"
132
+ action :paste do |str|
133
+ s = str.to_s
134
+ s = s.gsub(/\r\n?/, "\n")
135
+ s = s.tr("\n", " ")
136
+ buffer.insert(s)
137
+ end
138
+
139
+ def act_on(action, *args, env: nil, **kwargs)
140
+ return unless action
141
+
142
+ reset_history_cursor_for(action)
143
+
144
+ if Fatty::Actions.registered?(action)
145
+ if env
146
+ Fatty::Actions.call(action, env, *args, **kwargs)
147
+ else
148
+ defn = Fatty::Actions.lookup(action)
149
+ target =
150
+ case defn[:on]
151
+ when :field then self
152
+ when :buffer then buffer
153
+ else
154
+ raise Fatty::ActionError, "Cannot dispatch #{action} without env for target #{defn[:on].inspect}"
155
+ end
156
+ target.public_send(defn[:method], *args, **kwargs)
157
+ end
158
+ elsif buffer.respond_to?(action)
159
+ buffer.public_send(action, *args, **kwargs)
160
+ elsif respond_to?(action)
161
+ public_send(action, *args, **kwargs)
162
+ else
163
+ raise Fatty::ActionError, "Unknown action: #{action}"
164
+ end
165
+ end
166
+
167
+ # :category: Completion
168
+
169
+ def autosuggestion
170
+ return if buffer.text.empty?
171
+
172
+ active_completion_autosuggestion || default_completion_autosuggestion || history_autosuggestion
173
+ end
174
+
175
+ def completion_candidates
176
+ return [] unless @completion_proc
177
+
178
+ prefix = buffer.completion_prefix.to_s
179
+ return [] if prefix.empty?
180
+
181
+ Array(@completion_proc.call(buffer))
182
+ .compact
183
+ .map(&:to_s)
184
+ .reject(&:empty?)
185
+ .select { |s| s.start_with?(prefix) }
186
+ .reject { |s| s == prefix }
187
+ .uniq
188
+ end
189
+
190
+ def completion_suggestions
191
+ raw =
192
+ if path_completion_candidates.any?
193
+ path_completion_candidates
194
+ else
195
+ completion_candidates
196
+ end
197
+
198
+ raw
199
+ .map { |candidate| build_line_with_completion(candidate) }
200
+ .reject { |line| line == buffer.text.to_s }
201
+ .uniq
202
+ end
203
+
204
+ def default_completion_autosuggestion
205
+ suggestions = completion_suggestions
206
+ result = suggestions.first
207
+ result
208
+ end
209
+
210
+ def active_completion_autosuggestion
211
+ text = buffer.text.to_s
212
+ result = nil
213
+
214
+ if @completion_cycle_base == text &&
215
+ @completion_cycle_index &&
216
+ !@completion_cycle_candidates.empty?
217
+ result = @completion_cycle_candidates[@completion_cycle_index]
218
+ end
219
+
220
+ result
221
+ end
222
+
223
+ def cycle_completion!
224
+ text = buffer.text.to_s
225
+ suggestions = completion_suggestions
226
+ result = nil
227
+
228
+ if suggestions.empty?
229
+ reset_completion_cycle!
230
+ elsif @completion_cycle_base == text &&
231
+ @completion_cycle_candidates == suggestions &&
232
+ @completion_cycle_index
233
+ @completion_cycle_index = (@completion_cycle_index + 1) % @completion_cycle_candidates.length
234
+ result = @completion_cycle_candidates[@completion_cycle_index]
235
+ else
236
+ @completion_cycle_base = text
237
+ @completion_cycle_candidates = suggestions
238
+ @completion_cycle_index =
239
+ if suggestions.length > 1
240
+ 1
241
+ else
242
+ 0
243
+ end
244
+ result = @completion_cycle_candidates[@completion_cycle_index]
245
+ end
246
+
247
+ sync_virtual_suffix!
248
+ result
249
+ end
250
+
251
+ def reset_completion_cycle!
252
+ @completion_cycle_base = nil
253
+ @completion_cycle_candidates = []
254
+ @completion_cycle_index = nil
255
+ nil
256
+ end
257
+
258
+ def history_autosuggestion
259
+ return if history.nil?
260
+
261
+ history.suggest_for(
262
+ resolve_history_kind,
263
+ prefix: buffer.text,
264
+ ctx: resolve_history_ctx,
265
+ )
266
+ end
267
+
268
+ def path_completion_candidates
269
+ prefix = path_completion_prefix
270
+ return [] if prefix.nil? || prefix.empty?
271
+
272
+ rendered_path_candidates(prefix)
273
+ end
274
+
275
+ def path_like_prefix?(prefix)
276
+ s = prefix.to_s
277
+ return false if s.empty?
278
+
279
+ s.start_with?("/", "~/", "./", "../") || s.include?("/")
280
+ end
281
+
282
+ def path_completion_prefix
283
+ r = path_completion_range
284
+ return if r.nil?
285
+
286
+ buffer.text[r].to_s
287
+ end
288
+
289
+ # Use a StringScanner to determine where a pathname occurs before the
290
+ # cursor for purposes of providing completions. It considers escaped
291
+ # characters, including escaped whitespace as part of a plausible
292
+ # pathname.
293
+ def path_completion_range
294
+ text = buffer.text.to_s
295
+ cur = buffer.cursor
296
+ return if cur < 0
297
+
298
+ before = text[0...cur]
299
+ scanner = StringScanner.new(before)
300
+
301
+ last_token = nil
302
+ until scanner.eos?
303
+ next if scanner.scan(/\s+/)
304
+
305
+ if (token = scanner.scan(/(?:\\.|[^\s\\])+/))
306
+ last_token = [scanner.pos - token.length, scanner.pos]
307
+ else
308
+ scanner.getch
309
+ end
310
+ end
311
+
312
+ return unless last_token
313
+
314
+ start_i, end_i = last_token
315
+ prefix = before[start_i...end_i]
316
+
317
+ return unless path_like_prefix?(unescape_path(prefix))
318
+
319
+ start_i...end_i
320
+ end
321
+
322
+ def rendered_path_candidates(prefix)
323
+ raw_prefix = unescape_path(prefix)
324
+ expanded = expand_path_prefix(raw_prefix)
325
+ return [] if expanded.nil? || expanded.empty?
326
+
327
+ if File.directory?(expanded) && !raw_prefix.end_with?("/")
328
+ return [escape_path("#{raw_prefix}/")]
329
+ end
330
+
331
+ if raw_prefix.end_with?("/")
332
+ dir_part = expanded
333
+ base_part = ""
334
+ else
335
+ dir_part = File.dirname(expanded)
336
+ base_part = File.basename(expanded)
337
+ end
338
+
339
+ dir_part = "." if dir_part.nil? || dir_part.empty?
340
+ return [] unless Dir.exist?(dir_part)
341
+
342
+ # Sort normal directories, normal files, hidden directories, then hidden
343
+ # files, but not if the prefix starts with '.' or '#', then treat hidden
344
+ # files normally.
345
+ entries =
346
+ Dir.children(dir_part)
347
+ .select { |name| name.start_with?(base_part) }
348
+ .sort_by do |name|
349
+ full = File.join(dir_part, name)
350
+ hide_penalty =
351
+ if base_part.match?(/\A[.#]/)
352
+ 0
353
+ else
354
+ name.match?(/\A[.#]/) ? 1 : 0
355
+ end
356
+ file_penalty = File.directory?(full) ? 0 : 1
357
+ [hide_penalty, file_penalty, name]
358
+ end
359
+
360
+ return [] if entries.empty?
361
+
362
+ entries.map do |chosen|
363
+ full = File.join(dir_part, chosen)
364
+
365
+ rendered =
366
+ if raw_prefix.start_with?("~/")
367
+ File.join("~", full.delete_prefix("#{Dir.home}/"))
368
+ elsif raw_prefix.start_with?("./") && dir_part == "."
369
+ "./#{chosen}"
370
+ elsif raw_prefix.start_with?("../") && dir_part.start_with?("..")
371
+ File.join(dir_part, chosen)
372
+ elsif raw_prefix.start_with?("/")
373
+ full
374
+ elsif raw_prefix.end_with?("/")
375
+ "#{raw_prefix}#{chosen}"
376
+ elsif raw_prefix.include?("/")
377
+ File.join(File.dirname(raw_prefix), chosen)
378
+ else
379
+ chosen
380
+ end
381
+
382
+ rendered += "/" if File.directory?(full) && !rendered.end_with?("/")
383
+ escape_path(rendered)
384
+ end
385
+ end
386
+
387
+ def escape_path(path)
388
+ path.gsub(/([ \t\n\\'"`$!#&()*;<>?\[\]\{\}|])/) { "\\#{$1}" }
389
+ end
390
+
391
+ def unescape_path(path)
392
+ path.to_s.gsub(/\\(.)/, '\1')
393
+ end
394
+
395
+ def expand_path_prefix(prefix)
396
+ s = prefix.to_s
397
+ return if s.empty?
398
+
399
+ if s.start_with?("~/")
400
+ File.join(Dir.home, s.delete_prefix("~/"))
401
+ else
402
+ s
403
+ end
404
+ end
405
+
406
+ def build_line_with_completion(completion)
407
+ current = buffer.text.to_s
408
+ target = path_completion_range || buffer.completion_replace_range
409
+ start_i = target.begin
410
+ end_i = target.end
411
+
412
+ before = current[0...start_i].to_s
413
+ after = current[end_i..].to_s
414
+
415
+ "#{before}#{completion}#{after}"
416
+ end
417
+
418
+ def autosuggestion_visible?
419
+ suggestion = autosuggestion.to_s
420
+ text = buffer.text.to_s
421
+ return false if suggestion.empty?
422
+ return false unless suggestion.start_with?(text)
423
+
424
+ suggestion != text
425
+ end
426
+
427
+ def autosuggestion_suffix
428
+ return "" unless autosuggestion_visible?
429
+
430
+ autosuggestion.to_s.delete_prefix(buffer.text.to_s)
431
+ end
432
+
433
+ def accept_autosuggestion!
434
+ return unless autosuggestion_visible?
435
+
436
+ sync_virtual_suffix!
437
+ buffer.accept_virtual_suffix!
438
+ end
439
+
440
+ def popup_completion_candidates
441
+ path_prefix = popup_path_completion_prefix
442
+ if path_prefix
443
+ path = rendered_path_candidates(path_prefix)
444
+ return path if path.any?
445
+ end
446
+
447
+ return [] unless @completion_proc
448
+
449
+ Array(@completion_proc.call(buffer))
450
+ .compact
451
+ .map(&:to_s)
452
+ .reject(&:empty?)
453
+ .uniq
454
+ end
455
+
456
+ def popup_path_completion_prefix
457
+ prefix = path_completion_prefix
458
+ return if prefix.nil? || prefix.empty?
459
+
460
+ raw_prefix = unescape_path(prefix)
461
+ expanded = expand_path_prefix(raw_prefix)
462
+ return prefix unless File.directory?(expanded)
463
+
464
+ if raw_prefix.end_with?("/")
465
+ prefix
466
+ else
467
+ escape_path("#{raw_prefix}/")
468
+ end
469
+ end
470
+
471
+ def popup_completion_query
472
+ popup_path_completion_prefix || path_completion_prefix || buffer.completion_prefix
473
+ end
474
+
475
+ def popup_completion_range
476
+ path_completion_range || buffer.completion_replace_range
477
+ end
478
+
479
+ def popup_completion_query
480
+ path_completion_prefix || buffer.completion_prefix
481
+ end
482
+
483
+ def sync_virtual_suffix!
484
+ buffer.virtual_suffix = autosuggestion_suffix
485
+ end
486
+
487
+ private
488
+
489
+ def reset_history_cursor_for(action)
490
+ return if action.to_s.start_with?("history_")
491
+
492
+ history&.reset_cursor_for(resolve_history_kind, ctx: resolve_history_ctx)
493
+ end
494
+
495
+ def resolve_history_kind
496
+ value = @history_kind
497
+ value = instance_exec(&value) if value.respond_to?(:call)
498
+ value.to_sym
499
+ end
500
+
501
+ def resolve_history_ctx
502
+ value = @history_ctx
503
+ value = instance_exec(&value) if value.respond_to?(:call)
504
+ value.is_a?(Hash) ? value : {}
505
+ end
506
+ end
507
+ end