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,90 @@
1
+ # lib/fatty/config_files/themes/terminal.yml
2
+ name: terminal
3
+ inherit: null
4
+
5
+ output:
6
+ fg: default
7
+ bg: default
8
+
9
+ input:
10
+ fg: default
11
+ bg: default
12
+
13
+ input_suggestion:
14
+ fg: default
15
+ bg: default
16
+ attrs: [dim]
17
+
18
+ cursor:
19
+ fg: default
20
+ bg: default
21
+ attrs: [reverse]
22
+
23
+ region:
24
+ fg: default
25
+ bg: default
26
+ attrs: [reverse]
27
+
28
+ info:
29
+ fg: default
30
+ bg: default
31
+
32
+ good:
33
+ fg: default
34
+ bg: default
35
+ attrs: [bold]
36
+
37
+ warn:
38
+ fg: default
39
+ bg: default
40
+ attrs: [reverse]
41
+
42
+ error:
43
+ fg: default
44
+ bg: default
45
+ attrs: [bold, reverse]
46
+
47
+ pager_status:
48
+ fg: default
49
+ bg: default
50
+ attrs: [reverse]
51
+
52
+ search_input:
53
+ fg: default
54
+ bg: default
55
+ attrs: [reverse]
56
+
57
+ match_current:
58
+ fg: default
59
+ bg: default
60
+ attrs: [reverse]
61
+
62
+ match_other:
63
+ fg: default
64
+ bg: default
65
+ attrs: [underline]
66
+
67
+ popup:
68
+ fg: default
69
+ bg: default
70
+
71
+ popup_selection:
72
+ fg: default
73
+ bg: default
74
+ attrs: [reverse]
75
+
76
+ popup_input:
77
+ fg: default
78
+ bg: default
79
+ attrs: [reverse]
80
+
81
+ popup_frame:
82
+ fg: default
83
+ bg: default
84
+ border: single
85
+ corners: square
86
+
87
+ popup_counts:
88
+ fg: default
89
+ bg: default
90
+ attrs: [bold]
@@ -0,0 +1,77 @@
1
+ name: wordperfect
2
+ inherit: null
3
+
4
+ output:
5
+ fg: white
6
+ bg: navy
7
+
8
+ input:
9
+ fg: white
10
+ bg: navy
11
+ attrs: [bold]
12
+
13
+ input_suggestion:
14
+ fg: lightgray
15
+ bg: navy
16
+
17
+ cursor:
18
+ fg: white
19
+ bg: red
20
+
21
+ region:
22
+ fg: navy
23
+ bg: yellow
24
+
25
+ good:
26
+ fg: green
27
+ bg: navy
28
+
29
+ info:
30
+ fg: black
31
+ bg: navy
32
+
33
+ warn:
34
+ fg: black
35
+ bg: magenta
36
+
37
+ error:
38
+ fg: white
39
+ bg: red
40
+
41
+ pager_status:
42
+ fg: black
43
+ bg: lightgreen
44
+
45
+ search_input:
46
+ fg: black
47
+ bg: cyan
48
+
49
+ match_current:
50
+ fg: black
51
+ bg: red
52
+
53
+ match_other:
54
+ fg: grey
55
+ bg: pink
56
+
57
+ popup:
58
+ fg: white
59
+ bg: navy
60
+
61
+ popup_input:
62
+ fg: white
63
+ bg: navy
64
+
65
+ popup_counts:
66
+ fg: navy
67
+ bg: red
68
+
69
+ popup_selection:
70
+ fg: navy
71
+ bg: yellow
72
+
73
+ popup_frame:
74
+ fg: navy
75
+ bg: yellow
76
+ border: single
77
+ corners: rounded
@@ -0,0 +1,77 @@
1
+ name: wordperfect_light
2
+ inherit: wordperfect
3
+
4
+ output:
5
+ fg: white
6
+ bg: lightblue
7
+
8
+ # input:
9
+ # fg: white
10
+ # bg: navy
11
+ # attrs: [bold]
12
+
13
+ # input_suggestion:
14
+ # fg: lightgray
15
+ # bg: navy
16
+
17
+ # cursor:
18
+ # fg: white
19
+ # bg: red
20
+
21
+ # region:
22
+ # fg: navy
23
+ # bg: yellow
24
+
25
+ # good:
26
+ # fg: green
27
+ # bg: navy
28
+
29
+ # info:
30
+ # fg: black
31
+ # bg: navy
32
+
33
+ # warn:
34
+ # fg: black
35
+ # bg: magenta
36
+
37
+ # error:
38
+ # fg: white
39
+ # bg: red
40
+
41
+ # pager_status:
42
+ # fg: black
43
+ # bg: lightgreen
44
+
45
+ # search_input:
46
+ # fg: black
47
+ # bg: cyan
48
+
49
+ # match_current:
50
+ # fg: black
51
+ # bg: red
52
+
53
+ # match_other:
54
+ # fg: grey
55
+ # bg: pink
56
+
57
+ # popup:
58
+ # fg: white
59
+ # bg: navy
60
+
61
+ # popup_input:
62
+ # fg: white
63
+ # bg: navy
64
+
65
+ # popup_counts:
66
+ # fg: navy
67
+ # bg: red
68
+
69
+ # popup_selection:
70
+ # fg: navy
71
+ # bg: yellow
72
+
73
+ # popup_frame:
74
+ # fg: navy
75
+ # bg: yellow
76
+ # border: single
77
+ # corners: rounded
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # These can be used to add "roles" to strings so that they can be styled
4
+ # according to the theme.
5
+ class String
6
+ def fatty_good
7
+ { text: self, role: :good }
8
+ end
9
+
10
+ def fatty_info
11
+ { text: self, role: :info }
12
+ end
13
+
14
+ def fatty_warn
15
+ { text: self, role: :warn }
16
+ end
17
+
18
+ def fatty_error
19
+ { text: self, role: :error }
20
+ end
21
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "core_ext/string"
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fatty
4
+ # Counter accumulates and otherwise managed a numeric prefix count (e.g.,
5
+ # "12").
6
+ #
7
+ # Intended usage:
8
+ # counter.push_digit(1)
9
+ # counter.push_digit(2)
10
+ # n = counter.consume(default: 1) # => 12 (and clears)
11
+ #
12
+ class Counter
13
+ MAX_DIGITS = 6
14
+
15
+ def initialize
16
+ @digits = +""
17
+ @replace_next = false
18
+ end
19
+
20
+ def to_s
21
+ "Counter: #{@digits}; #{@replace_next}"
22
+ end
23
+
24
+ def active?
25
+ !@digits.empty?
26
+ end
27
+
28
+ def clear!
29
+ @digits.clear
30
+ @replace_next = false
31
+ self
32
+ end
33
+
34
+ def digits
35
+ @digits.dup
36
+ end
37
+
38
+ def push_digit(n)
39
+ if @replace_next
40
+ @digits.clear
41
+ @replace_next = false
42
+ end
43
+ if @digits.length < MAX_DIGITS
44
+ @digits << n.to_i.to_s
45
+ end
46
+ self
47
+ end
48
+
49
+ def set(n)
50
+ @digits = n.to_i.to_s
51
+ @replace_next = false
52
+ self
53
+ end
54
+
55
+ def universal_argument!
56
+ if active?
57
+ set(value * 4)
58
+ else
59
+ set(4)
60
+ @replace_next = true # next digit replaces the "4"
61
+ end
62
+ self
63
+ end
64
+
65
+ def value
66
+ if active?
67
+ @digits.to_i
68
+ end
69
+ end
70
+
71
+ def consume(default: nil)
72
+ n = value
73
+ if n.nil?
74
+ default
75
+ else
76
+ clear!
77
+ n
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "curses"
4
+
5
+ module Fatty
6
+ module Curses
7
+ # Context represents the active curses environment.
8
+ #
9
+ # It owns:
10
+ # - curses initialization and shutdown
11
+ # - terminal mode configuration
12
+ # - window lifecycle
13
+ #
14
+ # Context does NOT:
15
+ # - read input
16
+ # - decode keys
17
+ # - render UI
18
+ #
19
+ # Those responsibilities belong to EventSource and Renderer.
20
+ #
21
+ # Context exists so that all curses state is centralized and never leaks
22
+ # into Sessions, Views, or Terminal.
23
+ class Context
24
+ DEFAULT_ESC_DELAY = 25
25
+
26
+ attr_reader :input_win, :output_win, :status_win, :alert_win
27
+ attr_reader :rows, :cols, :palette, :truecolor
28
+
29
+ def initialize
30
+ @started = false
31
+ end
32
+
33
+ def start
34
+ return self if @started
35
+
36
+ ::Curses.init_screen
37
+ configure_escape_delay!
38
+ MouseConstants.ensure!
39
+
40
+ ::Curses.raw
41
+ ::Curses.noecho
42
+ ::Curses.curs_set(1)
43
+ ::Curses.stdscr.keypad(true)
44
+ ::Curses.mousemask(::Curses::ALL_MOUSE_EVENTS)
45
+ enable_bracketed_paste!
46
+ setup_colors
47
+ @truecolor = truecolor_enabled?
48
+ @started = true
49
+ self
50
+ end
51
+
52
+ def configure_escape_delay!
53
+ delay =
54
+ if ENV["ESCDELAY"]
55
+ ENV["ESCDELAY"].to_i
56
+ else
57
+ Fatty::Config.config.dig(:esc_delay)&.to_i
58
+ end
59
+ delay = DEFAULT_ESC_DELAY if delay.nil? || delay <= 0
60
+ if ::Curses.respond_to?(:set_escdelay)
61
+ ::Curses.set_escdelay(delay)
62
+ else
63
+ ENV["ESCDELAY"] = delay.to_s
64
+ end
65
+ Fatty.info("ESC delay set to #{delay} ms", tag: :input)
66
+ end
67
+
68
+ def setup_colors
69
+ return unless ::Curses.has_colors?
70
+
71
+ reset_ansi_pairs!
72
+ ::Curses.start_color
73
+ ::Curses.use_default_colors if ::Curses.respond_to?(:use_default_colors)
74
+
75
+ theme_spec = Fatty::Themes::Manager.roles(Fatty::Themes::Manager.current) || {}
76
+ palette = Fatty::Colors::Palette.compile(
77
+ theme_spec,
78
+ available_colors: ::Curses.colors,
79
+ )
80
+ apply_palette(palette)
81
+ end
82
+
83
+ # Allocate or reallocate windows using Screen layout.
84
+ def apply_layout(screen)
85
+ ensure_started!
86
+
87
+ @rows = screen.rows
88
+ @cols = screen.cols
89
+
90
+ close_windows
91
+
92
+ out = screen.output_rect
93
+ sts = screen.status_rect
94
+ inp = screen.input_rect
95
+ alr = screen.alert_rect
96
+
97
+ @output_win = ::Curses::Window.new(out.rows, out.cols, out.row, out.col)
98
+ @status_win = ::Curses::Window.new(sts.rows, sts.cols, sts.row, sts.col)
99
+ @input_win = ::Curses::Window.new(inp.rows, inp.cols, inp.row, inp.col)
100
+ @alert_win = ::Curses::Window.new(alr.rows, alr.cols, alr.row, alr.col)
101
+
102
+ # We do our own viewport/paging; allowing curses to scroll introduces
103
+ # “mystery” blank lines if a newline slips into output
104
+ @output_win.scrollok(true)
105
+ @input_win.keypad(true)
106
+ self
107
+ end
108
+
109
+ def close
110
+ close_windows
111
+ disable_bracketed_paste! if @started
112
+ ::Curses.close_screen if @started
113
+ @started = false
114
+ end
115
+
116
+ # Map a Fatty::Ansi::Style to a curses attribute.
117
+ #
118
+ # - If style has no explicit fg/bg, we keep the themed role pair.
119
+ # - If style specifies fg/bg, we allocate/init a curses pair on demand.
120
+ #
121
+ # Note: this is intentionally independent of theme roles; it is for SGR
122
+ # output runs inside the output pane.
123
+ def truecolor_enabled?
124
+ cfg = Fatty::Config.config
125
+
126
+ setting =
127
+ if cfg.key?(:truecolor)
128
+ cfg[:truecolor]
129
+ else
130
+ "auto"
131
+ end
132
+ @truecolor =
133
+ case setting.to_s.downcase
134
+ when "true", "yes", "on", "1"
135
+ true
136
+ when "false", "no", "off", "0"
137
+ false
138
+ else
139
+ truecolor_env?
140
+ end
141
+ Fatty.info(
142
+ "truecolor=#{@truecolor} setting=#{setting.inspect} " \
143
+ "TERM=#{ENV['TERM'].inspect} COLORTERM=#{ENV['COLORTERM'].inspect} " \
144
+ "TERM_PROGRAM=#{ENV['TERM_PROGRAM'].inspect} TMUX=#{ENV.key?('TMUX')}",
145
+ tag: :themes,
146
+ )
147
+ @truecolor
148
+ end
149
+
150
+ def truecolor_env?
151
+ colorterm = ENV["COLORTERM"].to_s
152
+ term = ENV["TERM"].to_s
153
+ term_program = ENV["TERM_PROGRAM"].to_s
154
+
155
+ colorterm.match?(/truecolor|24bit/i) ||
156
+ term.match?(/truecolor|24bit|direct/i) ||
157
+ term.match?(/kitty|wezterm|alacritty|ghostty|foot/i) ||
158
+ term_program.match?(/kitty|wezterm|alacritty|ghostty|iTerm/i)
159
+ end
160
+
161
+ # Map a Fatty::Ansi::Style to a curses attribute.
162
+ #
163
+ # - If style has no explicit fg/bg, keep the themed role pair.
164
+ # - If style specifies fg/bg, allocate/init a curses pair on demand.
165
+ #
166
+ # This is intentionally independent of theme roles; it is for SGR output
167
+ # runs inside the output pane.
168
+ def ansi_attr(style, fallback_role: :output)
169
+ base_pair_id = Fatty::Colors::Pairs::ROLE_TO_PAIR.fetch(fallback_role)
170
+ base_attr = ::Curses.color_pair(base_pair_id)
171
+
172
+ has_explicit = !(style.fg.nil? && style.bg.nil?)
173
+ attr =
174
+ if has_explicit
175
+ pair_id = ansi_pair_id(style.fg, style.bg, fallback_pair_id: base_pair_id)
176
+ ::Curses.color_pair(pair_id)
177
+ else
178
+ base_attr
179
+ end
180
+ attr |= ::Curses::A_BOLD if style.bold
181
+ attr |= ::Curses::A_UNDERLINE if style.underline
182
+ attr |= ::Curses::A_REVERSE if style.reverse
183
+ if style.italic && defined?(::Curses::A_ITALIC)
184
+ attr |= ::Curses::A_ITALIC
185
+ end
186
+ if style.strike && defined?(::Curses::A_HORIZONTAL)
187
+ attr |= ::Curses::A_HORIZONTAL
188
+ end
189
+ attr
190
+ end
191
+
192
+ def apply_palette(palette)
193
+ if ::Curses.has_colors?
194
+ ::Curses.start_color
195
+ ::Curses.use_default_colors if ::Curses.respond_to?(:use_default_colors)
196
+ palette.each_value do |entry|
197
+ next unless entry[:pair]
198
+
199
+ ::Curses.init_pair(entry[:pair], entry[:fg], entry[:bg])
200
+ end
201
+ end
202
+ @palette = palette
203
+ end
204
+
205
+ private
206
+
207
+ def ensure_started!
208
+ return if @started
209
+
210
+ raise "Curses::Context not started"
211
+ end
212
+
213
+ def close_windows
214
+ @output_win&.close
215
+ @input_win&.close
216
+ @alert_win&.close
217
+ @status_win&.close
218
+
219
+ @output_win = nil
220
+ @input_win = nil
221
+ @alert_win = nil
222
+ @status_win = nil
223
+ end
224
+
225
+ # Generate a map from [fg, bg] pairs to Cuses pair ids.
226
+ def ansi_pair_id(fg, bg, fallback_pair_id:)
227
+ has_colors = ::Curses.has_colors?
228
+ return fallback_pair_id unless has_colors
229
+
230
+ @ansi_pairs ||= {}
231
+ @ansi_pair_limit ||= begin
232
+ ::Curses.color_pairs
233
+ rescue StandardError
234
+ 256
235
+ end
236
+
237
+ key = [fg || -1, bg || -1]
238
+ cached = @ansi_pairs[key]
239
+ return cached if cached
240
+
241
+ # Leave low pair ids for stable theme roles.
242
+ start_id = 64
243
+ @ansi_next_pair_id ||= start_id
244
+ @ansi_next_pair_id = start_id if @ansi_next_pair_id < start_id
245
+
246
+ if @ansi_next_pair_id >= @ansi_pair_limit
247
+ @ansi_pairs[key] = fallback_pair_id
248
+ else
249
+ pair_id = @ansi_next_pair_id
250
+ @ansi_next_pair_id += 1
251
+
252
+ fg_i = fg.nil? ? -1 : fg.to_i
253
+ bg_i = bg.nil? ? -1 : bg.to_i
254
+ ::Curses.init_pair(pair_id, fg_i, bg_i)
255
+ @ansi_pairs[key] = pair_id
256
+ end
257
+
258
+ @ansi_pairs[key]
259
+ end
260
+
261
+ def reset_ansi_pairs!
262
+ @ansi_pairs = {}
263
+ @ansi_next_pair_id = 1
264
+ end
265
+
266
+ def enable_bracketed_paste!
267
+ $stdout.write("\e[?2004h")
268
+ $stdout.flush
269
+ nil
270
+ end
271
+
272
+ def disable_bracketed_paste!
273
+ $stdout.write("\e[?2004l")
274
+ $stdout.flush
275
+ nil
276
+ end
277
+ end
278
+ end
279
+ end