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,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ # Base class for stateful runtime components.
5
+ #
6
+ # Charm/Bubbletea-style contract:
7
+ #
8
+ # init(terminal:) => commands
9
+ # update(message) => commands
10
+ # view(screen:, renderer:, terminal:) => renders only (no return contract)
11
+ #
12
+ # Where `commands` is an Array (possibly empty). Terminal is responsible for
13
+ # executing commands after each update cycle.
14
+ class Session
15
+ include Actionable
16
+
17
+ attr_reader :terminal, :views, :keymap, :counter
18
+
19
+ def initialize(keymap: nil, views: [])
20
+ @keymap = keymap
21
+ @views = Array(views)
22
+ @counter = Counter.new
23
+ end
24
+
25
+ def inspect
26
+ "#{self.class.name}:#{object_id}"
27
+ end
28
+
29
+ def add_view(view)
30
+ @views << view
31
+ view
32
+ end
33
+
34
+ # Called once when the session becomes active (e.g. pushed).
35
+ # Subclasses may override to kick off timers/async work, etc.
36
+ def init(terminal:)
37
+ @terminal = terminal
38
+ []
39
+ end
40
+
41
+ # Handle a message and return commands.
42
+ def update(message)
43
+ Fatty.debug("#{self.class}#update(message -> #{message})", tag: :session)
44
+
45
+ commands =
46
+ case message[0]
47
+ when :key
48
+ ev = message[1]
49
+ action, args = resolve_action(ev)
50
+
51
+ Fatty.debug(
52
+ "#{self.class}#update: key ev=#{ev.inspect} action=#{action.inspect} args=#{args.inspect}",
53
+ tag: :session,
54
+ )
55
+
56
+ if action
57
+ handle_action(action, args, event: ev)
58
+ else
59
+ update_key(ev)
60
+ end
61
+ when :cmd
62
+ Fatty.debug("#{self.class}#update: cmd message=#{message.inspect}", tag: :session)
63
+ update_cmd(message[1], message[2])
64
+ else
65
+ Fatty.warn("#{self.class}#update: unknown message[0]=#{message[0].inspect}", tag: :session)
66
+ []
67
+ end
68
+ commands
69
+ end
70
+
71
+ # Save any state we want saved on quit, error, etc.
72
+ def persist!
73
+ end
74
+
75
+ def tick
76
+ false
77
+ end
78
+
79
+ def resolve_action(ev)
80
+ return [nil, []] unless keymap
81
+
82
+ keymap.resolve_action(ev, contexts: keymap_contexts)
83
+ end
84
+
85
+ # Subclasses can override to vary contexts dynamically
86
+ # (paging/isearch/popup/etc). Must return an Array of symbols in
87
+ # precedence order.
88
+ def keymap_contexts
89
+ [:input]
90
+ end
91
+
92
+ # Subclasses override this to react to resolved actions.
93
+ def handle_action(_action, _args, event:)
94
+ []
95
+ end
96
+
97
+ desc "Accumulate a count with a decimal digit"
98
+ action :count_digit, on: :session do |n|
99
+ counter.push_digit(n)
100
+ end
101
+
102
+ desc "Clear the accumulated count"
103
+ action :count_clear, on: :session do
104
+ counter.clear!
105
+ end
106
+
107
+ desc "Universal argument (C-u)"
108
+ action :universal_argument, on: :session do
109
+ counter.universal_argument!
110
+ end
111
+
112
+ # Render the session.
113
+ #
114
+ # By default, renders all views belonging to the session, ordered by z-index.
115
+ # Subclasses can override, but should not mutate state here.
116
+ def view(screen:, renderer:)
117
+ views.sort_by(&:z).each do |v|
118
+ v.render(screen:, renderer:, terminal:, session: self)
119
+ end
120
+ end
121
+
122
+ def close
123
+ nil
124
+ end
125
+
126
+ def handle_resize
127
+ []
128
+ end
129
+
130
+ private
131
+
132
+ def update_key(_ev)
133
+ []
134
+ end
135
+
136
+ def update_cmd(_name, _payload)
137
+ []
138
+ end
139
+
140
+ def match_all_query_terms?(haystack, query)
141
+ Fatty::Search.match_all_terms?(haystack, query)
142
+ end
143
+
144
+ def safely_close_window(win)
145
+ return unless win
146
+
147
+ begin
148
+ win.erase
149
+ rescue RuntimeError => e
150
+ raise unless closed_window_error?(e)
151
+ end
152
+
153
+ begin
154
+ win.noutrefresh if win.respond_to?(:noutrefresh)
155
+ rescue RuntimeError => e
156
+ raise unless closed_window_error?(e)
157
+ end
158
+
159
+ begin
160
+ win.close
161
+ rescue RuntimeError => e
162
+ raise unless closed_window_error?(e)
163
+ end
164
+
165
+ nil
166
+ end
167
+
168
+ def closed_window_error?(error)
169
+ message = error.message
170
+ message.include?("closed window") || message.include?("already closed window")
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session"
4
+
5
+ require_relative "session/alert_session"
6
+ require_relative "session/modal_session"
7
+ require_relative "session/popup_session"
8
+ require_relative "session/search_session"
9
+ require_relative "session/isearch_session"
10
+ require_relative "session/input_session"
11
+ require_relative "session/output_session"
12
+ require_relative "session/shell_session"
13
+ require_relative 'session/prompt_session'
14
+ require_relative 'session/keytest_session'
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ class Terminal
5
+ class PopupOwner
6
+ attr_reader :on_result, :on_cancel
7
+
8
+ def initialize(on_result: nil, on_cancel: nil)
9
+ @on_result = on_result
10
+ @on_cancel = on_cancel
11
+ end
12
+
13
+ def update(msg)
14
+ _cmd, name, payload = msg
15
+
16
+ case name
17
+ when :popup_result, :prompt_result
18
+ on_result&.call(payload)
19
+ when :popup_cancelled, :prompt_cancelled
20
+ on_cancel&.call
21
+ end
22
+ []
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,374 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ class Terminal
5
+ class Progress
6
+ PARTIAL_BLOCKS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"].freeze
7
+ FULL_BLOCK = "█"
8
+ EMPTY_BAR = "."
9
+
10
+ SHADE_EMPTY = "░"
11
+ SHADE_HALF = "▒"
12
+ SHADE_FULL = "▓"
13
+
14
+ BRAILLE_STEPS = ["⠀", "⣀", "⣄", "⣆", "⣇", "⣧", "⣷", "⣿"].freeze
15
+
16
+ attr_reader :terminal, :label, :total, :style, :role, :width
17
+
18
+ # The width parameter's purpose varies by style:
19
+ #
20
+ # :trail width = max visible width of the trail portion
21
+ # :bar width = max bar width
22
+ # :unicode_bar width = max bar width
23
+ # :braille_bar width = max bar width
24
+ # :spinner width ignored, unless later used for suffix/trail
25
+ # :count width ignored
26
+ # :percent width ignored
27
+ def initialize(terminal:, label:, total: nil, style: :percent, role: :info, width: 40)
28
+ @terminal = terminal
29
+ @label = label.to_s
30
+ @total = total&.to_i
31
+ @style = style.to_sym
32
+ @role = role
33
+ @width = width.to_i
34
+ @trail = []
35
+ @current = 0
36
+ @spinner_index = 0
37
+
38
+ validate_total_requirement!
39
+
40
+ refresh
41
+ end
42
+
43
+ def update(current: nil, total: @total, label: @label, indicator: nil, render: false)
44
+ @current = current.to_i unless current.nil?
45
+ @total = total&.to_i
46
+ @label = label.to_s
47
+
48
+ if style == :spinner
49
+ advance_spinner
50
+ else
51
+ append_indicator(indicator)
52
+ end
53
+
54
+ refresh
55
+ terminal.render_now if render
56
+ self
57
+ end
58
+
59
+ def finish(message = nil, clear: false, role: @role, render: false, transient: true)
60
+ if clear
61
+ terminal.clear_status
62
+ else
63
+ text =
64
+ if message && !message.empty?
65
+ render_text(suffix: message)
66
+ else
67
+ render_text
68
+ end
69
+ terminal.set_status(text, role: role, transient: transient)
70
+ end
71
+
72
+ terminal.render_now if render
73
+ self
74
+ end
75
+
76
+ def clear
77
+ terminal.clear_status
78
+ self
79
+ end
80
+
81
+ private
82
+
83
+ attr_reader :current
84
+
85
+ def append_indicator(indicator)
86
+ return if indicator.nil?
87
+
88
+ item =
89
+ if indicator.is_a?(Hash) && indicator.key?(:text)
90
+ indicator
91
+ else
92
+ indicator.to_s
93
+ end
94
+
95
+ return if renderable_indicator_text(item).empty?
96
+
97
+ @trail << item
98
+ trim_trail if @width && @width > 0
99
+ end
100
+
101
+ def renderable_indicator_text(item)
102
+ if item.is_a?(Hash) && item.key?(:text)
103
+ item[:text].to_s
104
+ else
105
+ item.to_s
106
+ end
107
+ end
108
+
109
+ def refresh
110
+ terminal.set_status(render_text, role: role)
111
+ end
112
+
113
+ def render_text(suffix: nil)
114
+ case style
115
+ when :count
116
+ base = total ? "#{label} [#{current}/#{total}]" : "#{label} [#{current}]"
117
+ suffix_text(base, suffix)
118
+ when :simple_percent
119
+ base = total && total > 0 ? "#{label} #{percent}%" : label.to_s
120
+ suffix_text(base, suffix)
121
+ when :trail
122
+ render_trail_text(suffix: suffix)
123
+ when :bar
124
+ render_bar_text(suffix: suffix, mode: :solid)
125
+ when :unicode_bar
126
+ render_bar_text(suffix: suffix, mode: :unicode)
127
+ when :braille_bar
128
+ render_bar_text(suffix: suffix, mode: :braille)
129
+ when :spinner
130
+ render_spinner_text(suffix: suffix)
131
+ else
132
+ base = total && total > 0 ? "#{label} [#{current}/#{total}] #{percent}%" : "#{label} [#{current}]"
133
+ suffix_text(base, suffix)
134
+ end
135
+ end
136
+
137
+ def percent
138
+ ((current.to_f / total.to_f) * 100).round
139
+ end
140
+
141
+ def suffix_text(base, suffix)
142
+ extra = suffix.to_s
143
+ return base if extra.empty?
144
+
145
+ "#{base} #{extra}"
146
+ end
147
+
148
+ def render_trail_text(suffix: nil)
149
+ base =
150
+ if total && total > 0
151
+ "#{label} [#{current}/#{total}] #{percent}%"
152
+ else
153
+ "#{label} [#{current}]"
154
+ end
155
+
156
+ trailer = suffix.to_s.empty? ? "" : " #{suffix}"
157
+ return "#{base}#{trailer}" if @trail.empty?
158
+
159
+ limit = trail_limit(base, trailer)
160
+ trail = limited_trail(limit)
161
+ [base, " ", *trail, trailer]
162
+ end
163
+
164
+ def trail_limit(prefix, suffix = "")
165
+ cols =
166
+ if terminal.screen
167
+ terminal.screen.cols.to_i
168
+ else
169
+ 80
170
+ end
171
+
172
+ available = cols - visible_length(prefix) - visible_length(suffix) - 4
173
+ available = 0 if available < 0
174
+ available
175
+ end
176
+
177
+ def limited_trail(limit)
178
+ used = 0
179
+ selected = []
180
+
181
+ @trail.reverse_each do |item|
182
+ width = visible_length(item)
183
+ break if used + width > limit
184
+
185
+ selected.unshift(item)
186
+ used += width
187
+ end
188
+
189
+ selected
190
+ end
191
+
192
+ def trim_trail
193
+ used = 0
194
+ selected = []
195
+
196
+ @trail.reverse_each do |item|
197
+ width = visible_length(item)
198
+ break if used + width > @width
199
+
200
+ selected.unshift(item)
201
+ used += width
202
+ end
203
+
204
+ @trail = selected
205
+ end
206
+
207
+ def visible_length(value)
208
+ if value.is_a?(Hash) && value.key?(:text)
209
+ Fatty::Ansi.visible_length(value[:text].to_s)
210
+ else
211
+ Fatty::Ansi.visible_length(value.to_s)
212
+ end
213
+ end
214
+
215
+ def render_bar_text(suffix: nil, mode: :solid)
216
+ base = label.to_s
217
+ info = progress_info_text
218
+ extra = suffix.to_s
219
+ trailer = extra.empty? ? "" : " #{extra}"
220
+
221
+ bar_width = bar_width_for(base, info, trailer)
222
+ bar =
223
+ case mode
224
+ when :unicode
225
+ unicode_bar(bar_width)
226
+ when :braille
227
+ braille_bar(bar_width)
228
+ else
229
+ solid_bar(bar_width)
230
+ end
231
+
232
+ pieces = [base]
233
+ pieces << "[#{bar}]" unless bar.empty?
234
+ pieces << info unless info.empty?
235
+
236
+ text = pieces.join(" ")
237
+ text = "#{text}#{trailer}" unless trailer.empty?
238
+ text
239
+ end
240
+
241
+ def render_spinner_text(suffix: nil)
242
+ frame = spinner_frames[@spinner_index % spinner_frames.length]
243
+ base =
244
+ if total && total > 0
245
+ "#{label} #{frame} #{percent}%"
246
+ else
247
+ "#{label} #{frame}"
248
+ end
249
+
250
+ suffix_text(base, suffix)
251
+ end
252
+
253
+ def progress_info_text
254
+ if total && total > 0
255
+ "#{percent}% [#{current}/#{total}]"
256
+ else
257
+ "[#{current}]"
258
+ end
259
+ end
260
+
261
+ def bar_width_for(base, info, trailer)
262
+ cols =
263
+ if terminal.screen
264
+ terminal.screen.cols.to_i
265
+ else
266
+ 80
267
+ end
268
+
269
+ reserved = visible_length(base)
270
+ reserved += 1 + visible_length(info) unless info.empty?
271
+ reserved += visible_length(trailer) unless trailer.empty?
272
+
273
+ # Space for:
274
+ # " ["
275
+ # "]"
276
+ # around the bar
277
+ available = cols - reserved - 4
278
+ available = 0 if available < 0
279
+ [available, @width].min
280
+ end
281
+
282
+ def solid_bar(width)
283
+ return "" if width <= 0
284
+
285
+ ratio = progress_ratio
286
+ filled = (ratio * width).round
287
+ filled = 0 if filled < 0
288
+ filled = width if filled > width
289
+
290
+ empty = width - filled
291
+ (FULL_BLOCK * filled) + (EMPTY_BAR * empty)
292
+ end
293
+
294
+ def unicode_bar(width)
295
+ return "" if width <= 0
296
+
297
+ raw = progress_ratio * width
298
+ full = raw.floor
299
+
300
+ # Show a transition block whenever the bar is in progress but not complete,
301
+ # provided there is space for it.
302
+ half =
303
+ if progress_ratio.positive? && progress_ratio < 1.0 && full < width
304
+ 1
305
+ else
306
+ 0
307
+ end
308
+
309
+ bar = +""
310
+ bar << (SHADE_FULL * full)
311
+ bar << SHADE_HALF if half == 1
312
+
313
+ remaining = width - Fatty::Ansi.visible_length(bar)
314
+ remaining = 0 if remaining < 0
315
+ bar << (SHADE_EMPTY * remaining)
316
+ bar
317
+ end
318
+
319
+ def braille_bar(width)
320
+ return "" if width <= 0
321
+
322
+ raw = progress_ratio * width
323
+ full = raw.floor
324
+ remainder = raw - full
325
+
326
+ partial_idx = (remainder * (BRAILLE_STEPS.length - 1)).round
327
+ partial_idx = 0 if partial_idx < 0
328
+ partial_idx = BRAILLE_STEPS.length - 1 if partial_idx >= BRAILLE_STEPS.length
329
+
330
+ bar = +""
331
+ bar << (BRAILLE_STEPS[-1] * full)
332
+
333
+ if progress_ratio.positive? && progress_ratio < 1.0 && full < width
334
+ partial =
335
+ if partial_idx.zero?
336
+ BRAILLE_STEPS[1]
337
+ else
338
+ BRAILLE_STEPS[partial_idx]
339
+ end
340
+ bar << partial
341
+ end
342
+
343
+ remaining = width - Fatty::Ansi.visible_length(bar)
344
+ remaining = 0 if remaining < 0
345
+ bar << (BRAILLE_STEPS[0] * remaining)
346
+ bar
347
+ end
348
+
349
+ def progress_ratio
350
+ return 0.0 unless total && total > 0
351
+
352
+ ratio = current.to_f / total.to_f
353
+ ratio = 0.0 if ratio < 0.0
354
+ ratio = 1.0 if ratio > 1.0
355
+ ratio
356
+ end
357
+
358
+ def validate_total_requirement!
359
+ return if style == :spinner
360
+ return if total && total > 0
361
+
362
+ raise ArgumentError, "progress style #{style.inspect} requires total:"
363
+ end
364
+
365
+ def advance_spinner
366
+ @spinner_index = (@spinner_index + 1) % spinner_frames.length
367
+ end
368
+
369
+ def spinner_frames
370
+ BRAILLE_STEPS[1..]
371
+ end
372
+ end
373
+ end
374
+ end