fatty 0.99.0 → 0.99.3

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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.org +11 -29
  3. data/README.md +2207 -18
  4. data/README.org +2066 -104
  5. data/exe/fatty +236 -93
  6. data/help/help.md +233 -0
  7. data/lib/fatty/action.rb +3 -7
  8. data/lib/fatty/actionable.rb +2 -0
  9. data/lib/fatty/alert.rb +65 -39
  10. data/lib/fatty/ansi/renderer.rb +26 -6
  11. data/lib/fatty/ansi.rb +1 -0
  12. data/lib/fatty/api/alert.rb +15 -0
  13. data/lib/fatty/api/choose.rb +145 -0
  14. data/lib/fatty/api/environment.rb +12 -0
  15. data/lib/fatty/api/keytest.rb +11 -0
  16. data/lib/fatty/api/menu.rb +104 -0
  17. data/lib/fatty/api/output.rb +55 -0
  18. data/lib/fatty/api/progress.rb +24 -0
  19. data/lib/fatty/api/prompt.rb +77 -0
  20. data/lib/fatty/api/status.rb +55 -0
  21. data/lib/fatty/api.rb +38 -0
  22. data/lib/fatty/callback_environment.rb +74 -0
  23. data/lib/fatty/colors/color.rb +366 -232
  24. data/lib/fatty/colors/pairs.rb +2 -0
  25. data/lib/fatty/command.rb +78 -0
  26. data/lib/fatty/config.rb +227 -39
  27. data/lib/fatty/config_files/config.yml +4 -12
  28. data/lib/fatty/config_files/keydefs.yml +13 -23
  29. data/lib/fatty/config_files/themes/capuchin_monk.yml +140 -0
  30. data/lib/fatty/config_files/themes/catppuccin_latte.yml +140 -0
  31. data/lib/fatty/config_files/themes/catppuccin_mocha.yml +140 -0
  32. data/lib/fatty/config_files/themes/cyberpunk.yml +141 -0
  33. data/lib/fatty/config_files/themes/dracula.yml +140 -0
  34. data/lib/fatty/config_files/themes/everforest_dark.yml +140 -0
  35. data/lib/fatty/config_files/themes/gruvbox_dark.yml +140 -0
  36. data/lib/fatty/config_files/themes/gruvbox_light.yml +140 -0
  37. data/lib/fatty/config_files/themes/mono.yml +119 -47
  38. data/lib/fatty/config_files/themes/monokai.yml +140 -0
  39. data/lib/fatty/config_files/themes/nordic.yml +1 -0
  40. data/lib/fatty/config_files/themes/onedark.yml +140 -0
  41. data/lib/fatty/config_files/themes/solarized_dark.yml +1 -0
  42. data/lib/fatty/config_files/themes/solarized_light.yml +140 -0
  43. data/lib/fatty/config_files/themes/terminal.yml +67 -0
  44. data/lib/fatty/config_files/themes/tokyo_night.yml +140 -0
  45. data/lib/fatty/config_files/themes/wordperfect.yml +80 -9
  46. data/lib/fatty/core_ext/hash.rb +19 -0
  47. data/lib/fatty/core_ext/string.rb +19 -12
  48. data/lib/fatty/core_ext.rb +1 -0
  49. data/lib/fatty/curses/context.rb +86 -84
  50. data/lib/fatty/curses/event_source.rb +128 -40
  51. data/lib/fatty/curses/key_decoder.rb +77 -11
  52. data/lib/fatty/env.rb +21 -20
  53. data/lib/fatty/help.rb +1 -29
  54. data/lib/fatty/history/entry.rb +2 -0
  55. data/lib/fatty/history.rb +17 -14
  56. data/lib/fatty/input_buffer.rb +71 -47
  57. data/lib/fatty/input_field.rb +216 -149
  58. data/lib/fatty/key_event.rb +67 -66
  59. data/lib/fatty/key_map.rb +54 -16
  60. data/lib/fatty/keymaps/emacs.rb +40 -6
  61. data/lib/fatty/log_formats/json.rb +2 -0
  62. data/lib/fatty/log_formats/text.rb +2 -0
  63. data/lib/fatty/logger.rb +7 -2
  64. data/lib/fatty/markdown/ansi_renderer.rb +75 -6
  65. data/lib/fatty/markdown/render.rb +2 -3
  66. data/lib/fatty/mouse_event.rb +142 -2
  67. data/lib/fatty/output_buffer.rb +2 -0
  68. data/lib/fatty/pager.rb +158 -47
  69. data/lib/fatty/progress.rb +417 -0
  70. data/lib/fatty/renderer/curses.rb +340 -184
  71. data/lib/fatty/renderer/truecolor.rb +266 -163
  72. data/lib/fatty/renderer.rb +115 -97
  73. data/lib/fatty/screen.rb +2 -0
  74. data/lib/fatty/session/alert_session.rb +39 -21
  75. data/lib/fatty/session/isearch_session.rb +92 -38
  76. data/lib/fatty/session/keytest_session.rb +105 -85
  77. data/lib/fatty/session/modal_session.rb +38 -1
  78. data/lib/fatty/session/output_session.rb +472 -21
  79. data/lib/fatty/session/popup_session.rb +251 -134
  80. data/lib/fatty/session/prompt_session.rb +68 -26
  81. data/lib/fatty/session/search_session.rb +79 -31
  82. data/lib/fatty/session/shell_session.rb +383 -368
  83. data/lib/fatty/session/status_session.rb +167 -0
  84. data/lib/fatty/session.rb +95 -95
  85. data/lib/fatty/terminal/popup_owner.rb +14 -11
  86. data/lib/fatty/terminal.rb +515 -813
  87. data/lib/fatty/themes/loader.rb +4 -2
  88. data/lib/fatty/themes/manager.rb +4 -0
  89. data/lib/fatty/themes/registry.rb +2 -0
  90. data/lib/fatty/themes/resolver.rb +175 -162
  91. data/lib/fatty/version.rb +1 -1
  92. data/lib/fatty/viewport.rb +8 -1
  93. data/lib/fatty.rb +35 -7
  94. metadata +42 -40
  95. data/.envrc +0 -2
  96. data/.simplecov +0 -23
  97. data/.yardopts +0 -4
  98. data/CHANGELOG.md +0 -34
  99. data/Rakefile +0 -15
  100. data/TODO.org +0 -163
  101. data/examples/markdown/native-markdown.md +0 -370
  102. data/examples/markdown/ox-gfm-markdown.md +0 -373
  103. data/examples/markdown/ox-gfm-markdown.org +0 -376
  104. data/fatty.gemspec +0 -42
  105. data/lib/fatty/accept_env.rb +0 -32
  106. data/lib/fatty/config_files/help.md +0 -120
  107. data/lib/fatty/config_files/help.org +0 -124
  108. data/lib/fatty/config_files/themes/wordperfect_light.yml +0 -77
  109. data/lib/fatty/menu_env.rb +0 -22
  110. data/lib/fatty/session/input_session.rb +0 -99
  111. data/lib/fatty/sessions.rb +0 -14
  112. data/lib/fatty/terminal/progress.rb +0 -374
  113. data/lib/fatty/view/alert_view.rb +0 -14
  114. data/lib/fatty/view/cursor_view.rb +0 -18
  115. data/lib/fatty/view/input_view.rb +0 -9
  116. data/lib/fatty/view/output_view.rb +0 -9
  117. data/lib/fatty/view/status_view.rb +0 -14
  118. data/lib/fatty/view.rb +0 -33
  119. data/lib/fatty/views.rb +0 -9
  120. data/sig/fatty.rbs +0 -4
