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,1067 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'terminal/progress'
|
|
4
|
+
require_relative 'terminal/popup_owner'
|
|
5
|
+
|
|
6
|
+
module Fatty
|
|
7
|
+
class Terminal
|
|
8
|
+
SCROLL_RENDER_THROTTLE = 0.05
|
|
9
|
+
DEFAULT_STATUS_MAX_ROWS = 4
|
|
10
|
+
|
|
11
|
+
# Commands are plain Ruby arrays for now.
|
|
12
|
+
#
|
|
13
|
+
# Suggested shapes:
|
|
14
|
+
#
|
|
15
|
+
# Terminal/runtime commands:
|
|
16
|
+
# [:terminal, :quit]
|
|
17
|
+
# [:terminal, :push, session]
|
|
18
|
+
# [:terminal, :pop]
|
|
19
|
+
#
|
|
20
|
+
# Session-targeted commands (no special casing):
|
|
21
|
+
# [:send, :alert, :show, { level: :warn, message: "No matches" }]
|
|
22
|
+
# [:send, :alert, :clear, {}]
|
|
23
|
+
#
|
|
24
|
+
# You can add more later; Terminal only needs a small dispatcher.
|
|
25
|
+
|
|
26
|
+
attr_reader :screen, :renderer, :event_source, :status_text, :status_role, :env
|
|
27
|
+
|
|
28
|
+
def initialize(prompt: "> ",
|
|
29
|
+
on_accept: nil,
|
|
30
|
+
completion_proc: nil,
|
|
31
|
+
history_path: :default,
|
|
32
|
+
history_ctx: nil,
|
|
33
|
+
env: nil)
|
|
34
|
+
@prompt = Prompt.ensure(prompt)
|
|
35
|
+
@on_accept = on_accept
|
|
36
|
+
@completion_proc = completion_proc
|
|
37
|
+
@history_path = history_path
|
|
38
|
+
@history_ctx = history_ctx
|
|
39
|
+
@env = env
|
|
40
|
+
|
|
41
|
+
@running = false
|
|
42
|
+
@stack = []
|
|
43
|
+
@pinned = []
|
|
44
|
+
@sessions = []
|
|
45
|
+
@sessions_by_id = {}
|
|
46
|
+
@modal_stack = []
|
|
47
|
+
@status_text = nil
|
|
48
|
+
@status_role = :info
|
|
49
|
+
@status_transient = false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# --- Status line management ------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def set_status(text, role: :info, transient: false)
|
|
55
|
+
old_rows = status_rows
|
|
56
|
+
str =
|
|
57
|
+
if text.is_a?(Array)
|
|
58
|
+
text.map { |part| part.is_a?(Hash) && part.key?(:text) ? part[:text] : part }.join
|
|
59
|
+
else
|
|
60
|
+
text.to_s
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
if str.empty?
|
|
64
|
+
@status_text = nil
|
|
65
|
+
@status_role = :info
|
|
66
|
+
@status_transient = false
|
|
67
|
+
else
|
|
68
|
+
@status_text = text
|
|
69
|
+
@status_role = role
|
|
70
|
+
@status_transient = transient
|
|
71
|
+
end
|
|
72
|
+
refresh_layout! if @screen && old_rows != status_rows
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def clear_status
|
|
76
|
+
old_rows = status_rows
|
|
77
|
+
@status_text = nil
|
|
78
|
+
@status_role = :info
|
|
79
|
+
@status_transient = false
|
|
80
|
+
refresh_layout! if @screen && old_rows != status_rows
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def transient_status?
|
|
84
|
+
!!@status_transient
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Display a message to the user in the status line, colored according to
|
|
88
|
+
# the Config for "info".
|
|
89
|
+
def info(text)
|
|
90
|
+
return $stderr.puts(text) unless @ctx
|
|
91
|
+
|
|
92
|
+
set_status(text.to_s, role: :info, transient: true)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Display a message to the user in the status line, colored according to
|
|
96
|
+
# the Config for "good," i.e., success.
|
|
97
|
+
def good(text)
|
|
98
|
+
return $stderr.puts(text) unless @ctx
|
|
99
|
+
|
|
100
|
+
set_status(text.to_s, role: :good, transient: true)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Display a message to the user in the status line, colored according to
|
|
104
|
+
# the Config for "warn," i.e., short of an error but not complete
|
|
105
|
+
# success either.
|
|
106
|
+
def warn(text)
|
|
107
|
+
return $stderr.puts(text) unless @ctx
|
|
108
|
+
|
|
109
|
+
set_status(text.to_s, role: :warn, transient: true)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Display a message to the user in the status line, colored according to
|
|
113
|
+
# the Config for "oops," i.e., a soft failure.
|
|
114
|
+
def oops(text)
|
|
115
|
+
return $stderr.puts(text) unless @ctx
|
|
116
|
+
|
|
117
|
+
set_status(text.to_s, role: :error, transient: true)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# --- Session management ------------------------------------------------
|
|
121
|
+
|
|
122
|
+
def push(session)
|
|
123
|
+
Fatty.debug("Terminal#push(#{session})", tag: :session)
|
|
124
|
+
@stack << session
|
|
125
|
+
register(session)
|
|
126
|
+
commands = session.init(terminal: self)
|
|
127
|
+
apply_commands(commands)
|
|
128
|
+
session
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def pop
|
|
132
|
+
Fatty.debug("Terminal#pop -> #{@stack.last}", tag: :session)
|
|
133
|
+
@stack.pop
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def pin(session)
|
|
137
|
+
Fatty.debug("Terminal#pin(#{session})", tag: :session)
|
|
138
|
+
@pinned << session
|
|
139
|
+
register(session)
|
|
140
|
+
commands = session.init(terminal: self)
|
|
141
|
+
apply_commands(commands)
|
|
142
|
+
session
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def active_session
|
|
146
|
+
top = @modal_stack.last
|
|
147
|
+
return top[:session] if top
|
|
148
|
+
|
|
149
|
+
focused_session
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def focused_session
|
|
153
|
+
top = @stack.last
|
|
154
|
+
return top[:session] if top.is_a?(Hash)
|
|
155
|
+
|
|
156
|
+
top
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def register(session)
|
|
160
|
+
return unless session.respond_to?(:id)
|
|
161
|
+
return if session.id.nil?
|
|
162
|
+
|
|
163
|
+
@sessions_by_id[session.id] = session
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def find_session(id)
|
|
167
|
+
@sessions_by_id[id]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def push_modal(session, owner:)
|
|
171
|
+
@modal_stack << { session: session, owner: owner }
|
|
172
|
+
msg = "Terminal#push_modal: size=#{@modal_stack.length} session=#{session.class} object_id=#{session.object_id}"
|
|
173
|
+
Fatty.debug(msg, tag: :session)
|
|
174
|
+
register(session)
|
|
175
|
+
@renderer.invalidate! if defined?(@renderer) && @renderer
|
|
176
|
+
commands = session.init(terminal: self)
|
|
177
|
+
apply_commands(commands)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def pop_modal
|
|
181
|
+
top = @modal_stack.pop
|
|
182
|
+
msg = "Terminal#pop_modal: size=#{@modal_stack.length} popped=#{top && top[:session].class}"
|
|
183
|
+
Fatty.debug(msg, tag: :session)
|
|
184
|
+
session = top && top[:session]
|
|
185
|
+
|
|
186
|
+
session.close if session&.respond_to?(:close)
|
|
187
|
+
@renderer&.invalidate!
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Return the owner of the top modal session without modifying the stack.
|
|
192
|
+
def modal_owner
|
|
193
|
+
top = @modal_stack.last
|
|
194
|
+
top && top[:owner]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# --- Runtime -----------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
def go
|
|
200
|
+
preflight!
|
|
201
|
+
start_curses!
|
|
202
|
+
install_default_sessions!
|
|
203
|
+
|
|
204
|
+
@running = true
|
|
205
|
+
last_render = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
206
|
+
pending_scroll_render = false
|
|
207
|
+
render_frame
|
|
208
|
+
|
|
209
|
+
# For performance logging
|
|
210
|
+
perf_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
211
|
+
loop_count = 0
|
|
212
|
+
event_count = 0
|
|
213
|
+
tick_dirty_count = 0
|
|
214
|
+
render_count = 0
|
|
215
|
+
deferred_count = 0
|
|
216
|
+
frame_ms = 0.0
|
|
217
|
+
|
|
218
|
+
while @running
|
|
219
|
+
loop_count += 1
|
|
220
|
+
|
|
221
|
+
dirty = false
|
|
222
|
+
immediate = false
|
|
223
|
+
|
|
224
|
+
msg = event_source.next_event
|
|
225
|
+
if msg
|
|
226
|
+
dispatch_message(msg)
|
|
227
|
+
dirty = true
|
|
228
|
+
immediate = true
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
s = active_session
|
|
232
|
+
begin
|
|
233
|
+
tick_dirty = !!s&.tick
|
|
234
|
+
tick_dirty_count += 1 if tick_dirty
|
|
235
|
+
dirty ||= tick_dirty
|
|
236
|
+
rescue StandardError => e
|
|
237
|
+
Fatty.error("Terminal#go tick failed: #{e.class}: #{e.message}", tag: :terminal)
|
|
238
|
+
dirty = true
|
|
239
|
+
immediate = true
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
if dirty
|
|
243
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
244
|
+
|
|
245
|
+
if immediate || renderer.context.truecolor || !scrolling_output?
|
|
246
|
+
render_frame
|
|
247
|
+
last_render = now
|
|
248
|
+
pending_scroll_render = false
|
|
249
|
+
render_count += 1
|
|
250
|
+
elsif now - last_render >= SCROLL_RENDER_THROTTLE
|
|
251
|
+
render_frame
|
|
252
|
+
last_render = now
|
|
253
|
+
pending_scroll_render = false
|
|
254
|
+
render_count += 1
|
|
255
|
+
else
|
|
256
|
+
pending_scroll_render = true
|
|
257
|
+
deferred_count += 1
|
|
258
|
+
end
|
|
259
|
+
elsif pending_scroll_render
|
|
260
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
261
|
+
if now - last_render >= SCROLL_RENDER_THROTTLE
|
|
262
|
+
render_frame
|
|
263
|
+
last_render = now
|
|
264
|
+
pending_scroll_render = false
|
|
265
|
+
render_count += 1
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
270
|
+
if now - perf_started_at >= 1.0
|
|
271
|
+
avg_frame_ms =
|
|
272
|
+
if render_count.zero?
|
|
273
|
+
0.0
|
|
274
|
+
else
|
|
275
|
+
(frame_ms / render_count).round(2)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Performance logging
|
|
279
|
+
Fatty.debug(
|
|
280
|
+
"perf loops=#{loop_count} events=#{event_count} " \
|
|
281
|
+
"tick_dirty=#{tick_dirty_count} renders=#{render_count} " \
|
|
282
|
+
"deferred=#{deferred_count} avg_frame_ms=#{avg_frame_ms} " \
|
|
283
|
+
"scrolling=#{scrolling_output?}",
|
|
284
|
+
tag: :perf,
|
|
285
|
+
)
|
|
286
|
+
perf_started_at = now
|
|
287
|
+
loop_count = 0
|
|
288
|
+
event_count = 0
|
|
289
|
+
tick_dirty_count = 0
|
|
290
|
+
render_count = 0
|
|
291
|
+
deferred_count = 0
|
|
292
|
+
frame_ms = 0.0
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
rescue => e
|
|
296
|
+
Fatty.error("Terminal#go fatal error: #{e.class}: #{e.message}", tag: :terminal)
|
|
297
|
+
Fatty.error(e.backtrace.join("\n"), tag: :terminal) if e.backtrace
|
|
298
|
+
raise
|
|
299
|
+
ensure
|
|
300
|
+
begin
|
|
301
|
+
stop_curses!
|
|
302
|
+
rescue => e
|
|
303
|
+
Fatty.error("Terminal#go stop_curses! failed: #{e.class}: #{e.message}", tag: :terminal)
|
|
304
|
+
Fatty.error(e.backtrace.join("\n"), tag: :terminal) if e.backtrace
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
begin
|
|
308
|
+
persist_sessions!
|
|
309
|
+
rescue => e
|
|
310
|
+
Fatty.error("Terminal#go persist_sessions! failed: #{e.class}: #{e.message}", tag: :terminal)
|
|
311
|
+
Fatty.error(e.backtrace.join("\n"), tag: :terminal) if e.backtrace
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# The consumer can call #choose to cause an interactive popup session to
|
|
316
|
+
# present the user with a series of choices to select from.
|
|
317
|
+
def choose(prompt, choices:, initial_choice_idx: 0, quit_value: nil)
|
|
318
|
+
items = normalize_choices(choices)
|
|
319
|
+
raise ArgumentError, "choices must not be empty" if items.empty?
|
|
320
|
+
|
|
321
|
+
labels = items.map(&:first)
|
|
322
|
+
popup = Fatty::PopUpSession.new(
|
|
323
|
+
source: labels,
|
|
324
|
+
kind: :terminal_choose,
|
|
325
|
+
title: "Choose",
|
|
326
|
+
message: prompt,
|
|
327
|
+
prompt: "> ",
|
|
328
|
+
selection: :top,
|
|
329
|
+
validate_unique_labels: true,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
popup.instance_variable_set(:@selected, initial_choice_idx.to_i.clamp(0, labels.length - 1))
|
|
333
|
+
|
|
334
|
+
done = false
|
|
335
|
+
result = nil
|
|
336
|
+
|
|
337
|
+
acc_proc = ->(payload) do
|
|
338
|
+
item = payload[:item]
|
|
339
|
+
idx = labels.index(item)
|
|
340
|
+
result = idx ? items[idx][1] : quit_value
|
|
341
|
+
done = true
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
cancel_proc = -> do
|
|
345
|
+
result = quit_value
|
|
346
|
+
done = true
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
owner = PopupOwner.new(on_result: acc_proc, on_cancel: cancel_proc)
|
|
350
|
+
begin
|
|
351
|
+
push_modal(popup, owner: owner)
|
|
352
|
+
render_frame
|
|
353
|
+
|
|
354
|
+
while !done && @running
|
|
355
|
+
dirty = false
|
|
356
|
+
msg = event_source.next_event
|
|
357
|
+
if msg
|
|
358
|
+
dispatch_message(msg)
|
|
359
|
+
dirty = true
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
s = active_session
|
|
363
|
+
begin
|
|
364
|
+
tick_dirty = !!s&.tick
|
|
365
|
+
dirty ||= tick_dirty
|
|
366
|
+
rescue StandardError => e
|
|
367
|
+
Fatty.error("Terminal#choose tick failed: #{e.class}: #{e.message}", tag: :terminal)
|
|
368
|
+
dirty = true
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
render_frame if dirty
|
|
372
|
+
end
|
|
373
|
+
ensure
|
|
374
|
+
render_frame
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
result
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# A simple Yes/No chooser.
|
|
381
|
+
def confirm(prompt, default: true)
|
|
382
|
+
idx = default ? 0 : 1
|
|
383
|
+
choose(
|
|
384
|
+
prompt,
|
|
385
|
+
choices: [["Yes", true], ["No", false]],
|
|
386
|
+
initial_choice_idx: idx,
|
|
387
|
+
quit_value: false,
|
|
388
|
+
)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# The consumer can call #choose_multi to cause an interactive popup session to
|
|
392
|
+
# present the user with a series of choices to select from.
|
|
393
|
+
def choose_multi(prompt, choices:, quit_value: nil)
|
|
394
|
+
items = normalize_choices(choices)
|
|
395
|
+
raise ArgumentError, "choices must not be empty" if items.empty?
|
|
396
|
+
|
|
397
|
+
labels = items.map(&:first)
|
|
398
|
+
popup = Fatty::PopUpSession.new(
|
|
399
|
+
source: labels,
|
|
400
|
+
kind: :terminal_choose_multi,
|
|
401
|
+
title: "Choose Many",
|
|
402
|
+
message: prompt,
|
|
403
|
+
prompt: "> ",
|
|
404
|
+
selection: :top,
|
|
405
|
+
selection_mode: :multiple,
|
|
406
|
+
validate_unique_labels: true,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
done = false
|
|
410
|
+
result = nil
|
|
411
|
+
|
|
412
|
+
label_to_value = items.to_h
|
|
413
|
+
acc_proc = ->(payload) do
|
|
414
|
+
selected = payload[:items] || {}
|
|
415
|
+
|
|
416
|
+
result =
|
|
417
|
+
selected.each_with_object({}) do |(label, _), h|
|
|
418
|
+
h[label] = label_to_value.fetch(label, quit_value)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
done = true
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
cancel_proc = -> do
|
|
425
|
+
result = quit_value
|
|
426
|
+
done = true
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
owner = PopupOwner.new(on_result: acc_proc, on_cancel: cancel_proc)
|
|
430
|
+
|
|
431
|
+
begin
|
|
432
|
+
push_modal(popup, owner: owner)
|
|
433
|
+
render_frame
|
|
434
|
+
|
|
435
|
+
while !done && @running
|
|
436
|
+
dirty = false
|
|
437
|
+
msg = event_source.next_event
|
|
438
|
+
if msg
|
|
439
|
+
dispatch_message(msg)
|
|
440
|
+
dirty = true
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
s = active_session
|
|
444
|
+
begin
|
|
445
|
+
tick_dirty = !!s&.tick
|
|
446
|
+
dirty ||= tick_dirty
|
|
447
|
+
rescue StandardError => e
|
|
448
|
+
Fatty.error("Terminal#choose_multi tick failed: #{e.class}: #{e.message}", tag: :terminal)
|
|
449
|
+
dirty = true
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
render_frame if dirty
|
|
453
|
+
end
|
|
454
|
+
ensure
|
|
455
|
+
render_frame
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
result
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Create a popup to ask the user to enter an arbitrary string. These
|
|
462
|
+
# prompts will keep their own history based on the history_key, or if not
|
|
463
|
+
# history_key is given, the prompt text.
|
|
464
|
+
def prompt(prompt, initial: "", quit_value: nil, history_key: nil)
|
|
465
|
+
history_ctx = { prompt: (history_key || prompt).to_s }
|
|
466
|
+
|
|
467
|
+
popup = Fatty::PromptSession.new(
|
|
468
|
+
title: "Prompt",
|
|
469
|
+
message: prompt,
|
|
470
|
+
prompt: "> ",
|
|
471
|
+
initial: initial,
|
|
472
|
+
kind: :terminal_prompt,
|
|
473
|
+
history_ctx: history_ctx,
|
|
474
|
+
)
|
|
475
|
+
done = false
|
|
476
|
+
result = nil
|
|
477
|
+
|
|
478
|
+
acc_proc = ->(payload) do
|
|
479
|
+
result = payload[:text]
|
|
480
|
+
done = true
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
cancel_proc = -> do
|
|
484
|
+
result = quit_value
|
|
485
|
+
done = true
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
owner = PopupOwner.new(on_result: acc_proc, on_cancel: cancel_proc)
|
|
489
|
+
|
|
490
|
+
begin
|
|
491
|
+
push_modal(popup, owner: owner)
|
|
492
|
+
render_frame
|
|
493
|
+
|
|
494
|
+
while !done && @running
|
|
495
|
+
dirty = false
|
|
496
|
+
msg = event_source.next_event
|
|
497
|
+
if msg
|
|
498
|
+
dispatch_message(msg)
|
|
499
|
+
dirty = true
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
s = active_session
|
|
503
|
+
begin
|
|
504
|
+
tick_dirty = !!s&.tick
|
|
505
|
+
dirty ||= tick_dirty
|
|
506
|
+
rescue StandardError => e
|
|
507
|
+
Fatty.error("Terminal#prompt tick failed: #{e.class}: #{e.message}", tag: :terminal)
|
|
508
|
+
dirty = true
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
render_frame if dirty
|
|
512
|
+
end
|
|
513
|
+
ensure
|
|
514
|
+
render_frame
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
result
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# Create a transient status-line progress indicator.
|
|
521
|
+
# For style :spinner, total may be omitted for indeterminate progress.
|
|
522
|
+
def progress(label:, total: nil, style: :percent, role: :info, width: 40)
|
|
523
|
+
Progress.new(
|
|
524
|
+
terminal: self,
|
|
525
|
+
label: label,
|
|
526
|
+
total: total,
|
|
527
|
+
style: style,
|
|
528
|
+
role: role,
|
|
529
|
+
width: width,
|
|
530
|
+
)
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# Present a chooser whose selected value is executed.
|
|
534
|
+
#
|
|
535
|
+
# choices may be:
|
|
536
|
+
# [["Label", proc { ... }], ...]
|
|
537
|
+
# or:
|
|
538
|
+
# { "Label" => proc { ... } }
|
|
539
|
+
#
|
|
540
|
+
# The proc may accept:
|
|
541
|
+
# 0 args
|
|
542
|
+
# terminal:
|
|
543
|
+
# terminal:, label:
|
|
544
|
+
# terminal:, label:, payload:
|
|
545
|
+
def menu(prompt, choices:, initial_choice_idx: 0, quit_value: nil)
|
|
546
|
+
items = normalize_choices(choices)
|
|
547
|
+
raise ArgumentError, "choices must not be empty" if items.empty?
|
|
548
|
+
|
|
549
|
+
labels = items.map(&:first)
|
|
550
|
+
popup = Fatty::PopUpSession.new(
|
|
551
|
+
source: labels,
|
|
552
|
+
kind: :terminal_menu,
|
|
553
|
+
title: "Menu",
|
|
554
|
+
message: prompt,
|
|
555
|
+
prompt: "> ",
|
|
556
|
+
selection: :top,
|
|
557
|
+
validate_unique_labels: true,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
popup.instance_variable_set(:@selected, initial_choice_idx.to_i.clamp(0, labels.length - 1))
|
|
561
|
+
|
|
562
|
+
done = false
|
|
563
|
+
result = nil
|
|
564
|
+
menu_session = active_session
|
|
565
|
+
acc_proc = ->(payload) do
|
|
566
|
+
label = payload[:item]
|
|
567
|
+
idx = labels.index(label)
|
|
568
|
+
action = idx ? items[idx][1] : nil
|
|
569
|
+
result = call_menu_action(
|
|
570
|
+
action,
|
|
571
|
+
session: menu_session,
|
|
572
|
+
label: label,
|
|
573
|
+
payload: payload,
|
|
574
|
+
)
|
|
575
|
+
done = true
|
|
576
|
+
end
|
|
577
|
+
cancel_proc = -> do
|
|
578
|
+
result = quit_value
|
|
579
|
+
done = true
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
owner = PopupOwner.new(on_result: acc_proc, on_cancel: cancel_proc)
|
|
583
|
+
begin
|
|
584
|
+
push_modal(popup, owner: owner)
|
|
585
|
+
render_frame
|
|
586
|
+
|
|
587
|
+
while !done && @running
|
|
588
|
+
dirty = false
|
|
589
|
+
msg = event_source.next_event
|
|
590
|
+
|
|
591
|
+
if msg
|
|
592
|
+
dispatch_message(msg)
|
|
593
|
+
dirty = true
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
s = active_session
|
|
597
|
+
begin
|
|
598
|
+
tick_dirty = !!s&.tick
|
|
599
|
+
dirty ||= tick_dirty
|
|
600
|
+
rescue StandardError => e
|
|
601
|
+
Fatty.error("Terminal#menu tick failed: #{e.class}: #{e.message}", tag: :terminal)
|
|
602
|
+
dirty = true
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
render_frame if dirty
|
|
606
|
+
end
|
|
607
|
+
ensure
|
|
608
|
+
render_frame
|
|
609
|
+
end
|
|
610
|
+
result
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def call_menu_action(action, session:, label:, payload:)
|
|
614
|
+
if action.respond_to?(:call)
|
|
615
|
+
env = MenuEnv.new(
|
|
616
|
+
terminal: self,
|
|
617
|
+
session: session,
|
|
618
|
+
label: label,
|
|
619
|
+
payload: payload,
|
|
620
|
+
)
|
|
621
|
+
if action.arity.zero?
|
|
622
|
+
action.call
|
|
623
|
+
else
|
|
624
|
+
action.call(env)
|
|
625
|
+
end
|
|
626
|
+
else
|
|
627
|
+
action
|
|
628
|
+
end
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
def render_now
|
|
632
|
+
render_frame
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
def status_visible?
|
|
636
|
+
@status_text && !@status_text.empty?
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def status_rows
|
|
640
|
+
return 0 unless status_visible?
|
|
641
|
+
|
|
642
|
+
status_lines.length.clamp(1, status_max_rows)
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
def status_max_rows
|
|
646
|
+
Fatty::Config.config.dig(:status, :max_rows)&.to_i || DEFAULT_STATUS_MAX_ROWS
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
def status_lines
|
|
650
|
+
width = screen&.cols || 80
|
|
651
|
+
|
|
652
|
+
@status_text.to_s
|
|
653
|
+
.lines
|
|
654
|
+
.flat_map { |line| wrap_status_line(line.chomp, width) }
|
|
655
|
+
.last(status_max_rows)
|
|
656
|
+
end
|
|
657
|
+
|
|
658
|
+
private
|
|
659
|
+
|
|
660
|
+
def wrap_status_line(line, width)
|
|
661
|
+
text = Fatty::Ansi.strip(line.to_s)
|
|
662
|
+
return [""] if text.empty?
|
|
663
|
+
|
|
664
|
+
# Good enough first pass: no soft wrap within words.
|
|
665
|
+
# Later this should use visible-width-aware wrapping.
|
|
666
|
+
text.scan(/.{1,#{[width, 1].max}}/)
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
def preflight!
|
|
670
|
+
Fatty::Config.config
|
|
671
|
+
Fatty::Logger.configure
|
|
672
|
+
if Fatty::Logger.logger
|
|
673
|
+
Fatty.info("Logger configured to log to #{Logger.path}")
|
|
674
|
+
Fatty.info("Read config from #{Config.user_config_path}", tag: :config)
|
|
675
|
+
Fatty.info("Config", config: Config.config, tag: :config)
|
|
676
|
+
end
|
|
677
|
+
Fatty::Config.keydefs
|
|
678
|
+
Fatty::Config.keybindings
|
|
679
|
+
Fatty::Config.install_default_themes!
|
|
680
|
+
Fatty::Themes::Manager.load!
|
|
681
|
+
Thread.report_on_exception = true
|
|
682
|
+
rescue FatConfig::ParseError => ex
|
|
683
|
+
msg = "Terminal#preflight!: configuration error: #{ex.class}: #{ex.message}"
|
|
684
|
+
warn msg
|
|
685
|
+
begin
|
|
686
|
+
Fatty.error(msg, tag: :config)
|
|
687
|
+
rescue StandardError
|
|
688
|
+
nil
|
|
689
|
+
end
|
|
690
|
+
exit(1)
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
def start_curses!
|
|
694
|
+
@ctx = Fatty::Curses::Context.new
|
|
695
|
+
@ctx.start
|
|
696
|
+
|
|
697
|
+
@screen = Fatty::Screen.new(rows: ::Curses.lines, cols: ::Curses.cols, status_rows: status_rows)
|
|
698
|
+
@ctx.apply_layout(@screen)
|
|
699
|
+
|
|
700
|
+
@renderer =
|
|
701
|
+
if @ctx.truecolor
|
|
702
|
+
Fatty::Renderer::Truecolor.new(context: @ctx, screen: @screen, palette: @ctx.palette)
|
|
703
|
+
else
|
|
704
|
+
Fatty::Renderer::Curses.new(context: @ctx, screen: @screen, palette: @ctx.palette)
|
|
705
|
+
end
|
|
706
|
+
@renderer.sync_backgrounds! if @ctx.truecolor
|
|
707
|
+
|
|
708
|
+
@env ||= Fatty::Env.detect
|
|
709
|
+
key_decoder = Fatty::Curses::KeyDecoder.new(env: @env)
|
|
710
|
+
@event_source =
|
|
711
|
+
Fatty::Curses::EventSource.new(context: @ctx, key_decoder: key_decoder, poll_ms: 50)
|
|
712
|
+
self
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
def stop_curses!
|
|
716
|
+
@ctx&.close
|
|
717
|
+
ensure
|
|
718
|
+
begin
|
|
719
|
+
$stdout.write("\e[0m") # SGR reset
|
|
720
|
+
$stdout.write("\e[0 q") # DECSCUSR: restore terminal default cursor
|
|
721
|
+
$stdout.flush
|
|
722
|
+
rescue StandardError
|
|
723
|
+
# best-effort cleanup
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
def install_default_sessions!
|
|
728
|
+
pin(Fatty::AlertSession.new)
|
|
729
|
+
push(Fatty::ShellSession.new(
|
|
730
|
+
prompt: @prompt,
|
|
731
|
+
on_accept: @on_accept,
|
|
732
|
+
completion_proc: @completion_proc,
|
|
733
|
+
history_path: @history_path,
|
|
734
|
+
history_ctx: @history_ctx,
|
|
735
|
+
))
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
def scrolling_output?
|
|
739
|
+
session = active_session
|
|
740
|
+
pager =
|
|
741
|
+
if session.respond_to?(:pager)
|
|
742
|
+
session.pager
|
|
743
|
+
end
|
|
744
|
+
|
|
745
|
+
pager && pager.mode == :scrolling
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
def refresh_layout!
|
|
749
|
+
screen.resize(
|
|
750
|
+
rows: @ctx.rows,
|
|
751
|
+
cols: @ctx.cols,
|
|
752
|
+
status_rows: status_rows,
|
|
753
|
+
)
|
|
754
|
+
@ctx.apply_layout(screen)
|
|
755
|
+
renderer.screen = screen
|
|
756
|
+
if (session = focused_session)
|
|
757
|
+
session.resize_output! if session.respond_to?(:resize_output!)
|
|
758
|
+
end
|
|
759
|
+
if (top = @modal_stack.last)
|
|
760
|
+
session = top[:session]
|
|
761
|
+
apply_commands(session.handle_resize) if session.respond_to?(:handle_resize)
|
|
762
|
+
end
|
|
763
|
+
renderer.sync_backgrounds! if renderer.context.truecolor
|
|
764
|
+
renderer.invalidate!
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
def resize_message?(message)
|
|
768
|
+
kind, event = message
|
|
769
|
+
kind == :key && event.respond_to?(:key) && event.key == :resize
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
def handle_resize
|
|
773
|
+
rows = ::Curses.lines
|
|
774
|
+
cols = ::Curses.cols
|
|
775
|
+
size = [rows, cols]
|
|
776
|
+
|
|
777
|
+
return [] if size == @last_handled_resize_size
|
|
778
|
+
|
|
779
|
+
@last_handled_resize_size = size
|
|
780
|
+
|
|
781
|
+
did_resize_term = false
|
|
782
|
+
|
|
783
|
+
# ncurses must be told to finalize internal resize state before we
|
|
784
|
+
# draw ANSI output. Without this, ANSI writes after resize may be
|
|
785
|
+
# ignored or clipped. Guarded to avoid recursive resize storms.
|
|
786
|
+
unless @inside_resize_term
|
|
787
|
+
@inside_resize_term = true
|
|
788
|
+
did_resize_term = true
|
|
789
|
+
|
|
790
|
+
# Use ncurses' high-level resize finalizer. This updates stdscr/curscr
|
|
791
|
+
# and ncurses bookkeeping before we rebuild Fatty's own layout and draw
|
|
792
|
+
# the ANSI overlay. resize_term is lower-level and is not equivalent here.
|
|
793
|
+
::Curses.resizeterm(rows, cols)
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
renderer.clear_physical_screen! if renderer.context.truecolor
|
|
797
|
+
|
|
798
|
+
screen.resize(rows: rows, cols: cols, status_rows: status_rows)
|
|
799
|
+
renderer.context.apply_layout(screen)
|
|
800
|
+
renderer.screen = screen
|
|
801
|
+
renderer.sync_backgrounds! if renderer.context.truecolor
|
|
802
|
+
renderer.invalidate!
|
|
803
|
+
|
|
804
|
+
if (out = focused_session)
|
|
805
|
+
out.resize_output! if out.respond_to?(:resize_output!)
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
if (top = @modal_stack.last)
|
|
809
|
+
session = top[:session]
|
|
810
|
+
cmds = session.handle_resize
|
|
811
|
+
apply_commands(cmds)
|
|
812
|
+
end
|
|
813
|
+
[]
|
|
814
|
+
ensure
|
|
815
|
+
@inside_resize_term = false if did_resize_term
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
def persist_sessions!
|
|
819
|
+
sessions = []
|
|
820
|
+
sessions << @focused_session if @focused_session
|
|
821
|
+
sessions.concat(@sessions) if defined?(@sessions) && @sessions
|
|
822
|
+
|
|
823
|
+
sessions.uniq.each do |s|
|
|
824
|
+
next unless s.respond_to?(:persist!)
|
|
825
|
+
|
|
826
|
+
s.persist!
|
|
827
|
+
end
|
|
828
|
+
end
|
|
829
|
+
|
|
830
|
+
def quit
|
|
831
|
+
@running = false
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
# --- Dispatch ----------------------------------------------------------
|
|
835
|
+
|
|
836
|
+
def dispatch_message(message)
|
|
837
|
+
s = active_session
|
|
838
|
+
return [] unless s
|
|
839
|
+
|
|
840
|
+
# Clear transient alerts on the next user keypress.
|
|
841
|
+
if key_event_message?(message) && !resize_message?(message) && find_session(:alert)
|
|
842
|
+
apply_command([:send, :alert, :clear, {}])
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
# Clear transient status line on the next user keypress.
|
|
846
|
+
if key_event_message?(message) && transient_status?
|
|
847
|
+
clear_status
|
|
848
|
+
end
|
|
849
|
+
|
|
850
|
+
Fatty.debug("Terminal#dispatch_message: #{message.inspect}", tag: :session)
|
|
851
|
+
commands = s.update(message)
|
|
852
|
+
Fatty.debug("Terminal#dispatch_message: session=#{s.class} -> cmds=#{commands.inspect}", tag: :session)
|
|
853
|
+
|
|
854
|
+
apply_commands(commands)
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
# Return whether message is a key message
|
|
858
|
+
def key_event_message?(message)
|
|
859
|
+
message.is_a?(Array) && message[0] == :key
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
def apply_commands(commands)
|
|
863
|
+
Array(commands).each do |cmd|
|
|
864
|
+
apply_command(cmd)
|
|
865
|
+
end
|
|
866
|
+
end
|
|
867
|
+
|
|
868
|
+
# A command is either bound for this Terminal (first element :terminal) or
|
|
869
|
+
# it's meant to be forwarded to a Session (first element :send). This
|
|
870
|
+
# method routes the command to its proper destination.
|
|
871
|
+
def apply_command(cmd)
|
|
872
|
+
Fatty.debug("Terminal#apply_command(#{cmd})", tag: :session)
|
|
873
|
+
return if cmd.nil?
|
|
874
|
+
|
|
875
|
+
unless cmd.is_a?(Array) && cmd.first.is_a?(Symbol)
|
|
876
|
+
raise ArgumentError, "command must be an Array starting with a Symbol, got: #{cmd.inspect}"
|
|
877
|
+
end
|
|
878
|
+
|
|
879
|
+
case cmd[0]
|
|
880
|
+
when :terminal
|
|
881
|
+
apply_terminal_command(cmd)
|
|
882
|
+
when :send
|
|
883
|
+
apply_send_command(cmd)
|
|
884
|
+
else
|
|
885
|
+
raise ArgumentError, "unknown command domain #{cmd[0].inspect} (cmd=#{cmd.inspect})"
|
|
886
|
+
end
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
# Apply a command meant to be applied by this Terminal
|
|
890
|
+
def apply_terminal_command(cmd)
|
|
891
|
+
_, name, *rest = cmd
|
|
892
|
+
case name
|
|
893
|
+
when :quit
|
|
894
|
+
persist_sessions!
|
|
895
|
+
quit
|
|
896
|
+
when :push
|
|
897
|
+
session = rest.fetch(0)
|
|
898
|
+
push(session)
|
|
899
|
+
when :pop
|
|
900
|
+
pop
|
|
901
|
+
when :push_modal
|
|
902
|
+
session = rest.fetch(0)
|
|
903
|
+
Fatty.debug("Terminal#apply_terminal_command(:push_modal) before size=#{@modal_stack.length}", tag: :session)
|
|
904
|
+
push_modal(session, owner: focused_session)
|
|
905
|
+
Fatty.debug("Terminal#apply_terminal_command(:push_modal) after size=#{@modal_stack.length}", tag: :session)
|
|
906
|
+
when :pop_modal
|
|
907
|
+
Fatty.debug("Terminal#apply_terminal_command(:pop_modal) before size=#{@modal_stack.length}", tag: :session)
|
|
908
|
+
pop_modal
|
|
909
|
+
Fatty.debug("Terminal#apply_terminal_command(:pop_modal) aftersize=#{@modal_stack.length}", tag: :session)
|
|
910
|
+
when :send_modal_owner
|
|
911
|
+
msg = rest.fetch(0)
|
|
912
|
+
owner = modal_owner
|
|
913
|
+
# cmds = owner ? owner.update(msg, terminal: self) : []
|
|
914
|
+
cmds = owner ? owner.update(msg) : []
|
|
915
|
+
apply_commands(cmds)
|
|
916
|
+
when :cycle_theme
|
|
917
|
+
new_theme = Fatty::Themes::Manager.cycle
|
|
918
|
+
renderer.apply_theme!(new_theme)
|
|
919
|
+
apply_command([:send, :alert, :show, { level: :info, message: "Theme: #{new_theme}" }])
|
|
920
|
+
when :set_theme
|
|
921
|
+
theme = rest.fetch(0)
|
|
922
|
+
Fatty::Themes::Manager.set(theme)
|
|
923
|
+
renderer.apply_theme!(theme)
|
|
924
|
+
apply_command([:send, :alert, :show, { level: :info, message: "Theme: #{theme}" }])
|
|
925
|
+
when :handle_resize
|
|
926
|
+
handle_resize
|
|
927
|
+
else
|
|
928
|
+
raise ArgumentError, "unknown terminal command #{name.inspect} (cmd=#{cmd.inspect})"
|
|
929
|
+
end
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
# Forward a command meant to be applied by a
|
|
933
|
+
# [:send, recipient, message_name, payload_hash]
|
|
934
|
+
def apply_send_command(cmd)
|
|
935
|
+
_, recipient, message_name, payload = cmd
|
|
936
|
+
|
|
937
|
+
session = find_session(recipient)
|
|
938
|
+
raise ArgumentError, "no session registered with id=#{recipient.inspect}" unless session
|
|
939
|
+
|
|
940
|
+
payload ||= {}
|
|
941
|
+
unless payload.is_a?(Hash)
|
|
942
|
+
raise ArgumentError, "send payload must be a Hash, got: #{payload.inspect}"
|
|
943
|
+
end
|
|
944
|
+
|
|
945
|
+
# Deliver as a uniform "command message" array for now.
|
|
946
|
+
# Sessions can pattern-match it in #update.
|
|
947
|
+
message = [:cmd, message_name, payload]
|
|
948
|
+
|
|
949
|
+
commands = session.update(message)
|
|
950
|
+
apply_commands(commands)
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
def prompt_history
|
|
954
|
+
@prompt_history ||= Fatty::History.new(path: :default)
|
|
955
|
+
end
|
|
956
|
+
|
|
957
|
+
# --- Choose helpers ---------------------------------------------------------
|
|
958
|
+
|
|
959
|
+
def normalize_choices(choices)
|
|
960
|
+
Array(choices).map do |choice|
|
|
961
|
+
if choice.is_a?(Array) && choice.length == 2
|
|
962
|
+
[choice[0].to_s, choice[1]]
|
|
963
|
+
else
|
|
964
|
+
[choice.to_s, choice]
|
|
965
|
+
end
|
|
966
|
+
end
|
|
967
|
+
end
|
|
968
|
+
|
|
969
|
+
# --- Rendering ---------------------------------------------------------
|
|
970
|
+
|
|
971
|
+
def render_frame
|
|
972
|
+
renderer.begin_frame
|
|
973
|
+
sessions = @pinned + @stack
|
|
974
|
+
sessions.each do |s|
|
|
975
|
+
s.view(screen: screen, renderer: renderer)
|
|
976
|
+
end
|
|
977
|
+
Fatty::StatusView.new.render(
|
|
978
|
+
screen: screen,
|
|
979
|
+
renderer: renderer,
|
|
980
|
+
terminal: self,
|
|
981
|
+
)
|
|
982
|
+
if (top = @modal_stack.last)
|
|
983
|
+
top[:session].view(screen: screen, renderer: renderer)
|
|
984
|
+
end
|
|
985
|
+
|
|
986
|
+
restore_active_cursor
|
|
987
|
+
renderer.finish_frame
|
|
988
|
+
end
|
|
989
|
+
|
|
990
|
+
def restore_active_cursor
|
|
991
|
+
if @modal_stack && !@modal_stack.empty?
|
|
992
|
+
session = @modal_stack.last[:session]
|
|
993
|
+
|
|
994
|
+
if session.respond_to?(:pager_active?) && session.pager_active?
|
|
995
|
+
::Curses.curs_set(0)
|
|
996
|
+
return
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
if session.respond_to?(:win) && session.respond_to?(:field) && session.field
|
|
1000
|
+
win = session.win
|
|
1001
|
+
unless win
|
|
1002
|
+
::Curses.curs_set(0)
|
|
1003
|
+
return
|
|
1004
|
+
end
|
|
1005
|
+
|
|
1006
|
+
begin
|
|
1007
|
+
maxy = win.maxy
|
|
1008
|
+
maxx = win.maxx
|
|
1009
|
+
rescue RuntimeError
|
|
1010
|
+
::Curses.curs_set(0)
|
|
1011
|
+
return
|
|
1012
|
+
end
|
|
1013
|
+
|
|
1014
|
+
# If the popup is too small to place a cursor safely, just hide it.
|
|
1015
|
+
if maxy < 3 || maxx < 3
|
|
1016
|
+
::Curses.curs_set(0)
|
|
1017
|
+
return
|
|
1018
|
+
end
|
|
1019
|
+
|
|
1020
|
+
::Curses.curs_set(1)
|
|
1021
|
+
|
|
1022
|
+
cursor_x = session.field.cursor_x
|
|
1023
|
+
cursor_x = 0 if cursor_x.nil?
|
|
1024
|
+
|
|
1025
|
+
if session.is_a?(Fatty::PopUpSession)
|
|
1026
|
+
input_row = maxy - 2
|
|
1027
|
+
cursor_x = cursor_x.clamp(0, [maxx - 3, 0].max)
|
|
1028
|
+
begin
|
|
1029
|
+
win.setpos(input_row, 1 + cursor_x)
|
|
1030
|
+
rescue RuntimeError
|
|
1031
|
+
::Curses.curs_set(0)
|
|
1032
|
+
end
|
|
1033
|
+
elsif session.is_a?(Fatty::PromptSession)
|
|
1034
|
+
message_rows = session.message && !session.message.empty? ? 1 : 0
|
|
1035
|
+
input_row = 1 + message_rows
|
|
1036
|
+
cursor_x = cursor_x.clamp(0, [maxx - 3, 0].max)
|
|
1037
|
+
begin
|
|
1038
|
+
win.setpos(input_row, 1 + cursor_x)
|
|
1039
|
+
rescue RuntimeError
|
|
1040
|
+
::Curses.curs_set(0)
|
|
1041
|
+
end
|
|
1042
|
+
else
|
|
1043
|
+
::Curses.curs_set(0)
|
|
1044
|
+
end
|
|
1045
|
+
|
|
1046
|
+
return
|
|
1047
|
+
end
|
|
1048
|
+
|
|
1049
|
+
::Curses.curs_set(0)
|
|
1050
|
+
return
|
|
1051
|
+
end
|
|
1052
|
+
|
|
1053
|
+
session = active_session
|
|
1054
|
+
return unless session
|
|
1055
|
+
|
|
1056
|
+
if session.respond_to?(:pager_active?) && session.pager_active?
|
|
1057
|
+
::Curses.curs_set(0)
|
|
1058
|
+
return
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1061
|
+
return unless session.respond_to?(:field) && session.field
|
|
1062
|
+
|
|
1063
|
+
::Curses.curs_set(1)
|
|
1064
|
+
renderer.restore_cursor(session.field)
|
|
1065
|
+
end
|
|
1066
|
+
end
|
|
1067
|
+
end
|