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
|
@@ -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'
|
data/lib/fatty/screen.rb
ADDED
|
@@ -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
|
data/lib/fatty/search.rb
ADDED
|
@@ -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
|