@@ -1,738 +1,430 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'terminal/progress'
4
3
  require_relative 'terminal/popup_owner'
5
4
 
6
5
  module Fatty
6
+ # Fatty aims at providing a full-featured, curses-based platform for
7
+ # REPL-style interaction with
8
+ # - command-line editing (by default using Emacs-like keybindings),
9
+ # - an output pane for normal output, a status pane for out-of-band
10
+ # messages to the user, and an alert pane for library-related error or
11
+ # information messages,
12
+ # - history traversal and context-aware history,
13
+ # - command completions based on input-so-far,
14
+ # - popup facilities for things such as prompting the user for a string,
15
+ # selecting one or more values from a list, selecting user-defined
16
+ # actions from a menu, and more,
17
+ # - a variety of progress indicators to keep the user informed about what's
18
+ # happening in a long-running process.
19
+ # - scrolling and searching long output to the output pane,
20
+ # - rendering markdown text in the output pane,
21
+ # - a theming system with several pre-defined color themes and facilities
22
+ # for the user to add their own,
23
+ # - a way to define keys that curses does not recognize and assign actions
24
+ # to them, including actions the user defines
25
+ # - rendering ANSI colored text to the output pane and the status pane,
26
+ # - a way to suspend Fatty and exit to your real terminal and come back to
27
+ # where you left off when you exit that terminal session.
28
+ #
29
+ # In other words, Fatty does much of the heavy lifting needed for a
30
+ # comfortable, full-featured interactive application.
31
+ #
32
+ # Fatty does not purport to be a "terminal" program but sits on top of them
33
+ # using curses and truecolor rendering into your normal terminal.
34
+ #
35
+ # Terminal is the main entry point. A library user normally creates a
36
+ # Terminal, configures the prompt, completion, history, and accept callback,
37
+ # then calls #go.
38
+ #
39
+ # Initialization options:
40
+ #
41
+ # - prompt :: either a fixed string or a proc that, when called, returns a
42
+ # string to be used as the command-line prompt;
43
+ #
44
+ # - on_accept :: a proc that takes the edited line and performs some action
45
+ # on it; the proc will have available to it a variable, `env`, that
46
+ # contains an CallbackEnvironment object containing a reference to the
47
+ # ShellSession and Terminal so that it can call actions on them, most
48
+ # commonly `env.session.append_output(text)` to write output to the output
49
+ # pane;
50
+ #
51
+ # - completion_proc :: a proc that returns an Array of strings based on the
52
+ # input-so-far to suggest completions that can be inserted at that point;
53
+ #
54
+ # - history_path :: a path or sentinel describing input history is loaded
55
+ # from and persisted;
56
+ #
57
+ # - history_ctx :: a proc that provides a "context" for history so that it
58
+ # can be "favored" by history requests in the same context later;
59
+ #
60
+ # - env :: an optional Fatty::Env describing the runtime environment; when
61
+ # omitted, Fatty will detect it with `Fatty::Env.detect`.
62
+ #
7
63
  class Terminal
