Class: Fatty::Terminal
- Inherits:
-
Object
- Object
- Fatty::Terminal
- Defined in:
- lib/fatty/terminal.rb,
lib/fatty/terminal/progress.rb,
lib/fatty/terminal/popup_owner.rb
Defined Under Namespace
Classes: PopupOwner, Progress
Constant Summary collapse
- SCROLL_RENDER_THROTTLE =
0.05- DEFAULT_STATUS_MAX_ROWS =
4
Instance Attribute Summary collapse
-
#env ⇒ Object
readonly
Commands are plain Ruby arrays for now.
-
#event_source ⇒ Object
readonly
Commands are plain Ruby arrays for now.
-
#renderer ⇒ Object
readonly
Commands are plain Ruby arrays for now.
-
#screen ⇒ Object
readonly
Commands are plain Ruby arrays for now.
-
#status_role ⇒ Object
readonly
Commands are plain Ruby arrays for now.
-
#status_text ⇒ Object
readonly
Commands are plain Ruby arrays for now.
Instance Method Summary collapse
- #active_session ⇒ Object
- #call_menu_action(action, session:, label:, payload:) ⇒ Object
-
#choose(prompt, choices:, initial_choice_idx: 0, quit_value: nil) ⇒ Object
The consumer can call #choose to cause an interactive popup session to present the user with a series of choices to select from.
-
#choose_multi(prompt, choices:, quit_value: nil) ⇒ Object
The consumer can call #choose_multi to cause an interactive popup session to present the user with a series of choices to select from.
- #clear_status ⇒ Object
-
#confirm(prompt, default: true) ⇒ Object
A simple Yes/No chooser.
- #find_session(id) ⇒ Object
- #focused_session ⇒ Object
-
#go ⇒ Object
--- Runtime -----------------------------------------------------------.
-
#good(text) ⇒ Object
Display a message to the user in the status line, colored according to the Config for "good," i.e., success.
-
#info(text) ⇒ Object
Display a message to the user in the status line, colored according to the Config for "info".
-
#initialize(prompt: "> ", on_accept: nil, completion_proc: nil, history_path: :default, history_ctx: nil, env: nil) ⇒ Terminal
constructor
A new instance of Terminal.
-
#menu(prompt, choices:, initial_choice_idx: 0, quit_value: nil) ⇒ Object
Present a chooser whose selected value is executed.
-
#modal_owner ⇒ Object
Return the owner of the top modal session without modifying the stack.
-
#oops(text) ⇒ Object
Display a message to the user in the status line, colored according to the Config for "oops," i.e., a soft failure.
- #pin(session) ⇒ Object
- #pop ⇒ Object
- #pop_modal ⇒ Object
-
#progress(label:, total: nil, style: :percent, role: :info, width: 40) ⇒ Object
Create a transient status-line progress indicator.
-
#prompt(prompt, initial: "", quit_value: nil, history_key: nil) ⇒ Object
Create a popup to ask the user to enter an arbitrary string.
-
#push(session) ⇒ Object
--- Session management ------------------------------------------------.
- #push_modal(session, owner:) ⇒ Object
- #register(session) ⇒ Object
- #render_now ⇒ Object
-
#set_status(text, role: :info, transient: false) ⇒ Object
--- Status line management ------------------------------------------------.
- #status_lines ⇒ Object
- #status_max_rows ⇒ Object
- #status_rows ⇒ Object
- #status_visible? ⇒ Boolean
- #transient_status? ⇒ Boolean
-
#warn(text) ⇒ Object
Display a message to the user in the status line, colored according to the Config for "warn," i.e., short of an error but not complete success either.
Constructor Details
#initialize(prompt: "> ", on_accept: nil, completion_proc: nil, history_path: :default, history_ctx: nil, env: nil) ⇒ Terminal
Returns a new instance of Terminal.
28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
# File 'lib/fatty/terminal.rb', line 28 def initialize(prompt: "> ", on_accept: nil, completion_proc: nil, history_path: :default, history_ctx: nil, env: nil) @prompt = Prompt.ensure(prompt) @on_accept = on_accept @completion_proc = completion_proc @history_path = history_path @history_ctx = history_ctx @env = env @running = false @stack = [] @pinned = [] @sessions = [] @sessions_by_id = {} @modal_stack = [] @status_text = nil @status_role = :info @status_transient = false end |
Instance Attribute Details
#env ⇒ Object (readonly)
Commands are plain Ruby arrays for now.
Suggested shapes:
Terminal/runtime commands: [:terminal, :quit] [:terminal, :push, session] [:terminal, :pop]
Session-targeted commands (no special casing): [:send, :alert, :show, { level: :warn, message: "No matches" }] [:send, :alert, :clear, {}]
You can add more later; Terminal only needs a small dispatcher.
26 27 28 |
# File 'lib/fatty/terminal.rb', line 26 def env @env end |
#event_source ⇒ Object (readonly)
Commands are plain Ruby arrays for now.
Suggested shapes:
Terminal/runtime commands: [:terminal, :quit] [:terminal, :push, session] [:terminal, :pop]
Session-targeted commands (no special casing): [:send, :alert, :show, { level: :warn, message: "No matches" }] [:send, :alert, :clear, {}]
You can add more later; Terminal only needs a small dispatcher.
26 27 28 |
# File 'lib/fatty/terminal.rb', line 26 def event_source @event_source end |
#renderer ⇒ Object (readonly)
Commands are plain Ruby arrays for now.
Suggested shapes:
Terminal/runtime commands: [:terminal, :quit] [:terminal, :push, session] [:terminal, :pop]
Session-targeted commands (no special casing): [:send, :alert, :show, { level: :warn, message: "No matches" }] [:send, :alert, :clear, {}]
You can add more later; Terminal only needs a small dispatcher.
26 27 28 |
# File 'lib/fatty/terminal.rb', line 26 def renderer @renderer end |
#screen ⇒ Object (readonly)
Commands are plain Ruby arrays for now.
Suggested shapes:
Terminal/runtime commands: [:terminal, :quit] [:terminal, :push, session] [:terminal, :pop]
Session-targeted commands (no special casing): [:send, :alert, :show, { level: :warn, message: "No matches" }] [:send, :alert, :clear, {}]
You can add more later; Terminal only needs a small dispatcher.
26 27 28 |
# File 'lib/fatty/terminal.rb', line 26 def screen @screen end |
#status_role ⇒ Object (readonly)
Commands are plain Ruby arrays for now.
Suggested shapes:
Terminal/runtime commands: [:terminal, :quit] [:terminal, :push, session] [:terminal, :pop]
Session-targeted commands (no special casing): [:send, :alert, :show, { level: :warn, message: "No matches" }] [:send, :alert, :clear, {}]
You can add more later; Terminal only needs a small dispatcher.
26 27 28 |
# File 'lib/fatty/terminal.rb', line 26 def status_role @status_role end |
#status_text ⇒ Object (readonly)
Commands are plain Ruby arrays for now.
Suggested shapes:
Terminal/runtime commands: [:terminal, :quit] [:terminal, :push, session] [:terminal, :pop]
Session-targeted commands (no special casing): [:send, :alert, :show, { level: :warn, message: "No matches" }] [:send, :alert, :clear, {}]
You can add more later; Terminal only needs a small dispatcher.
26 27 28 |
# File 'lib/fatty/terminal.rb', line 26 def status_text @status_text end |
Instance Method Details
#active_session ⇒ Object
145 146 147 148 149 150 |
# File 'lib/fatty/terminal.rb', line 145 def active_session top = @modal_stack.last return top[:session] if top focused_session end |
#call_menu_action(action, session:, label:, payload:) ⇒ Object
613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 |
# File 'lib/fatty/terminal.rb', line 613 def (action, session:, label:, payload:) if action.respond_to?(:call) env = MenuEnv.new( terminal: self, session: session, label: label, payload: payload, ) if action.arity.zero? action.call else action.call(env) end else action end end |
#choose(prompt, choices:, initial_choice_idx: 0, quit_value: nil) ⇒ Object
The consumer can call #choose to cause an interactive popup session to present the user with a series of choices to select from.
317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 |
# File 'lib/fatty/terminal.rb', line 317 def choose(prompt, choices:, initial_choice_idx: 0, quit_value: nil) items = normalize_choices(choices) raise ArgumentError, "choices must not be empty" if items.empty? labels = items.map(&:first) popup = Fatty::PopUpSession.new( source: labels, kind: :terminal_choose, title: "Choose", message: prompt, prompt: "> ", selection: :top, validate_unique_labels: true, ) popup.instance_variable_set(:@selected, initial_choice_idx.to_i.clamp(0, labels.length - 1)) done = false result = nil acc_proc = ->(payload) do item = payload[:item] idx = labels.index(item) result = idx ? items[idx][1] : quit_value done = true end cancel_proc = -> do result = quit_value done = true end owner = PopupOwner.new(on_result: acc_proc, on_cancel: cancel_proc) begin push_modal(popup, owner: owner) render_frame while !done && @running dirty = false msg = event_source.next_event if msg (msg) dirty = true end s = active_session begin tick_dirty = !!s&.tick dirty ||= tick_dirty rescue StandardError => e Fatty.error("Terminal#choose tick failed: #{e.class}: #{e.}", tag: :terminal) dirty = true end render_frame if dirty end ensure render_frame end result end |
#choose_multi(prompt, choices:, quit_value: nil) ⇒ Object
The consumer can call #choose_multi to cause an interactive popup session to present the user with a series of choices to select from.
393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 |
# File 'lib/fatty/terminal.rb', line 393 def choose_multi(prompt, choices:, quit_value: nil) items = normalize_choices(choices) raise ArgumentError, "choices must not be empty" if items.empty? labels = items.map(&:first) popup = Fatty::PopUpSession.new( source: labels, kind: :terminal_choose_multi, title: "Choose Many", message: prompt, prompt: "> ", selection: :top, selection_mode: :multiple, validate_unique_labels: true, ) done = false result = nil label_to_value = items.to_h acc_proc = ->(payload) do selected = payload[:items] || {} result = selected.each_with_object({}) do |(label, _), h| h[label] = label_to_value.fetch(label, quit_value) end done = true end cancel_proc = -> do result = quit_value done = true end owner = PopupOwner.new(on_result: acc_proc, on_cancel: cancel_proc) begin push_modal(popup, owner: owner) render_frame while !done && @running dirty = false msg = event_source.next_event if msg (msg) dirty = true end s = active_session begin tick_dirty = !!s&.tick dirty ||= tick_dirty rescue StandardError => e Fatty.error("Terminal#choose_multi tick failed: #{e.class}: #{e.}", tag: :terminal) dirty = true end render_frame if dirty end ensure render_frame end result end |
#clear_status ⇒ Object
75 76 77 78 79 80 81 |
# File 'lib/fatty/terminal.rb', line 75 def clear_status old_rows = status_rows @status_text = nil @status_role = :info @status_transient = false refresh_layout! if @screen && old_rows != status_rows end |
#confirm(prompt, default: true) ⇒ Object
A simple Yes/No chooser.
381 382 383 384 385 386 387 388 389 |
# File 'lib/fatty/terminal.rb', line 381 def confirm(prompt, default: true) idx = default ? 0 : 1 choose( prompt, choices: [["Yes", true], ["No", false]], initial_choice_idx: idx, quit_value: false, ) end |
#find_session(id) ⇒ Object
166 167 168 |
# File 'lib/fatty/terminal.rb', line 166 def find_session(id) @sessions_by_id[id] end |
#focused_session ⇒ Object
152 153 154 155 156 157 |
# File 'lib/fatty/terminal.rb', line 152 def focused_session top = @stack.last return top[:session] if top.is_a?(Hash) top end |
#go ⇒ Object
--- Runtime -----------------------------------------------------------
199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 |
# File 'lib/fatty/terminal.rb', line 199 def go preflight! start_curses! install_default_sessions! @running = true last_render = Process.clock_gettime(Process::CLOCK_MONOTONIC) pending_scroll_render = false render_frame # For performance logging perf_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) loop_count = 0 event_count = 0 tick_dirty_count = 0 render_count = 0 deferred_count = 0 frame_ms = 0.0 while @running loop_count += 1 dirty = false immediate = false msg = event_source.next_event if msg (msg) dirty = true immediate = true end s = active_session begin tick_dirty = !!s&.tick tick_dirty_count += 1 if tick_dirty dirty ||= tick_dirty rescue StandardError => e Fatty.error("Terminal#go tick failed: #{e.class}: #{e.}", tag: :terminal) dirty = true immediate = true end if dirty now = Process.clock_gettime(Process::CLOCK_MONOTONIC) if immediate || renderer.context.truecolor || !scrolling_output? render_frame last_render = now pending_scroll_render = false render_count += 1 elsif now - last_render >= SCROLL_RENDER_THROTTLE render_frame last_render = now pending_scroll_render = false render_count += 1 else pending_scroll_render = true deferred_count += 1 end elsif pending_scroll_render now = Process.clock_gettime(Process::CLOCK_MONOTONIC) if now - last_render >= SCROLL_RENDER_THROTTLE render_frame last_render = now pending_scroll_render = false render_count += 1 end end now = Process.clock_gettime(Process::CLOCK_MONOTONIC) if now - perf_started_at >= 1.0 avg_frame_ms = if render_count.zero? 0.0 else (frame_ms / render_count).round(2) end # Performance logging Fatty.debug( "perf loops=#{loop_count} events=#{event_count} " \ "tick_dirty=#{tick_dirty_count} renders=#{render_count} " \ "deferred=#{deferred_count} avg_frame_ms=#{avg_frame_ms} " \ "scrolling=#{scrolling_output?}", tag: :perf, ) perf_started_at = now loop_count = 0 event_count = 0 tick_dirty_count = 0 render_count = 0 deferred_count = 0 frame_ms = 0.0 end end rescue => e Fatty.error("Terminal#go fatal error: #{e.class}: #{e.}", tag: :terminal) Fatty.error(e.backtrace.join("\n"), tag: :terminal) if e.backtrace raise ensure begin stop_curses! rescue => e Fatty.error("Terminal#go stop_curses! failed: #{e.class}: #{e.}", tag: :terminal) Fatty.error(e.backtrace.join("\n"), tag: :terminal) if e.backtrace end begin persist_sessions! rescue => e Fatty.error("Terminal#go persist_sessions! failed: #{e.class}: #{e.}", tag: :terminal) Fatty.error(e.backtrace.join("\n"), tag: :terminal) if e.backtrace end end |
#good(text) ⇒ Object
Display a message to the user in the status line, colored according to the Config for "good," i.e., success.
97 98 99 100 101 |
# File 'lib/fatty/terminal.rb', line 97 def good(text) return $stderr.puts(text) unless @ctx set_status(text.to_s, role: :good, transient: true) end |
#info(text) ⇒ Object
Display a message to the user in the status line, colored according to the Config for "info".
89 90 91 92 93 |
# File 'lib/fatty/terminal.rb', line 89 def info(text) return $stderr.puts(text) unless @ctx set_status(text.to_s, role: :info, transient: true) end |
#menu(prompt, choices:, initial_choice_idx: 0, quit_value: nil) ⇒ Object
Present a chooser whose selected value is executed.
choices may be: [["Label", proc { ... }], ...] or: { "Label" => proc { ... } }
The proc may accept: 0 args terminal: terminal:, label: terminal:, label:, payload:
545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 |
# File 'lib/fatty/terminal.rb', line 545 def (prompt, choices:, initial_choice_idx: 0, quit_value: nil) items = normalize_choices(choices) raise ArgumentError, "choices must not be empty" if items.empty? labels = items.map(&:first) popup = Fatty::PopUpSession.new( source: labels, kind: :terminal_menu, title: "Menu", message: prompt, prompt: "> ", selection: :top, validate_unique_labels: true, ) popup.instance_variable_set(:@selected, initial_choice_idx.to_i.clamp(0, labels.length - 1)) done = false result = nil = active_session acc_proc = ->(payload) do label = payload[:item] idx = labels.index(label) action = idx ? items[idx][1] : nil result = ( action, session: , label: label, payload: payload, ) done = true end cancel_proc = -> do result = quit_value done = true end owner = PopupOwner.new(on_result: acc_proc, on_cancel: cancel_proc) begin push_modal(popup, owner: owner) render_frame while !done && @running dirty = false msg = event_source.next_event if msg (msg) dirty = true end s = active_session begin tick_dirty = !!s&.tick dirty ||= tick_dirty rescue StandardError => e Fatty.error("Terminal#menu tick failed: #{e.class}: #{e.}", tag: :terminal) dirty = true end render_frame if dirty end ensure render_frame end result end |
#modal_owner ⇒ Object
Return the owner of the top modal session without modifying the stack.
192 193 194 195 |
# File 'lib/fatty/terminal.rb', line 192 def modal_owner top = @modal_stack.last top && top[:owner] end |
#oops(text) ⇒ Object
Display a message to the user in the status line, colored according to the Config for "oops," i.e., a soft failure.
114 115 116 117 118 |
# File 'lib/fatty/terminal.rb', line 114 def oops(text) return $stderr.puts(text) unless @ctx set_status(text.to_s, role: :error, transient: true) end |
#pin(session) ⇒ Object
136 137 138 139 140 141 142 143 |
# File 'lib/fatty/terminal.rb', line 136 def pin(session) Fatty.debug("Terminal#pin(#{session})", tag: :session) @pinned << session register(session) commands = session.init(terminal: self) apply_commands(commands) session end |
#pop ⇒ Object
131 132 133 134 |
# File 'lib/fatty/terminal.rb', line 131 def pop Fatty.debug("Terminal#pop -> #{@stack.last}", tag: :session) @stack.pop end |
#pop_modal ⇒ Object
180 181 182 183 184 185 186 187 188 189 |
# File 'lib/fatty/terminal.rb', line 180 def pop_modal top = @modal_stack.pop msg = "Terminal#pop_modal: size=#{@modal_stack.length} popped=#{top && top[:session].class}" Fatty.debug(msg, tag: :session) session = top && top[:session] session.close if session&.respond_to?(:close) @renderer&.invalidate! nil end |
#progress(label:, total: nil, style: :percent, role: :info, width: 40) ⇒ Object
Create a transient status-line progress indicator. For style :spinner, total may be omitted for indeterminate progress.
522 523 524 525 526 527 528 529 530 531 |
# File 'lib/fatty/terminal.rb', line 522 def progress(label:, total: nil, style: :percent, role: :info, width: 40) Progress.new( terminal: self, label: label, total: total, style: style, role: role, width: width, ) end |
#prompt(prompt, initial: "", quit_value: nil, history_key: nil) ⇒ Object
Create a popup to ask the user to enter an arbitrary string. These prompts will keep their own history based on the history_key, or if not history_key is given, the prompt text.
464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 |
# File 'lib/fatty/terminal.rb', line 464 def prompt(prompt, initial: "", quit_value: nil, history_key: nil) history_ctx = { prompt: (history_key || prompt).to_s } popup = Fatty::PromptSession.new( title: "Prompt", message: prompt, prompt: "> ", initial: initial, kind: :terminal_prompt, history_ctx: history_ctx, ) done = false result = nil acc_proc = ->(payload) do result = payload[:text] done = true end cancel_proc = -> do result = quit_value done = true end owner = PopupOwner.new(on_result: acc_proc, on_cancel: cancel_proc) begin push_modal(popup, owner: owner) render_frame while !done && @running dirty = false msg = event_source.next_event if msg (msg) dirty = true end s = active_session begin tick_dirty = !!s&.tick dirty ||= tick_dirty rescue StandardError => e Fatty.error("Terminal#prompt tick failed: #{e.class}: #{e.}", tag: :terminal) dirty = true end render_frame if dirty end ensure render_frame end result end |
#push(session) ⇒ Object
--- Session management ------------------------------------------------
122 123 124 125 126 127 128 129 |
# File 'lib/fatty/terminal.rb', line 122 def push(session) Fatty.debug("Terminal#push(#{session})", tag: :session) @stack << session register(session) commands = session.init(terminal: self) apply_commands(commands) session end |
#push_modal(session, owner:) ⇒ Object
170 171 172 173 174 175 176 177 178 |
# File 'lib/fatty/terminal.rb', line 170 def push_modal(session, owner:) @modal_stack << { session: session, owner: owner } msg = "Terminal#push_modal: size=#{@modal_stack.length} session=#{session.class} object_id=#{session.object_id}" Fatty.debug(msg, tag: :session) register(session) @renderer.invalidate! if defined?(@renderer) && @renderer commands = session.init(terminal: self) apply_commands(commands) end |
#register(session) ⇒ Object
159 160 161 162 163 164 |
# File 'lib/fatty/terminal.rb', line 159 def register(session) return unless session.respond_to?(:id) return if session.id.nil? @sessions_by_id[session.id] = session end |
#render_now ⇒ Object
631 632 633 |
# File 'lib/fatty/terminal.rb', line 631 def render_now render_frame end |
#set_status(text, role: :info, transient: false) ⇒ Object
--- Status line management ------------------------------------------------
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
# File 'lib/fatty/terminal.rb', line 54 def set_status(text, role: :info, transient: false) old_rows = status_rows str = if text.is_a?(Array) text.map { |part| part.is_a?(Hash) && part.key?(:text) ? part[:text] : part }.join else text.to_s end if str.empty? @status_text = nil @status_role = :info @status_transient = false else @status_text = text @status_role = role @status_transient = transient end refresh_layout! if @screen && old_rows != status_rows end |
#status_lines ⇒ Object
649 650 651 652 653 654 655 656 |
# File 'lib/fatty/terminal.rb', line 649 def status_lines width = screen&.cols || 80 @status_text.to_s .lines .flat_map { |line| wrap_status_line(line.chomp, width) } .last(status_max_rows) end |
#status_max_rows ⇒ Object
645 646 647 |
# File 'lib/fatty/terminal.rb', line 645 def status_max_rows Fatty::Config.config.dig(:status, :max_rows)&.to_i || DEFAULT_STATUS_MAX_ROWS end |
#status_rows ⇒ Object
639 640 641 642 643 |
# File 'lib/fatty/terminal.rb', line 639 def status_rows return 0 unless status_visible? status_lines.length.clamp(1, status_max_rows) end |
#status_visible? ⇒ Boolean
635 636 637 |
# File 'lib/fatty/terminal.rb', line 635 def status_visible? @status_text && !@status_text.empty? end |
#transient_status? ⇒ Boolean
83 84 85 |
# File 'lib/fatty/terminal.rb', line 83 def transient_status? !!@status_transient end |
#warn(text) ⇒ Object
Display a message to the user in the status line, colored according to the Config for "warn," i.e., short of an error but not complete success either.
106 107 108 109 110 |
# File 'lib/fatty/terminal.rb', line 106 def warn(text) return $stderr.puts(text) unless @ctx set_status(text.to_s, role: :warn, transient: true) end |