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.
- checksums.yaml +7 -0
- data/.envrc +2 -0
- data/.simplecov +23 -0
- data/.yardopts +4 -0
- data/CHANGELOG.md +34 -0
- data/CHANGELOG.org +38 -0
- data/LICENSE.txt +21 -0
- data/README.md +31 -0
- data/README.org +166 -0
- data/Rakefile +15 -0
- data/TODO.org +163 -0
- data/examples/markdown/native-markdown.md +370 -0
- data/examples/markdown/ox-gfm-markdown.md +373 -0
- data/examples/markdown/ox-gfm-markdown.org +376 -0
- data/exe/fatty +275 -0
- data/fatty.gemspec +42 -0
- data/lib/fatty/accept_env.rb +32 -0
- data/lib/fatty/action.rb +103 -0
- data/lib/fatty/action_environment.rb +42 -0
- data/lib/fatty/actionable.rb +73 -0
- data/lib/fatty/alert.rb +93 -0
- data/lib/fatty/ansi/renderer.rb +168 -0
- data/lib/fatty/ansi.rb +352 -0
- data/lib/fatty/colors/color.rb +379 -0
- data/lib/fatty/colors/pairs.rb +73 -0
- data/lib/fatty/colors/palette.rb +73 -0
- data/lib/fatty/colors/rgb.txt +788 -0
- data/lib/fatty/colors.rb +5 -0
- data/lib/fatty/config.rb +86 -0
- data/lib/fatty/config_files/config.yml +50 -0
- data/lib/fatty/config_files/help.md +120 -0
- data/lib/fatty/config_files/help.org +124 -0
- data/lib/fatty/config_files/keybindings.yml +49 -0
- data/lib/fatty/config_files/keydefs.yml +23 -0
- data/lib/fatty/config_files/themes/mono.yml +76 -0
- data/lib/fatty/config_files/themes/nordic.yml +77 -0
- data/lib/fatty/config_files/themes/solarized_dark.yml +77 -0
- data/lib/fatty/config_files/themes/terminal.yml +90 -0
- data/lib/fatty/config_files/themes/wordperfect.yml +77 -0
- data/lib/fatty/config_files/themes/wordperfect_light.yml +77 -0
- data/lib/fatty/core_ext/string.rb +21 -0
- data/lib/fatty/core_ext.rb +3 -0
- data/lib/fatty/counter.rb +81 -0
- data/lib/fatty/curses/context.rb +279 -0
- data/lib/fatty/curses/curses_coder.rb +684 -0
- data/lib/fatty/curses/event_source.rb +230 -0
- data/lib/fatty/curses/key_decoder.rb +183 -0
- data/lib/fatty/curses/patch.rb +116 -0
- data/lib/fatty/curses/window_styling.rb +32 -0
- data/lib/fatty/curses.rb +16 -0
- data/lib/fatty/env.rb +100 -0
- data/lib/fatty/help.rb +41 -0
- data/lib/fatty/history/entry.rb +71 -0
- data/lib/fatty/history.rb +289 -0
- data/lib/fatty/input_buffer.rb +998 -0
- data/lib/fatty/input_field.rb +507 -0
- data/lib/fatty/key_event.rb +342 -0
- data/lib/fatty/key_map.rb +392 -0
- data/lib/fatty/keymaps/emacs.rb +189 -0
- data/lib/fatty/log_formats/json.rb +47 -0
- data/lib/fatty/log_formats/text.rb +67 -0
- data/lib/fatty/logger.rb +142 -0
- data/lib/fatty/markdown/ansi_renderer.rb +373 -0
- data/lib/fatty/markdown/render.rb +22 -0
- data/lib/fatty/markdown.rb +4 -0
- data/lib/fatty/menu_env.rb +22 -0
- data/lib/fatty/mouse_event.rb +32 -0
- data/lib/fatty/output_buffer.rb +78 -0
- data/lib/fatty/pager.rb +801 -0
- data/lib/fatty/prompt.rb +40 -0
- data/lib/fatty/renderer/curses.rb +697 -0
- data/lib/fatty/renderer/truecolor.rb +607 -0
- data/lib/fatty/renderer.rb +419 -0
- data/lib/fatty/screen.rb +96 -0
- data/lib/fatty/search.rb +43 -0
- data/lib/fatty/session/alert_session.rb +52 -0
- data/lib/fatty/session/input_session.rb +99 -0
- data/lib/fatty/session/isearch_session.rb +172 -0
- data/lib/fatty/session/keytest_session.rb +236 -0
- data/lib/fatty/session/modal_session.rb +61 -0
- data/lib/fatty/session/output_session.rb +105 -0
- data/lib/fatty/session/popup_session.rb +540 -0
- data/lib/fatty/session/prompt_session.rb +157 -0
- data/lib/fatty/session/search_session.rb +136 -0
- data/lib/fatty/session/shell_session.rb +566 -0
- data/lib/fatty/session.rb +173 -0
- data/lib/fatty/sessions.rb +14 -0
- data/lib/fatty/terminal/popup_owner.rb +26 -0
- data/lib/fatty/terminal/progress.rb +374 -0
- data/lib/fatty/terminal.rb +1067 -0
- data/lib/fatty/themes/loader.rb +136 -0
- data/lib/fatty/themes/manager.rb +71 -0
- data/lib/fatty/themes/registry.rb +64 -0
- data/lib/fatty/themes/resolver.rb +224 -0
- data/lib/fatty/themes/themes.rb +131 -0
- data/lib/fatty/themes.rb +6 -0
- data/lib/fatty/version.rb +5 -0
- data/lib/fatty/view/alert_view.rb +14 -0
- data/lib/fatty/view/cursor_view.rb +18 -0
- data/lib/fatty/view/input_view.rb +9 -0
- data/lib/fatty/view/output_view.rb +9 -0
- data/lib/fatty/view/status_view.rb +14 -0
- data/lib/fatty/view.rb +33 -0
- data/lib/fatty/viewport.rb +90 -0
- data/lib/fatty/views.rb +9 -0
- data/lib/fatty.rb +55 -0
- data/sig/fatty.rbs +4 -0
- metadata +250 -0
data/lib/fatty/logger.rb
ADDED
|
@@ -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,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
|