8
64
  SCROLL_RENDER_THROTTLE = 0.05
9
- DEFAULT_STATUS_MAX_ROWS = 4
10
65
 
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
66
+ attr_reader :screen, :renderer, :event_source, :env, :ctx
67
+ attr_reader :shell_session, :status_session, :alert_session, :focused_session
27
68
 
28
69
  def initialize(prompt: "> ",
29
70
  on_accept: nil,
71
+ app_name: nil,
72
+ app_config_dir: nil,
30
73
  completion_proc: nil,
31
74
  history_path: :default,
32
75
  history_ctx: nil,
33
76
  env: nil)
34
77
  @prompt = Prompt.ensure(prompt)
35
78
  @on_accept = on_accept
79
+ @app_name = app_name
80
+ @app_config_dir = app_config_dir
36
81
  @completion_proc = completion_proc
37
82
  @history_path = history_path
38
83
  @history_ctx = history_ctx
39
84
  @env = env
85
+ @renderer = nil
40
86
 
41
87
  @running = false
42
- @stack = []
43
- @pinned = []
44
88
  @sessions = []
45
89
  @sessions_by_id = {}
46
90
  @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
91
+ @status_rows = 0
158
92
 
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
93
+ @ctx = nil
94
+ @immediate_render = false
95
+ @session_dirty = false
96
+ @render_requested = false
97
+ @last_render_time = nil
98
+ @restore_cursor_after_render = true
99
+ @render_count = 0
100
+ @deferred_count = 0
164
101
  end
165
102
 
166
- def find_session(id)
167
- @sessions_by_id[id]
103
+ def inspect
104
+ "Terminal: prompt: #{@prompt}; history: #{@history_path}; sessions size: #{@sessions.size} active: #{active_session.id}"
168
105
  end
169
106
 
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]
107
+ def to_s
108
+ inspect
195
109
  end
196
110
 
197
111
  # --- Runtime -----------------------------------------------------------
198
-
112
+ # Run the terminal.
113
+ #
114
+ # This method owns Fatty's main lifecycle:
115
+ #
116
+ # - load configuration and logging;
117
+ # - start curses and build the screen, renderer, and event source;
118
+ # - install the default permanent sessions;
119
+ # - enter the event loop;
120
+ # - restore the real terminal and persist session state on exit.
121
+ #
122
+ # The event loop has two sources of work. First, it polls the EventSource
123
+ # for user input, resize events, and other terminal events. Any event is
124
+ # routed to the active session, whose #update method may mutate session
125
+ # state and return commands for Terminal to process. Second, the active
126
+ # session is ticked once per loop so it can animate, poll, or otherwise
127
+ # mark itself dirty without receiving a key event.
128
+ #
129
+ # Whenever an event or tick changes state, Terminal schedules a render. Most
130
+ # renders happen immediately. The exception is fast scrolling in the normal
131
+ # curses renderer, where repeated output updates may be throttled to avoid
132
+ # excessive redraws. Truecolor rendering and direct user input are rendered
133
+ # immediately.
134
+ #
135
+ # Fatal errors are logged and re-raised, but cleanup still runs: curses is
136
+ # stopped so the real terminal is restored, and persistent sessions are given
137
+ # a chance to save their state.
199
138
  def go
200
139
  preflight!
201
140
  start_curses!
202
141
  install_default_sessions!
142
+ Fatty.debug("@sessions: #{@sessions.map(&:id).join('==')}")
203
143
 
204
144
  @running = true
205
- last_render = Process.clock_gettime(Process::CLOCK_MONOTONIC)
206
- pending_scroll_render = false
145
+ @last_render_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
146
+ @pending_scroll_render = false
207
147
  render_frame
208
148
 
209
- # For performance logging
210
- perf_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
211
149
  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
150
  while @running
219
151
  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
152
+ @session_dirty = false
153
+ @immediate_render = false
154
+ if (cmd = event_source.next_event)
155
+ begin
156
+ apply_command(cmd)
157
+ @session_dirty = true
158
+ @immediate_render = true
159
+ rescue StandardError => e
160
+ Fatty.error("Terminal#go apply_command failed: #{e.class}: #{e.message}", tag: :terminal)
161
+ Fatty.error(e.backtrace.join("\n"), tag: :terminal) if e.backtrace
162
+ @session_dirty = true
163
+ @immediate_render = true
164
+ raise
266
165
  end
267
166
  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
167
+ @session_dirty ||= active_session&.tick
168
+ render_frame if render_due?
294
169
  end
