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,419 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This Struct encapsulates the geometry of popup windows often needed in
4
+ # rendering.
5
+ PopupLayout = Struct.new(
6
+ :row,
7
+ :width,
8
+ :height,
9
+ keyword_init: true,
10
+ )
11
+
12
+ module Fatty
13
+ # The Renderer class implements drawing the elements of a Fatty terminal
14
+ # (output, input, alert, status, and popups) on the screen. There are two
15
+ # ways of doing so: (1) using normal curses operations with curses-defined
16
+ # pairs and (2) using ANSI codes to write with a Truecolor full-color
17
+ # palette. This class is the base class for those two rendering methods and
18
+ # provides methods common to them.
19
+ class Renderer
20
+ FRAME_STYLES = {
21
+ ascii: { h: "-", v: "|" },
22
+ single: { h: "─", v: "│" },
23
+ double: { h: "═", v: "║" },
24
+ }.freeze
25
+
26
+ CORNER_STYLES = {
27
+ square: {
28
+ ascii: { tl: "+", tr: "+", bl: "+", br: "+" },
29
+ single: { tl: "┌", tr: "┐", bl: "└", br: "┘" },
30
+ double: { tl: "╔", tr: "╗", bl: "╚", br: "╝" },
31
+ },
32
+ rounded: {
33
+ single: { tl: "╭", tr: "╮", bl: "╰", br: "╯" },
34
+ },
35
+ }.freeze
36
+
37
+ POPUP_SELECTED_GUTTER = "▶ "
38
+ POPUP_UNSELECTED_GUTTER = " "
39
+
40
+ attr_reader :screen, :palette, :context
41
+
42
+ def initialize(screen:, palette:, context:)
43
+ @screen = screen
44
+ @palette = palette
45
+ @context = context
46
+ @last_status_state = nil
47
+ @last_alert_state = nil
48
+ @last_output_state = nil
49
+ @last_popup_state = nil
50
+ @last_prompt_popup_state = nil
51
+ @last_pager_field_state = nil
52
+ end
53
+
54
+ attr_writer :screen
55
+
56
+ def apply_theme!(theme)
57
+ Fatty::Themes::Manager.set(theme)
58
+ theme_spec = Fatty::Themes::Manager.roles(Fatty::Themes::Manager.current) || {}
59
+ @palette = Fatty::Colors::Palette.compile(theme_spec, available_colors: available_colors)
60
+
61
+ after_apply_theme!
62
+ invalidate!
63
+ sync_backgrounds!
64
+ end
65
+
66
+ def render_output(...)
67
+ raise NotImplementedError, "#{self.class} must implement #render_output"
68
+ end
69
+
70
+ def render_status(...)
71
+ raise NotImplementedError, "#{self.class} must implement #render_status"
72
+ end
73
+
74
+ def render_input_field(...)
75
+ raise NotImplementedError, "#{self.class} must implement #render_input_field"
76
+ end
77
+
78
+ def render_pager_field(...)
79
+ raise NotImplementedError, "#{self.class} must implement #render_pager_field"
80
+ end
81
+
82
+ def render_popup(...)
83
+ raise NotImplementedError, "#{self.class} must implement #render_popup"
84
+ end
85
+
86
+ def render_prompt_popup(...)
87
+ raise NotImplementedError, "#{self.class} must implement #render_prompt_popup"
88
+ end
89
+
90
+ def render_alert(...)
91
+ raise NotImplementedError, "#{self.class} must implement #render_alert"
92
+ end
93
+
94
+ def invalidate!
95
+ @last_output_state = nil
96
+ @last_input_state = nil
97
+ @last_alert_state = nil
98
+ @last_status_state = nil
99
+ @last_pager_field_state = nil
100
+ @last_popup_state = nil
101
+ end
102
+
103
+ protected
104
+
105
+ def available_colors
106
+ nil
107
+ end
108
+
109
+ def after_apply_theme!
110
+ nil
111
+ end
112
+
113
+ def alert_state(alert)
114
+ [
115
+ alert&.message.dup.freeze,
116
+ alert&.role,
117
+ alert&.details,
118
+ screen.alert_rect.row,
119
+ screen.alert_rect.cols,
120
+ ]
121
+ end
122
+
123
+ def status_state(text, role)
124
+ [
125
+ renderable_segments(text, role: role).map { |segment|
126
+ [
127
+ segment[:text].to_s.dup.freeze,
128
+ segment[:role],
129
+ segment[:style],
130
+ ].freeze
131
+ }.freeze,
132
+ role,
133
+ screen.status_rect.row,
134
+ screen.status_rect.rows,
135
+ screen.status_rect.cols,
136
+ ]
137
+ end
138
+
139
+ def popup_state(session)
140
+ [
141
+ popup_border,
142
+ session.title.to_s.dup.freeze,
143
+ session.message.to_s.dup.freeze,
144
+ session.displayed.map { |item| item.to_s.dup.freeze }.freeze,
145
+ session.selected,
146
+ session.field.buffer.text.to_s.dup.freeze,
147
+ session.field.buffer.cursor,
148
+ session.field.buffer.virtual_suffix.to_s.dup.freeze,
149
+ session.selected_labels.map { |label| label.to_s.dup.freeze }.sort.freeze,
150
+ session.counts&.dup&.freeze,
151
+ screen.rows,
152
+ screen.cols,
153
+ ]
154
+ end
155
+
156
+ def prompt_popup_state(session)
157
+ [
158
+ popup_border,
159
+ session.title.to_s.dup.freeze,
160
+ session.message.to_s.dup.freeze,
161
+ session.field.prompt_text.to_s.dup.freeze,
162
+ session.field.buffer.text.to_s.dup.freeze,
163
+ session.field.buffer.cursor,
164
+ session.field.buffer.virtual_suffix.to_s.dup.freeze,
165
+ screen.rows,
166
+ screen.cols,
167
+ ]
168
+ end
169
+
170
+ def output_state(viewport:, lines:, highlights:)
171
+ {
172
+ top: viewport.top,
173
+ height: viewport.height,
174
+ width: viewport.respond_to?(:width) ? viewport.width : screen.output_rect.cols,
175
+ col: viewport.respond_to?(:col) ? viewport.col : 0,
176
+ lines: lines.map { |line| line.to_s.dup.freeze }.freeze,
177
+ highlights: normalized_highlights_state(highlights),
178
+ output_rows: screen.output_rect.rows,
179
+ output_cols: screen.output_rect.cols,
180
+ }.freeze
181
+ end
182
+
183
+ def pager_field_state(field, row:, role:)
184
+ [
185
+ input_field_state(field),
186
+ row,
187
+ role,
188
+ screen.output_rect.row,
189
+ screen.output_rect.rows,
190
+ screen.output_rect.cols,
191
+ ].freeze
192
+ end
193
+
194
+ def renderable_text(value)
195
+ renderable_parts(value).map { |part|
196
+ if part.is_a?(Hash) && part.key?(:text)
197
+ part[:text].to_s
198
+ else
199
+ part.to_s
200
+ end
201
+ }.join
202
+ end
203
+
204
+ def status_render_lines(text, width:, max_rows:)
205
+ width = [width.to_i, 1].max
206
+ max_rows = [max_rows.to_i, 1].max
207
+
208
+ lines =
209
+ renderable_text(text)
210
+ .to_s
211
+ .lines
212
+ .flat_map do |line|
213
+ wrap_status_line(line.chomp, width: width)
214
+ end
215
+
216
+ lines = [""] if lines.empty?
217
+ lines.last(max_rows)
218
+ end
219
+
220
+ def wrap_status_line(line, width:)
221
+ text = Fatty::Ansi.strip(line.to_s)
222
+ return [""] if text.empty?
223
+
224
+ chunks = []
225
+ rest = text.dup
226
+
227
+ until rest.empty?
228
+ chunk = Fatty::Ansi.truncate_visible(rest, width)
229
+ chunks << chunk
230
+ rest = rest[chunk.length..].to_s
231
+ end
232
+
233
+ chunks
234
+ end
235
+
236
+ def renderable_segments(value, role:)
237
+ renderable_parts(value).map do |part|
238
+ if part.is_a?(Hash) && part.key?(:text)
239
+ {
240
+ text: part[:text].to_s,
241
+ role: part[:role] || role,
242
+ style: part[:style],
243
+ }.compact
244
+ else
245
+ {
246
+ text: part.to_s,
247
+ role: role,
248
+ }
249
+ end
250
+ end
251
+ end
252
+
253
+ def renderable_parts(value)
254
+ value.is_a?(Array) ? value : [value]
255
+ end
256
+
257
+ def input_field_state(field)
258
+ [
259
+ field.prompt_text.to_s.dup.freeze,
260
+ field.buffer.text.to_s.dup.freeze,
261
+ field.buffer.cursor,
262
+ field.buffer.virtual_suffix.to_s.dup.freeze,
263
+ field.buffer.region_active?,
264
+ field.buffer.region_range,
265
+ screen.input_rect.row,
266
+ screen.input_rect.cols,
267
+ ]
268
+ end
269
+
270
+ def popup_counts_text(session)
271
+ counts = session.counts
272
+
273
+ "#{counts[:total]} total · " \
274
+ "#{counts[:selected]} selected · " \
275
+ "#{counts[:matching]} matching · " \
276
+ "#{counts[:showing]} showing"
277
+ end
278
+
279
+ def popup_border
280
+ spec = popup_frame_spec
281
+
282
+ border = (spec[:border] || spec["border"] || :single).to_sym
283
+ corners = (spec[:corners] || spec["corners"] || :square).to_sym
284
+
285
+ edge = FRAME_STYLES.fetch(border, FRAME_STYLES[:single])
286
+ corner_set = CORNER_STYLES.fetch(corners, CORNER_STYLES[:square])
287
+ corner =
288
+ corner_set[border] ||
289
+ corner_set[:single] ||
290
+ CORNER_STYLES[:square][border] ||
291
+ CORNER_STYLES[:square][:single]
292
+
293
+ {
294
+ h: edge[:h],
295
+ v: edge[:v],
296
+ tl: corner[:tl],
297
+ tr: corner[:tr],
298
+ bl: corner[:bl],
299
+ br: corner[:br],
300
+ }
301
+ end
302
+
303
+ def popup_frame_spec
304
+ palette[:popup_frame] || {}
305
+ rescue StandardError => e
306
+ Fatty.warn("Could not resolve popup frame style: #{e.class}: #{e.message}", tag: :theme)
307
+ {}
308
+ end
309
+
310
+ def status_line(text, width:)
311
+ msg = text.to_s.tr("\r\n", " ")
312
+ msg.ljust(width)[0, width]
313
+ end
314
+
315
+ def pager_field_line(field, width:)
316
+ prompt = field.prompt_text.to_s
317
+ buf_text = field.buffer.text.to_s
318
+ text = (prompt + buf_text).tr("\r\n", "")
319
+ visible = Fatty::Ansi.plain_text(text)
320
+
321
+ visible[0, width].ljust(width)
322
+ end
323
+
324
+ def normalized_highlights(highlights)
325
+ return if highlights.nil?
326
+
327
+ highlights.each_with_object({}) do |(line_no, ranges), out|
328
+ out[line_no] =
329
+ Array(ranges).map do |r|
330
+ if r.is_a?(Hash)
331
+ [r[:from].to_i, r[:to].to_i, (r[:role] || :primary).to_sym]
332
+ else
333
+ [r[0].to_i, r[1].to_i, (r[2] || :primary).to_sym]
334
+ end
335
+ end
336
+ end
337
+ end
338
+
339
+ def normalized_highlights_state(highlights)
340
+ deep_state(highlights)
341
+ end
342
+
343
+ def deep_state(value)
344
+ case value
345
+ when Hash
346
+ value.to_h do |key, val|
347
+ [deep_state(key), deep_state(val)]
348
+ end.freeze
349
+ when Array
350
+ value.map { |item| deep_state(item) }.freeze
351
+ when String
352
+ value.dup.freeze
353
+ else
354
+ value
355
+ end
356
+ end
357
+
358
+ def highlight_ranges_for_line(highlights, abs_line)
359
+ return [] unless highlights
360
+
361
+ raw = highlights[abs_line]
362
+ return [] unless raw
363
+
364
+ # Accept any of:
365
+ # [[from,to], ...]
366
+ # [[from,to,:primary], [from,to,:secondary], ...]
367
+ # [{from:, to:, role:}, ...]
368
+ ranges =
369
+ Array(raw).map do |r|
370
+ if r.is_a?(Hash)
371
+ [r[:from].to_i, r[:to].to_i, (r[:role] || :primary).to_sym]
372
+ else
373
+ a = r[0].to_i
374
+ b = r[1].to_i
375
+ role = (r[2] || :primary).to_sym
376
+ [a, b, role]
377
+ end
378
+ end
379
+
380
+ ranges.sort_by!(&:first)
381
+ ranges
382
+ end
383
+
384
+ def field_segments(field, base_role:, suggestion_role: :input_suggestion, region_role: :region)
385
+ buf = field.buffer
386
+ text = buf.text.to_s
387
+ segments = [{ text: field.prompt_text.to_s, role: base_role }]
388
+
389
+ region =
390
+ if buf.respond_to?(:region_range)
391
+ buf.region_range
392
+ end
393
+
394
+ if region && region.begin < region.end
395
+ max = text.length
396
+ s = region.begin.clamp(0, max)
397
+ e = region.end.clamp(0, max)
398
+
399
+ segments << { text: text[0...s].to_s, role: base_role }
400
+ segments << { text: text[s...e].to_s, role: region_role }
401
+ segments << { text: text[e..].to_s, role: base_role }
402
+ else
403
+ segments << { text: text, role: base_role }
404
+ end
405
+
406
+ suffix = buf.virtual_suffix.to_s
407
+ segments << { text: suffix, role: suggestion_role } unless suffix.empty?
408
+
409
+ segments.reject { |seg| seg[:text].empty? }
410
+ end
411
+
412
+ def sync_backgrounds!
413
+ nil
414
+ end
415
+ end
416
+ end
417
+
418
+ require_relative 'renderer/curses.rb'
419
+ require_relative 'renderer/truecolor.rb'
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ # Screen is a backend-neutral layout model.
5
+ #
6
+ # It describes terminal geometry (rows/cols) and the logical regions that
7
+ # Fatty draws into (output/input/alert). Screen does not perform any IO
8
+ # and does not depend on curses.
9
+ #
10
+ # Curses uses Screen to allocate resources (windows) and to understand where
11
+ # drawing should occur.
12
+ class Screen
13
+ Rect = Struct.new(:row, :col, :rows, :cols, keyword_init: true) do
14
+ def bottom
15
+ row + rows
16
+ end
17
+
18
+ def right
19
+ col + cols
20
+ end
21
+ end
22
+
23
+ attr_reader :rows, :cols
24
+ attr_reader :output_rect
25
+ attr_reader :input_rect
26
+ attr_reader :alert_rect
27
+ attr_reader :status_rect
28
+
29
+ def initialize(rows:, cols:, status_rows: 0)
30
+ resize(rows: rows, cols: cols, status_rows: status_rows)
31
+ end
32
+
33
+ # Update the terminal geometry and recompute the layout.
34
+ def resize(rows:, cols:, status_rows: 0)
35
+ @rows = rows
36
+ @cols = cols
37
+ layout!(status_rows: status_rows)
38
+ self
39
+ end
40
+
41
+ # --- Regions ----------------------------------------------------------
42
+
43
+ private
44
+
45
+ # Default layout:
46
+ # - status: 1 row above input (when it has content)
47
+ # - input: 1 row above alert
48
+ # - alert: 1 bottom row
49
+ # - output: all remaining rows
50
+ #
51
+ # If the terminal is too small, clamp to non-negative sizes.
52
+ def layout!(status_rows: 0)
53
+ status_rows = status_rows.to_i
54
+ status_rows = 0 if status_rows.negative?
55
+
56
+ alert_rows = rows >= 1 ? 1 : 0
57
+ input_rows = rows >= 2 ? 1 : 0
58
+ status_rows = 0 if rows < 3
59
+
60
+ alert_row = rows - alert_rows
61
+ input_row = alert_row - input_rows
62
+ status_row = input_row - status_rows
63
+
64
+ @alert_rect = Rect.new(
65
+ row: [alert_row, 0].max,
66
+ col: 0,
67
+ rows: alert_rows,
68
+ cols: cols,
69
+ )
70
+
71
+ @input_rect = Rect.new(
72
+ row: [input_row, 0].max,
73
+ col: 0,
74
+ rows: input_rows,
75
+ cols: cols,
76
+ )
77
+
78
+ @status_rect = Rect.new(
79
+ row: [status_row, 0].max,
80
+ col: 0,
81
+ rows: status_rows,
82
+ cols: cols,
83
+ )
84
+
85
+ output_rows = status_row
86
+ output_rows = 0 if output_rows.negative?
87
+
88
+ @output_rect = Rect.new(
89
+ row: 0,
90
+ col: 0,
91
+ rows: output_rows,
92
+ cols: cols,
93
+ )
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ module Search
5
+ extend self
6
+
7
+ # Split query into white-space-separated terms.
8
+ def split_terms(query)
9
+ query.to_s.strip.split(/\s+/).reject(&:empty?)
10
+ end
11
+
12
+ # Return the haystack items that match all the space-separated terms in
13
+ # query, regarless of order or case.
14
+ def match_all_terms?(haystack, query)
15
+ terms = split_terms(query)
16
+ return true if terms.empty?
17
+
18
+ text = haystack.to_s.downcase
19
+ terms.all? { |term| text.include?(term.downcase) }
20
+ end
21
+
22
+ def compile_regexp(pattern, regex: false)
23
+ return Regexp.new(pattern) if regex
24
+
25
+ terms = split_terms(pattern)
26
+ flags = pattern.match?(/[[:upper:]]/) ? 0 : Regexp::IGNORECASE
27
+ return Regexp.new("", flags) if terms.empty?
28
+
29
+ lookaheads =
30
+ terms.map { |term|
31
+ "(?=.*#{Regexp.escape(term)})"
32
+ }.join
33
+
34
+ Regexp.new("#{lookaheads}.*", flags)
35
+ end
36
+
37
+ def compile_term_regexps(pattern)
38
+ terms = split_terms(pattern)
39
+ flags = pattern.match?(/[A-Z]/) ? 0 : Regexp::IGNORECASE
40
+ terms.map { |term| Regexp.new(Regexp.escape(term), flags) }
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ # A pinned, non-interactive overlay session responsible for rendering alerts.
5
+ #
6
+ # It is intentionally dumb:
7
+ # - Terminal does not special-case alerts
8
+ # - other sessions emit commands like:
9
+ # [:send, :alert, :show, { level:, message: }]
10
+ # [:send, :alert, :clear, {}]
11
+ class AlertSession < Session
12
+ attr_reader :current
13
+
14
+ def initialize
15
+ super(views: [Fatty::AlertView.new(z: 1_000)])
16
+ @current = nil
17
+ end
18
+
19
+ def id
20
+ :alert
21
+ end
22
+
23
+ private
24
+
25
+ def update_cmd(name, payload)
26
+ payload ||= {}
27
+ case name
28
+ when :show
29
+ show_from_payload(payload)
30
+ when :clear
31
+ @current = nil unless @current&.sticky?
32
+ end
33
+ []
34
+ end
35
+
36
+ def show_from_payload(payload)
37
+ return if payload.nil?
38
+
39
+ if payload.is_a?(Fatty::Alert)
40
+ @current = payload
41
+ return
42
+ end
43
+
44
+ level = (payload[:level] || :info).to_sym
45
+ message = (payload[:message] || payload[:text]).to_s
46
+ details = payload[:details]
47
+ sticky = !!payload[:sticky]
48
+
49
+ @current = Fatty::Alert.new(level: level, message: message, details: details, sticky: sticky)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ class InputSession < Session
5
+ attr_reader :field
6
+
7
+ # @param on_accept: ->(line, session, terminal) { ... }
8
+ def initialize(field:, keymap:, views: [], on_accept: nil)
9
+ super(keymap: keymap, views: views)
10
+ @field = field
11
+ @on_accept = on_accept
12
+ end
13
+
14
+ #########################################################################################
15
+ # Framework and Session Hooks
16
+ #########################################################################################
17
+
18
+ def keymap_contexts
19
+ if paging_mode?
20
+ [:paging, :text, :terminal]
21
+ else
22
+ [:text, :terminal]
23
+ end
24
+ end
25
+
26
+ ############################################################################################
27
+ # Actions
28
+ ############################################################################################
29
+
30
+ action_on :session
31
+
32
+ action :input_accept do
33
+ line = @field.accept_line
34
+
35
+ if @on_accept
36
+ Array(@on_accept.call(line, self, terminal))
37
+ else
38
+ Array(emit([:accept_line, line]))
39
+ end
40
+ end
41
+
42
+ action :input_cycle_theme do
43
+ [[:terminal, :cycle_theme]]
44
+ end
45
+
46
+ private
47
+
48
+ # Unbound keys land here.
49
+ def update_key(ev)
50
+ []
51
+ end
52
+
53
+ def update_cmd(name, payload)
54
+ case name
55
+ when :paste
56
+ text = payload.fetch(:text, "").to_s
57
+ env = action_env(event: nil)
58
+ field.act_on(:paste, text, env: env)
59
+ end
60
+ []
61
+ end
62
+
63
+ # Bound keys land here because Session#update resolves via keymap first.
64
+ def handle_action(action, args, event:)
65
+ which =
66
+ if event&.respond_to?(:key)
67
+ event.key.inspect
68
+ elsif event&.respond_to?(:mouse)
69
+ event.mouse.inspect
70
+ end
71
+ Fatty.debug("InputSession#handle_action: #{which}", tag: :session)
72
+ env = action_env(event: event)
73
+
74
+ with_virtual_suffix_sync do
75
+ Fatty::Actions.call(action, env, *args)
76
+ end
77
+ []
78
+ rescue ActionError => e
79
+ Fatty.error("InputSession#handle_action: ActionError #{e.message}", tag: :session)
80
+ []
81
+ end
82
+
83
+ def action_env(event:)
84
+ ActionEnvironment.new(
85
+ session: self,
86
+ terminal: terminal,
87
+ event: event,
88
+ field: @field,
89
+ )
90
+ end
91
+
92
+ def with_virtual_suffix_sync
93
+ @field.sync_virtual_suffix!
94
+ result = yield
95
+ @field.sync_virtual_suffix!
96
+ result
97
+ end
98
+ end
99
+ end