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,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+ require "json"
5
+ require "time"
6
+ require "fileutils"
7
+
8
+ require_relative "log_formats/json"
9
+ require_relative "log_formats/text"
10
+
11
+ module Fatty
12
+ module Logger
13
+ class << self
14
+ attr_accessor :logger, :path
15
+ end
16
+
17
+ def self.configure
18
+ progname = Fatty::Config.progname
19
+ cfg = Fatty::Config.config || 'fatty'
20
+ path =
21
+ if cfg.dig(:log, :file)
22
+ File.expand_path(cfg.dig(:log, :file))
23
+ elsif ENV['XDG_STATE_HOME']
24
+ File.expand_path(File.join(ENV['XDG_STATE_HOME'], progname, "#{progname}.log"))
25
+ else
26
+ File.expand_path(File.join("~/.state/#{progname}", "#{progname}.log"))
27
+ end
28
+ dir = File.dirname(path)
29
+ FileUtils.mkdir_p(dir)
30
+ FileUtils.touch(path)
31
+ unless File.readable?(path) && File.writable?(path)
32
+ return self.logger = nil
33
+ end
34
+
35
+ io = File.open(path, "a")
36
+ io.sync = true
37
+ self.logger = ::Logger.new(io)
38
+ self.path = path
39
+ logger.level = severity(cfg.dig(:log, :level))
40
+ logger.formatter =
41
+ if cfg.dig(:log, :format).nil?
42
+ JsonFormatter.new
43
+ elsif cfg.dig(:log, :format)&.to_sym == :json
44
+ JsonFormatter.new
45
+ else
46
+ TextFormatter.new
47
+ end
48
+ logger.progname = progname
49
+ logger
50
+ end
51
+
52
+ def self.active_tags
53
+ tags = Fatty::Config.config.dig(:log, :tags) || [:all]
54
+ Array(tags).map(&:to_sym)
55
+ end
56
+
57
+ # Convenience: log structured events without repeating formatting.
58
+ #
59
+ # Fatty.log(:decode_getch, ch: 27, note: "escape")
60
+ #
61
+ # Supported tags are:
62
+ # - keycode:: raw curses input / bytes / ESC buffering
63
+ # - keyevent:: constructed KeyEvent normalization (ctrl/meta mapping)
64
+ # - keybinding:: KeyMap#resolve hits/misses, contexts used
65
+ # - action:: Actions.call, target selection, unknown action
66
+ # - command:: Terminal.apply_command / :send dispatches
67
+ # - session:: session update calls, mode/context stack changes
68
+ # - render:: viewports, layout sizes, redraw triggers
69
+ # - perf:: timings, frame time, slow paths
70
+ # - all:: All of the above
71
+ def self.log(event = nil, level: :debug, tag: nil, **data)
72
+ return unless logger
73
+
74
+ tags = active_tags
75
+ if tag && !tags.include?(:all)
76
+ return unless tags.include?(tag.to_sym)
77
+ end
78
+
79
+ payload = { event: event, tag: tag }
80
+ payload.merge!(data.reject { |k, _| k == :event || k == :tag })
81
+
82
+ logger.add(severity(level), payload)
83
+ rescue StandardError => ex
84
+ begin
85
+ logger&.add(
86
+ ::Logger::FATAL,
87
+ { event: "logger_error", err: ex.class.name, msg: ex.message, bt: ex.backtrace&.take(10) },
88
+ )
89
+ rescue StandardError
90
+ # swallow
91
+ end
92
+ end
93
+
94
+ # Translate our severity symbols to those expected by ::Logger.
95
+ def self.severity(sym)
96
+ case sym&.to_sym
97
+ when :debug
98
+ # This is 0
99
+ ::Logger::DEBUG
100
+ when :info
101
+ # This is 1
102
+ ::Logger::INFO
103
+ when :warn
104
+ # This is 2
105
+ ::Logger::WARN
106
+ when :error
107
+ # This is 3
108
+ ::Logger::ERROR
109
+ when :fatal
110
+ # This is 4
111
+ ::Logger::FATAL
112
+ else
113
+ ::Logger::DEBUG
114
+ end
115
+ end
116
+ end
117
+
118
+ # So we can just call Fatty.log
119
+ def self.log(event = nil, level: :debug, tag: nil, **data)
120
+ Logger.log(event, level: level, tag: tag, **data)
121
+ end
122
+
123
+ def self.debug(event, tag: nil, **data)
124
+ Logger.log(event, level: :debug, tag: tag, **data)
125
+ end
126
+
127
+ def self.info(event, tag: nil, **data)
128
+ Logger.log(event, level: :info, tag: tag, **data)
129
+ end
130
+
131
+ def self.warn(event, tag: nil, **data)
132
+ Logger.log(event, level: :warn, tag: tag, **data)
133
+ end
134
+
135
+ def self.error(event, tag: nil, **data)
136
+ Logger.log(event, level: :error, tag: tag, **data)
137
+ end
138
+
139
+ def self.fatal(event, tag: nil, **data)
140
+ Logger.log(event, level: :fatal, tag: tag, **data)
141
+ end
142
+ end
@@ -0,0 +1,373 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rouge"
4
+ require "cgi"
5
+
6
+ module Fatty
7
+ class AnsiRenderer < Redcarpet::Render::Base
8
+ HARD_BREAK = "\uE000"
9
+
10
+ def initialize(width: 80, palette: nil)
11
+ super()
12
+ @width = width.to_i
13
+ @palette = palette || {}
14
+ end
15
+
16
+ def block_code(code, language)
17
+ lexer = rouge_lexer(language.to_s, code.to_s)
18
+ formatter = Rouge::Formatters::Terminal256.new
19
+ gutter = md("│ ", :markdown_code_gutter)
20
+
21
+ highlighted = formatter.format(lexer.lex(code.to_s))
22
+ lines = highlighted.lines.map(&:chomp)
23
+
24
+ lines.pop while lines.any? && Fatty::Ansi.plain_text(lines.last).strip.empty?
25
+
26
+ body = lines.map { |line|
27
+ "#{gutter}#{line}"
28
+ }.join("\n")
29
+
30
+ "#{body}\n\n"
31
+ end
32
+
33
+ def normal_text(text)
34
+ text = render_inline_html(CGI.unescapeHTML(text.to_s))
35
+ text
36
+ end
37
+
38
+ def raw_html(html)
39
+ text = CGI.unescapeHTML(html.to_s)
40
+ render_inline_html(text)
41
+ end
42
+
43
+ def render_inline_html(text)
44
+ text.to_s
45
+ .gsub(%r{<br\s*/?>}i, HARD_BREAK)
46
+ .gsub(%r{<span\s+class=["']underline["']>(.*?)</span>}m) do
47
+ md(Regexp.last_match(1), :markdown_underline)
48
+ end
49
+ end
50
+
51
+ # Curses does not support strike-through, but this emulates it with
52
+ # unicode "combining characters" by adding a "Long Stroke Overlay"
53
+ # (U+0366) after each character, which overlays each with a strike-through
54
+ # overlay. We just had to make sure that Ansi.visible_length takes these
55
+ # into account in computing width.
56
+ def strikethrough(text)
57
+ text.to_s.each_char.map { |ch| "#{ch}\u0336" }.join
58
+ end
59
+
60
+ def link(link, _title, content)
61
+ label = content.to_s.empty? ? link : content
62
+ "#{md(label, :markdown_link)} #{md("<#{link}>", :markdown_url)}"
63
+ end
64
+
65
+ def codespan(code)
66
+ md(code.to_s, :markdown_code)
67
+ end
68
+
69
+ def double_emphasis(text)
70
+ md(text.to_s, :markdown_strong)
71
+ end
72
+
73
+ # Curses does not reliably render true italics, so we use underline instead.
74
+ def emphasis(text)
75
+ md(text.to_s, :markdown_emphasis)
76
+ end
77
+
78
+ def highlight(text)
79
+ md(text.to_s, :markdown_highlight)
80
+ end
81
+
82
+ def autolink(link, _link_type)
83
+ md(link.to_s, :markdown_link)
84
+ end
85
+
86
+ def quote(text)
87
+ "“#{text}”"
88
+ end
89
+
90
+ def block_quote(quote)
91
+ gutter = md("│ ", :markdown_quote_gutter)
92
+ quote = CGI.unescapeHTML(quote.to_s)
93
+ paragraphs = quote.to_s.split(/\n{2,}/).map do |para|
94
+ text = para.lines.map(&:strip).reject(&:empty?).join(" ")
95
+
96
+ if text.empty?
97
+ gutter
98
+ else
99
+ wrap(text, first_prefix: gutter, rest_prefix: gutter)
100
+ end
101
+ end
102
+ paragraphs.join("\n#{gutter}\n") + "\n\n"
103
+ end
104
+
105
+ def header(text, level)
106
+ body =
107
+ case level
108
+ when 1
109
+ h1(text)
110
+ when 2
111
+ h2(text)
112
+ when 3
113
+ h3(text)
114
+ else
115
+ Rainbow(text.to_s).bright.to_s
116
+ end
117
+ "#{body}\n\n"
118
+ end
119
+
120
+ def hrule
121
+ "#{md(TABLE_DASH * @width.to_i.clamp(20, 80), :markdown_hrule)}\n\n"
122
+ end
123
+
124
+ def linebreak
125
+ "\n"
126
+ end
127
+
128
+ def paragraph(text)
129
+ "#{wrap(text)}\n\n"
130
+ end
131
+
132
+ def list(contents, _type)
133
+ "#{contents}\n"
134
+ end
135
+
136
+ def list_item(text, _type)
137
+ text = render_inline_html(CGI.unescapeHTML(text.to_s))
138
+ head, rest = text.to_s.strip.split(/\n+/, 2)
139
+ out = wrap(
140
+ head.to_s.strip,
141
+ first_prefix: " • ",
142
+ rest_prefix: " ",
143
+ )
144
+
145
+ if rest && !rest.empty?
146
+ out << "\n"
147
+ out << indent_block(rest.rstrip, " ")
148
+ end
149
+
150
+ out << "\n"
151
+ out
152
+ end
153
+
154
+ def indent_block(text, prefix)
155
+ text.to_s.lines.map { |line|
156
+ if line.strip.empty?
157
+ line
158
+ else
159
+ "#{prefix}#{line}"
160
+ end
161
+ }.join
162
+ end
163
+
164
+ CELL_SEP = "\u001F"
165
+ TABLE_BAR = "│"
166
+ TABLE_DASH = "─"
167
+
168
+ def table(header, body)
169
+ rows = (header + body).lines.map do |line|
170
+ line.chomp.split(CELL_SEP, -1)
171
+ end
172
+
173
+ widths = table_widths(rows)
174
+
175
+ rendered = rows.each_with_index.map do |row, index|
176
+ header_row = index.zero?
177
+
178
+ line = render_table_row_cells(row, widths, header: header_row)
179
+
180
+ if header_row
181
+ [line, render_table_separator(widths)].join("\n")
182
+ else
183
+ line
184
+ end
185
+ end
186
+
187
+ rendered.join("\n") + "\n\n"
188
+ end
189
+
190
+ def table_row(content)
191
+ content.chomp.delete_suffix(CELL_SEP) + "\n"
192
+ end
193
+
194
+ def table_cell(content, _alignment)
195
+ inline(content.strip) + CELL_SEP
196
+ end
197
+
198
+ def table_widths(rows)
199
+ widths = []
200
+
201
+ rows.each do |row|
202
+ row.each_with_index do |cell, index|
203
+ width = Fatty::Ansi.visible_length(cell)
204
+ widths[index] = [widths[index] || 0, width].max
205
+ end
206
+ end
207
+
208
+ widths
209
+ end
210
+
211
+ def render_table_row_cells(row, widths, header: false)
212
+ cells = widths.each_with_index.map do |width, index|
213
+ text = row[index].to_s
214
+ text = header ? th(text) : td(text)
215
+ pad_visible(text, width)
216
+ end
217
+
218
+ " │ #{cells.join(' │ ')} │"
219
+ end
220
+
221
+ def render_table_separator(widths)
222
+ parts = widths.map do |width|
223
+ TABLE_DASH * (width + 2)
224
+ end
225
+
226
+ " #{TABLE_BAR}#{parts.join(TABLE_BAR)}#{TABLE_BAR}"
227
+ end
228
+
229
+ def pad_visible(text, width)
230
+ padding = width - Fatty::Ansi.visible_length(text)
231
+ padding = 0 if padding.negative?
232
+ "#{text}#{' ' * padding}"
233
+ end
234
+
235
+ def inline(text)
236
+ text.to_s
237
+ end
238
+
239
+ def h1(text)
240
+ md(text.to_s, :markdown_h1)
241
+ end
242
+
243
+ def h2(text)
244
+ md(text.to_s, :markdown_h2)
245
+ end
246
+
247
+ def h3(text)
248
+ md(text.to_s, :markdown_h3)
249
+ end
250
+
251
+ def th(text)
252
+ md(text.to_s, :markdown_table_header)
253
+ end
254
+
255
+ def td(text)
256
+ md(text.to_s, :markdown_table_cell)
257
+ end
258
+
259
+ def rouge_lexer(language, code)
260
+ lexer =
261
+ if !language.empty?
262
+ Rouge::Lexer.find_fancy(language, code)
263
+ else
264
+ Rouge::Lexer.guess(source: code)
265
+ end
266
+
267
+ lexer || Rouge::Lexers::PlainText.new
268
+ rescue StandardError
269
+ Rouge::Lexers::PlainText.new
270
+ end
271
+
272
+ private
273
+
274
+ def wrap(text, first_prefix: "", rest_prefix: first_prefix)
275
+ hard_lines = text.to_s.split(HARD_BREAK, -1)
276
+
277
+ hard_lines.each_with_index.map { |hard_line, index|
278
+ wrap_soft_line(
279
+ hard_line,
280
+ first_prefix: index.zero? ? first_prefix : rest_prefix,
281
+ rest_prefix: rest_prefix,
282
+ )
283
+ }.join("\n")
284
+ end
285
+
286
+ def wrap_soft_line(text, first_prefix: "", rest_prefix: first_prefix)
287
+ width = @width.to_i.clamp(20, 80)
288
+
289
+ words = text.to_s.split(/\s+/)
290
+ lines = []
291
+ line = +""
292
+
293
+ words.each do |word|
294
+ next if word.empty?
295
+
296
+ prefix = lines.empty? ? first_prefix : rest_prefix
297
+ available = width - Fatty::Ansi.visible_length(prefix)
298
+ available = 20 if available < 20
299
+
300
+ candidate = line.empty? ? word : "#{line} #{word}"
301
+
302
+ if Fatty::Ansi.visible_length(candidate) > available && !line.empty?
303
+ lines << "#{prefix}#{line}"
304
+ line = word.dup
305
+ else
306
+ line = candidate
307
+ end
308
+ end
309
+
310
+ unless line.empty?
311
+ prefix = lines.empty? ? first_prefix : rest_prefix
312
+ lines << "#{prefix}#{line}"
313
+ end
314
+
315
+ lines.empty? ? first_prefix.rstrip : lines.join("\n")
316
+ end
317
+
318
+ def md(text, role)
319
+ spec = @palette[role.to_sym] || {}
320
+ codes = sgr_codes_for(spec)
321
+
322
+ if codes.empty?
323
+ text.to_s
324
+ else
325
+ "\e[#{codes.join(';')}m#{text}\e[0m"
326
+ end
327
+ end
328
+
329
+ def sgr_codes_for(spec)
330
+ attrs = Array(spec[:attrs] || spec["attrs"]).map(&:to_sym)
331
+ codes = []
332
+
333
+ codes << 1 if attrs.include?(:bold)
334
+ codes << 2 if attrs.include?(:dim)
335
+ codes << 3 if attrs.include?(:italic)
336
+ codes << 4 if attrs.include?(:underline)
337
+ codes << 7 if attrs.include?(:reverse)
338
+
339
+ if (fg_rgb = spec[:fg_rgb] || spec["fg_rgb"])
340
+ codes.push(38, 2, *fg_rgb)
341
+ elsif (fg = spec[:fg] || spec["fg"])
342
+ codes.concat(color_codes(fg, foreground: true))
343
+ end
344
+
345
+ if (bg_rgb = spec[:bg_rgb] || spec["bg_rgb"])
346
+ codes.push(48, 2, *bg_rgb)
347
+ elsif (bg = spec[:bg] || spec["bg"])
348
+ codes.concat(color_codes(bg, foreground: false))
349
+ end
350
+ codes
351
+ end
352
+
353
+ def color_codes(color, foreground:)
354
+ color = color.to_i
355
+
356
+ if foreground
357
+ if color.between?(0, 7)
358
+ [30 + color]
359
+ elsif color.between?(8, 15)
360
+ [90 + color - 8]
361
+ else
362
+ [38, 5, color]
363
+ end
364
+ elsif color.between?(0, 7)
365
+ [40 + color]
366
+ elsif color.between?(8, 15)
367
+ [100 + color - 8]
368
+ else
369
+ [48, 5, color]
370
+ end
371
+ end
372
+ end
373
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ module Markdown
5
+ def self.render(text, width: 80, palette: nil)
6
+ markdown = Redcarpet::Markdown.new(
7
+ Fatty::AnsiRenderer.new(width: width, palette: palette),
8
+ no_intra_emphasis: true,
9
+ tables: true,
10
+ fenced_code_blocks: true,
11
+ autolink: true,
12
+ disable_indented_code_blocks: true,
13
+ strikethrough: true,
14
+ space_after_headers: true,
15
+ underline: true,
16
+ highlight: true,
17
+ footnotes: true,
18
+ )
19
+ markdown.render(text)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "markdown/ansi_renderer"
4
+ require_relative "markdown/render"
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ class MenuEnv
5
+ attr_reader :terminal, :session, :label, :payload
6
+
7
+ def initialize(terminal:, session:, label: '', payload: nil)
8
+ @terminal = terminal
9
+ @session = session
10
+ @label = label
11
+ @payload = payload
12
+ end
13
+
14
+ def output(text)
15
+ session.output.append(text.to_s)
16
+ end
17
+
18
+ def status(text)
19
+ terminal.set_status(text.to_s)
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ # The MouseEvent class is a simple class to store a mouse event with its
5
+ # modifiers.
6
+ class MouseEvent
7
+ attr_reader :button, :x, :y, :ctrl, :meta, :shift, :raw
8
+
9
+ def initialize(button:, x: nil, y:, raw: nil, ctrl: false, meta: false, shift: false)
10
+ @button = button
11
+ @x = x
12
+ @y = y
13
+ @raw = raw
14
+ @ctrl = ctrl
15
+ @meta = meta
16
+ @shift = shift
17
+ Fatty.debug("#{self.class}#new(#{self})", tag: :event)
18
+ end
19
+
20
+ def to_s
21
+ "MouseEvent: button: `#{button}`, x: `#{x}`, y: `#{y}`, raw: `#{raw}`, ctrl: `#{ctrl}`, meta: `#{meta}`, shift: `#{shift}`"
22
+ end
23
+
24
+ def printable?
25
+ false
26
+ end
27
+
28
+ def mouse?
29
+ true
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ class OutputBuffer
5
+ DEFAULT_MAX_LINES = 10_000
6
+
7
+ attr_reader :lines
8
+ attr_accessor :max_lines
9
+
10
+ def initialize(max_lines: DEFAULT_MAX_LINES)
11
+ @lines = []
12
+ @scroll = 0
13
+ @max_lines = Integer(max_lines)
14
+ @line_open = false
15
+ end
16
+
17
+ # Append text to the output buffer. Text may contain complete lines,
18
+ # partial lines, or both. Returns the number of lines trimmed. The
19
+ # Terminal can use this to adjust the Viewport.
20
+ def append(text)
21
+ ntrimmed = 0
22
+ str = text.to_s
23
+ return ntrimmed if str.empty?
24
+
25
+ str.split(/(\n)/).each do |part|
26
+ if part == "\n"
27
+ if @line_open
28
+ @line_open = false
29
+ else
30
+ ntrimmed += append_new_line("")
31
+ end
32
+ elsif !part.empty?
33
+ ntrimmed += append_fragment(part)
34
+ end
35
+ end
36
+
37
+ ntrimmed
38
+ end
39
+
40
+ def visible_lines(height)
41
+ start = [@lines.length - height - @scroll, 0].max
42
+ @lines[start, height] || []
43
+ end
44
+
45
+ def scroll_up
46
+ @scroll += 1
47
+ end
48
+
49
+ def scroll_down
50
+ @scroll -= 1 if @scroll.positive?
51
+ end
52
+
53
+ private
54
+
55
+ def append_fragment(fragment)
56
+ ntrimmed = 0
57
+ if @line_open && @lines.any?
58
+ @lines[-1] = "#{@lines[-1]}#{fragment}".freeze
59
+ else
60
+ ntrimmed += append_new_line(fragment)
61
+ @line_open = true
62
+ end
63
+ ntrimmed
64
+ end
65
+
66
+ def append_new_line(line)
67
+ ntrimmed = 0
68
+
69
+ if @lines.length >= @max_lines
70
+ @lines.shift
71
+ ntrimmed = 1
72
+ end
73
+
74
+ @lines << line.to_s.dup.freeze
75
+ ntrimmed
76
+ end
77
+ end
78
+ end