295
170
  rescue => e
296
171
  Fatty.error("Terminal#go fatal error: #{e.class}: #{e.message}", tag: :terminal)
297
172
  Fatty.error(e.backtrace.join("\n"), tag: :terminal) if e.backtrace
298
173
  raise
299
174
  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
175
+ cleanup_after_go
176
+ end
306
177
 
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
178
+ # --- Suspension ------------------------------------------------
179
+
180
+ # Suspend fatty and run the block in the normal terminal, then restore
181
+ # fatty's terminal on exit.
182
+ def suspend
183
+ stop_curses!
184
+ yield
185
+ ensure
186
+ start_curses!
187
+ refresh_layout!
188
+ render_frame
313
189
  end
314
190
 
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
- )
191
+ # A command is either bound for this Terminal or it's meant to be
192
+ # forwarded to a Session. This method routes the command to its proper
193
+ # destination.
194
+ def apply_command(command)
195
+ command = Fatty::Command.coerce(command)
196
+ return unless command
331
197
 
332
- popup.instance_variable_set(:@selected, initial_choice_idx.to_i.clamp(0, labels.length - 1))
198
+ @apply_command_depth ||= 0
199
+ outermost = @apply_command_depth.zero?
200
+ @apply_command_depth += 1
333
201
 
334
- done = false
335
- result = nil
202
+ Fatty.debug(
203
+ "Terminal#apply_command: active_session: #{active_session.id}",
204
+ tag: :session,
205
+ )
206
+ Fatty.debug("Terminal#apply_command(#{command.inspect})", tag: :session)
336
207
 
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
208
+ if user_interaction_command?(command)
209
+ apply_command(Command.session(:status, :clear))
210
+ apply_command(Command.session(:alert, :clear))
342
211
  end
343
212
 
344
- cancel_proc = -> do
345
- result = quit_value
346
- done = true
213
+ if command.terminal?
214
+ apply_terminal_command(command)
215
+ else
216
+ apply_session_command(command)
347
217
  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
218
+ ensure
219
+ if command
220
+ @apply_command_depth -= 1
221
+ render_frame if outermost && render_due?
375
222
  end
376
-
377
- result
378
223
  end
379
224
 
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
- )
225
+ def interrupt_pending?
226
+ event_source.interrupt_pending?
389
227
  end
390
228
 
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
229
+ def modal_active?
230
+ @modal_stack.any?
459
231
  end
460
232
 
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
233
+ def running?
234
+ @running
235
+ end
477
236
 
478
- acc_proc = ->(payload) do
479
- result = payload[:text]
480
- done = true
481
- end
237
+ def tick_active_session
238
+ active_session&.tick
239
+ end
482
240
 
483
- cancel_proc = -> do
484
- result = quit_value
485
- done = true
241
+ def render_frame
242
+ renderer.begin_frame
243
+ focused_session&.view
244
+ status_session.view
245
+ alert_session.view
246
+ if (top = @modal_stack.last)
247
+ top[:session].view
486
248
  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
249
+ if @restore_cursor_after_render
250
+ restore_active_cursor
251
+ else
252
+ renderer.hide_cursor
515
253
  end
516
-
517
- result
254
+ renderer.finish_frame
255
+ @pending_scroll_render = false
256
+ @render_requested = false
257
+ @session_dirty = false
258
+ @last_render_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
518
259
  end
519
260
 
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
- )
261
+ def without_cursor_restore
262
+ old_restore_cursor = @restore_cursor_after_render
263
+ @restore_cursor_after_render = false
264
+ yield
265
+ ensure
266
+ @restore_cursor_after_render = old_restore_cursor
531
267
  end
532
268
 
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
269
+ def history
270
+ shell_session&.history || Fatty::History.for_path(@history_path)
271
+ end
586
272
 
587
- while !done && @running
588
- dirty = false
589
- msg = event_source.next_event
273
+ private
590
274
 
591
- if msg
592
- dispatch_message(msg)
593
- dirty = true
594
- end
275
+ # simplecov:disable
595
276
 
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
277
+ # * Session management
278
+ #
279
+ # Terminal coordinates a small set of Session objects. A session is a UI
280
+ # component with three standard phases:
281
+ #
282
+ # - init :: receive the terminal and initialize session state;
283
+ # - update :: receive events or commands, mutate session state accordingly,
284
+ # and, optionally, return commands requesting that Terminal perform
285
+ # additional actions or route commands to other sessions;
286
+ # - view :: render the session's current state.
287
+ #
288
+ # Terminal is not itself a Session subclass, but it acts as the coordinator
289
+ # for all sessions. It routes input events to the active session, routes
290
+ # explicit commands either to itself or to a named session, and renders the
291
+ # sessions in a fixed order.
292
+ #
293
+ # Terminal keeps sessions in two groups:
294
+ #
295
+ # - permanent sessions :: a collection of always-present sessions. One
296
+ # permanent session is designated the focused session and receives
297
+ # keyboard input whenever no modal session is active. In the standard
298
+ # configuration this is a ShellSession. Other permanent sessions, such
299
+ # as StatusSession and AlertSession, are output-only sessions that do
300
+ # not receive input focus.
301
+ # - modal sessions :: temporary sessions displayed above the permanent
302
+ # sessions, usually as popups. While a modal session is present, it
303
+ # becomes the active session and receives input first. The modal stack
304
+ # also records an owner so that modal results can be sent back to the
305
+ # session or helper that opened the modal.
306
+ #
307
+ # Rendering follows the same layering: permanent sessions are rendered
308
+ # first, followed by the top modal session.
309
+ #
310
+ def install_session(session, focus: false)
311
+ @sessions << session
312
+ register(session)
313
+ @focused_session = session if focus
604
314
 
605
- render_frame if dirty
606
- end
607
- ensure
608
- render_frame
609
- end
610
- result
315
+ commands = session.init(terminal: self)
316
+ apply_commands(commands)
317
+ session
611
318
  end
612
319
 
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
320
+ def register(session)
321
+ @sessions_by_id[session.id] = session
629
322
  end
630
323
 
631
- def render_now
632
- render_frame
324
+ def deregister(session)
325
+ @sessions_by_id.delete(session.id)
633
326
  end
634
327
 
635
- def status_visible?
636
- @status_text && !@status_text.empty?
328
+ def install_default_sessions!
329
+ @alert_session = Fatty::AlertSession.new(id: :alert)
330
+ install_session(@alert_session)
331
+ @status_session = Fatty::StatusSession.new(id: :status)
332
+ install_session(@status_session)
333
+ @shell_session = ShellSession.new(
334
+ id: :shell,
335
+ prompt: @prompt,
336
+ on_accept: @on_accept,
337
+ completion_proc: @completion_proc,
338
+ history_path: @history_path,
339
+ history_ctx: @history_ctx,
340
+ )
341
+ install_session(@shell_session, focus: true)
637
342
  end
638
343
 
639
- def status_rows
640
- return 0 unless status_visible?
344
+ def push_modal(session, owner: focused_session)
345
+ commands = session.init(terminal: self)
346
+ register(session)
347
+ @modal_stack << { session: session, owner: owner }
641
348
 
642
- status_lines.length.clamp(1, status_max_rows)
643
- end
349
+ msg = "Terminal#push_modal: size=#{@modal_stack.length} session=#{session.class} object_id=#{session.object_id}"
350
+ Fatty.debug(msg, tag: :session)
644
351
 
645
- def status_max_rows
646
- Fatty::Config.config.dig(:status, :max_rows)&.to_i || DEFAULT_STATUS_MAX_ROWS
352
+ @renderer&.invalidate!
353
+ apply_commands(commands)
647
354
  end
648
355
 
649
- def status_lines
650
- width = screen&.cols || 80
356
+ def pop_modal
357
+ top = @modal_stack.pop
358
+ msg = "Terminal#pop_modal: size=#{@modal_stack.length} popped=#{top && top[:session].class}"
359
+ Fatty.debug(msg, tag: :session)
651
360
 
652
- @status_text.to_s
653
- .lines
654
- .flat_map { |line| wrap_status_line(line.chomp, width) }
655
- .last(status_max_rows)
361
+ session = top && top[:session]
362
+ if session
363
+ session.close
364
+ deregister(session)
365
+ end
366
+ @renderer&.invalidate!
367
+ nil
656
368
  end
657
369
 
658
- private
659
-
660
- def wrap_status_line(line, width)
661
- text = Fatty::Ansi.strip(line.to_s)
662
- return [""] if text.empty?
370
+ # If we have modal sessions, the most recent one is active; otherwise, the
371
+ # focused_session, i.e., the ShellSession.
372
+ def active_session
373
+ top = @modal_stack.last
374
+ top ? top[:session] : focused_session
375
+ end
663
376
 
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}}/)
377
+ # Return the owner of the top modal session without modifying the stack.
378
+ def modal_owner
379
+ top = @modal_stack.last
380
+ top && top[:owner]
667
381
  end
668
382
 
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
383
+ # Return the Session object associated with `id`, which will be a symbol
384
+ # like :status or :alert. Special id's :active and :focused resolve to
385
+ # the focused session (the one currently taking in key events) and the
386
+ # active session, the top modal session or, if there is none, the focused
387
+ # session.
388
+ def find_session(id)
389
+ case id
390
+ when :active
391
+ active_session
392
+ when :focused
393
+ focused_session
394
+ else
395
+ @sessions_by_id[id]
689
396
  end
690
- exit(1)
691
397
  end
692
398
 
693
- def start_curses!
694
- @ctx = Fatty::Curses::Context.new
695
- @ctx.start
399
+ # --- Rendering ---------------------------------------------------------
696
400
 
697
- @screen = Fatty::Screen.new(rows: ::Curses.lines, cols: ::Curses.cols, status_rows: status_rows)
698
- @ctx.apply_layout(@screen)
401
+ def render_due?
402
+ return true if @render_requested
699
403
 
700
- @renderer =
701
- if @ctx.truecolor
702
- Fatty::Renderer::Truecolor.new(context: @ctx, screen: @screen, palette: @ctx.palette)
404
+ result =
405
+ if @session_dirty
406
+ immediate_render_due?
407
+ elsif @pending_scroll_render
408
+ scroll_render_due?
703
409
  else
704
- Fatty::Renderer::Curses.new(context: @ctx, screen: @screen, palette: @ctx.palette)
410
+ false
705
411
  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
412
+ if @session_dirty && !result
413
+ @pending_scroll_render = true
414
+ end
415
+ result
713
416
  end
714
417
 
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
418
+ def immediate_render_due?
419
+ @immediate_render ||
420
+ renderer.context.truecolor ||
421
+ !scrolling_output? ||
422
+ scroll_render_due?
725
423
  end
726
424
 
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
- ))
425
+ def scroll_render_due?
426
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
427
+ now - @last_render_time >= SCROLL_RENDER_THROTTLE
736
428
  end
737
429
 
738
430
  def scrolling_output?
@@ -749,24 +441,23 @@ module Fatty
749
441
  screen.resize(
750
442
  rows: @ctx.rows,
751
443
  cols: @ctx.cols,
752
- status_rows: status_rows,
444
+ status_rows: @status_rows,
753
445
  )
754
446
  @ctx.apply_layout(screen)
755
447
  renderer.screen = screen
756
- if (session = focused_session)
757
- session.resize_output! if session.respond_to?(:resize_output!)
758
- end
448
+ apply_command(Command.session(:status, :resize))
449
+ apply_command(Command.session(:focused, :resize))
759
450
  if (top = @modal_stack.last)
760
- session = top[:session]
761
- apply_commands(session.handle_resize) if session.respond_to?(:handle_resize)
451
+ apply_command(Command.session(top[:session].id, :resize))
762
452
  end
763
453
  renderer.sync_backgrounds! if renderer.context.truecolor
764
454
  renderer.invalidate!
765
455
  end
766
456
 
767
- def resize_message?(message)
768
- kind, event = message
769
- kind == :key && event.respond_to?(:key) && event.key == :resize
457
+ def resize_command?(command)
458
+ # kind, event = message
459
+ # kind == :key && event.respond_to?(:key) && event.key == :resize
460
+ command.action == :resize
770
461
  end
771
462
 
772
463
  def handle_resize
@@ -777,25 +468,28 @@ module Fatty
777
468
  return [] if size == @last_handled_resize_size
778
469
 
779
470
  @last_handled_resize_size = size
780
-
781
471
  did_resize_term = false
782
-
783
472
  # ncurses must be told to finalize internal resize state before we
784
473
  # draw ANSI output. Without this, ANSI writes after resize may be
785
474
  # ignored or clipped. Guarded to avoid recursive resize storms.
786
475
  unless @inside_resize_term
787
476
  @inside_resize_term = true
788
477
  did_resize_term = true
789
-
790
478
  # Use ncurses' high-level resize finalizer. This updates stdscr/curscr
791
479
  # and ncurses bookkeeping before we rebuild Fatty's own layout and draw
792
480
  # the ANSI overlay. resize_term is lower-level and is not equivalent here.
793
481
  ::Curses.resizeterm(rows, cols)
794
482
  end
795
483
 
796
- renderer.clear_physical_screen! if renderer.context.truecolor
484
+ # renderer.clear_physical_screen! if renderer.context.truecolor
485
+
486
+ screen.resize(rows: rows, cols: cols, status_rows: @status_rows)
487
+ Fatty.info(
488
+ "handle_resize after=#{screen.rows}x#{screen.cols} " \
489
+ "output_after=#{screen.output_rect.inspect}",
490
+ tag: :resize,
491
+ )
797
492
 
798
- screen.resize(rows: rows, cols: cols, status_rows: status_rows)
799
493
  renderer.context.apply_layout(screen)
800
494
  renderer.screen = screen
801
495
  renderer.sync_backgrounds! if renderer.context.truecolor
@@ -810,11 +504,102 @@ module Fatty
810
504
  cmds = session.handle_resize
811
505
  apply_commands(cmds)
812
506
  end
507
+ @render_requested = true
813
508
  []
814
509
  ensure
815
510
  @inside_resize_term = false if did_resize_term
816
511
  end
817
512
 
513
+ # --- Initialization ---------------------------------------------------------
514
+
515
+ #
516
+ # These methods get Fatty started by reading the config, preparing
517
+ # Curses, the Logger, installing key definitions and mappings, and
518
+ # installing themes.
519
+ #
520
+ def preflight!
521
+ Fatty::Config.configure_app(
522
+ app_name: @app_name,
523
+ app_config_dir: @app_config_dir,
524
+ )
525
+ Fatty::Config.install_defaults!
526
+ Fatty::Config.config
527
+ Fatty::Logger.configure
528
+ if Fatty::Logger.logger
529
+ Fatty.info("Fatty #{Fatty::VERSION} loaded from #{__dir__}", tag: :config)
530
+ Fatty.info("Logger configured to log to #{Logger.path}", tag: :config)
531
+ Fatty.info("Read config from #{Config.user_config_path}", tag: :config)
532
+ Fatty.info("Config", config: Config.config, tag: :config)
533
+ end
534
+ Fatty::Config.keydefs
535
+ Fatty::Config.keybindings
536
+ Fatty::Themes::Manager.load!
537
+ Thread.report_on_exception = true
538
+ rescue FatConfig::ParseError => ex
539
+ msg = "Terminal#preflight!: configuration error: #{ex.class}: #{ex.message}"
540
+ warn msg
541
+ begin
542
+ Fatty.error(msg, tag: :config)
543
+ rescue StandardError
544
+ nil
545
+ end
546
+ exit(1)
547
+ end
548
+
549
+ def start_curses!
550
+ @ctx = Fatty::Curses::Context.new
551
+ @ctx.start
552
+
553
+ @screen = Fatty::Screen.new(rows: ::Curses.lines, cols: ::Curses.cols, status_rows: 0)
554
+ @ctx.apply_layout(@screen)
555
+
556
+ @renderer =
557
+ if @ctx.truecolor_enabled?
558
+ Fatty::Renderer::Truecolor.new(context: @ctx, screen: @screen, palette: @ctx.palette)
559
+ else
560
+ Fatty::Renderer::Curses.new(context: @ctx, screen: @screen, palette: @ctx.palette)
561
+ end
562
+ @renderer.invalidate!
563
+ @renderer.clear_physical_screen! if @ctx.truecolor_enabled?
564
+ @renderer.sync_backgrounds! if @ctx.truecolor_enabled?
565
+
566
+ @env ||= Fatty::Env.detect
567
+ key_decoder = Fatty::Curses::KeyDecoder.new(env: @env)
568
+ @event_source =
569
+ Fatty::Curses::EventSource.new(context: @ctx, key_decoder: key_decoder, poll_ms: 50)
570
+ self
571
+ end
572
+
573
+ # --- Shutdown ---------------------------------------------------------
574
+
575
+ def cleanup_after_go
576
+ begin
577
+ stop_curses!
578
+ rescue => e
579
+ Fatty.error("Terminal#go stop_curses! failed: #{e.class}: #{e.message}", tag: :terminal)
580
+ Fatty.error(e.backtrace.join("\n"), tag: :terminal) if e.backtrace
581
+ end
582
+
583
+ begin
584
+ persist_sessions!
585
+ rescue => e
586
+ Fatty.error("Terminal#go persist_sessions! failed: #{e.class}: #{e.message}", tag: :terminal)
587
+ Fatty.error(e.backtrace.join("\n"), tag: :terminal) if e.backtrace
588
+ end
589
+ end
590
+
591
+ def stop_curses!
592
+ @ctx&.close
593
+ ensure
594
+ begin
595
+ $stdout.write("\e[0m") # SGR reset
596
+ $stdout.write("\e[0 q") # DECSCUSR: restore terminal default cursor
597
+ $stdout.flush
598
+ rescue StandardError
599
+ # best-effort cleanup
600
+ end
601
+ end
602
+
818
603
  def persist_sessions!
819
604
  sessions = []
820
605
  sessions << @focused_session if @focused_session
@@ -833,235 +618,152 @@ module Fatty
833
618
 
834
619
  # --- Dispatch ----------------------------------------------------------
835
620
 
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
621
+ def user_interaction_command?(command)
622
+ return false if command.action == :resize
623
+ return false unless command.target == :active
624
+ return false unless command.action == :key
844
625
 
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)
626
+ event = command.payload[:event]
627
+ event && event.key != :resize
855
628
  end
856
629
 
857
- # Return whether message is a key message
858
- def key_event_message?(message)
859
- message.is_a?(Array) && message[0] == :key
630
+ # Return whether command is a key event
631
+ def key_event_command?(command)
632
+ command.action == :key
860
633
  end
861
634
 
862
635
  def apply_commands(commands)
636
+ commands = [commands] if commands.is_a?(Fatty::Command)
863
637
  Array(commands).each do |cmd|
864
638
  apply_command(cmd)
865
639
  end
866
640
  end
867
641
 
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
642
+ # Apply a command meant to be applied by this Terminal.
643
+ def apply_terminal_command(command)
644
+ case command.action
893
645
  when :quit
894
646
  persist_sessions!
895
647
  quit
896
- when :push
897
- session = rest.fetch(0)
898
- push(session)
899
- when :pop
900
- pop
648
+ when :register_session
649
+ register(command.payload.fetch(:session))
650
+ when :set_status_rows
651
+ @status_rows = command.payload.fetch(:rows)
652
+ when :refresh_layout
653
+ refresh_layout!
901
654
  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)
655
+ session = command.payload.fetch(:session)
656
+ push_modal(session, owner: command.payload[:owner] || focused_session)
906
657
  when :pop_modal
907
- Fatty.debug("Terminal#apply_terminal_command(:pop_modal) before size=#{@modal_stack.length}", tag: :session)
908
658
  pop_modal
909
- Fatty.debug("Terminal#apply_terminal_command(:pop_modal) aftersize=#{@modal_stack.length}", tag: :session)
910
659
  when :send_modal_owner
911
- msg = rest.fetch(0)
660
+ command = command.payload.fetch(:command)
912
661
  owner = modal_owner
913
- # cmds = owner ? owner.update(msg, terminal: self) : []
914
- cmds = owner ? owner.update(msg) : []
915
- apply_commands(cmds)
662
+ commands = owner ? owner.update(command) : []
663
+ apply_commands(commands)
664
+ when :choose_theme
665
+ Fatty.debug("choose_theme: themes=#{Fatty::Themes::Manager.theme_names.inspect}", tag: :theme)
666
+ choose_theme
916
667
  when :cycle_theme
917
668
  new_theme = Fatty::Themes::Manager.cycle
669
+ Fatty.debug("cycle_theme: new_theme=#{new_theme.inspect}", tag: :theme)
918
670
  renderer.apply_theme!(new_theme)
919
- apply_command([:send, :alert, :show, { level: :info, message: "Theme: #{new_theme}" }])
671
+ apply_command(
672
+ Command.session(
673
+ :alert,
674
+ :show,
675
+ role: :info,
676
+ text: "Theme: #{new_theme}",
677
+ ))
920
678
  when :set_theme
921
- theme = rest.fetch(0)
679
+ theme = command.payload.fetch(:theme)
922
680
  Fatty::Themes::Manager.set(theme)
923
681
  renderer.apply_theme!(theme)
924
- apply_command([:send, :alert, :show, { level: :info, message: "Theme: #{theme}" }])
925
- when :handle_resize
682
+ apply_command(
683
+ Command.session(
684
+ :alert,
685
+ :show,
686
+ role: :info,
687
+ text: "Theme: #{theme}",
688
+ ))
689
+ when :resize
926
690
  handle_resize
927
691
  else
928
- raise ArgumentError, "unknown terminal command #{name.inspect} (cmd=#{cmd.inspect})"
692
+ raise ArgumentError, "unknown terminal command #{command.action.inspect} (cmd=#{command.inspect})"
929
693
  end
930
694
  end
931
695
 
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
696
+ # Forward a command to the target session.
697
+ def apply_session_command(command)
698
+ session = find_session(command.id)
699
+ raise ArgumentError, "no session registered with id=#{command.id.inspect}" unless session
939
700
 
940
- payload ||= {}
941
- unless payload.is_a?(Hash)
942
- raise ArgumentError, "send payload must be a Hash, got: #{payload.inspect}"
701
+ unless command.payload.is_a?(Hash)
702
+ raise ArgumentError, "session command payload must be a Hash, got: #{command.payload.inspect}"
943
703
  end
944
704
 
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)
705
+ commands = session.update(command)
706
+ @session_dirty = true
707
+ @immediate_render = true if %i[status alert].include?(session.id)
950
708
  apply_commands(commands)
951
709
  end
952
710
 
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
711
+ def hide_cursor
712
+ renderer.hide_cursor
967
713
  end
968
714
 
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
715
+ def show_cursor
716
+ renderer.show_cursor
988
717
  end
989
718
 
990
719
  def restore_active_cursor
991
720
  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
721
  return
1051
722
  end
1052
723
 
1053
724
  session = active_session
1054
725
  return unless session
1055
726
 
727
+ if session.respond_to?(:cursor_visible?) && !session.cursor_visible?
728
+ renderer.hide_cursor
729
+ return
730
+ end
731
+
1056
732
  if session.respond_to?(:pager_active?) && session.pager_active?
1057
- ::Curses.curs_set(0)
733
+ renderer.hide_cursor
1058
734
  return
1059
735
  end
1060
736
 
1061
737
  return unless session.respond_to?(:field) && session.field
1062
738
 
1063
- ::Curses.curs_set(1)
739
+ renderer.show_cursor
1064
740
  renderer.restore_cursor(session.field)
1065
741
  end
742
+
743
+ def choose_theme
744
+ current = Fatty::Themes::Manager.current
745
+ names = Fatty::Themes::Manager.theme_names
746
+ ordered = [current] + (names - [current])
747
+
748
+ chooser = PopUpSession.new(
749
+ source: ordered,
750
+ kind: :theme_chooser,
751
+ title: "Themes",
752
+ prompt: "Theme: ",
753
+ current: :top,
754
+ )
755
+ owner = PopupOwner.new(
756
+ on_result: ->(payload) do
757
+ Fatty::Command.terminal(:set_theme, theme: payload[:item])
758
+ end,
759
+ on_cancel: -> do
760
+ Fatty::Command.terminal(:set_theme, theme: current)
761
+ end,
762
+ on_change: ->(payload) do
763
+ Fatty::Command.terminal(:set_theme, theme: payload[:item])
764
+ end,
765
+ )
766
+ push_modal(chooser, owner: owner)
767
+ end
1066
768
  end
1067
769
  end