r2ui 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/README.md +79 -0
  4. data/lib/r2ui/app.rb +311 -34
  5. data/lib/r2ui/canvas.rb +135 -14
  6. data/lib/r2ui/cli/builder.rb +72 -0
  7. data/lib/r2ui/cli/context.rb +45 -0
  8. data/lib/r2ui/cli/definition.rb +174 -0
  9. data/lib/r2ui/cli/ext/ask.rb +133 -0
  10. data/lib/r2ui/cli/ext/box.rb +112 -0
  11. data/lib/r2ui/cli/ext/choose.rb +162 -0
  12. data/lib/r2ui/cli/ext/choose_many.rb +241 -0
  13. data/lib/r2ui/cli/ext/color_option.rb +64 -0
  14. data/lib/r2ui/cli/ext/completion.rb +520 -0
  15. data/lib/r2ui/cli/ext/config.rb +179 -0
  16. data/lib/r2ui/cli/ext/confirm.rb +110 -0
  17. data/lib/r2ui/cli/ext/dashboard.rb +105 -0
  18. data/lib/r2ui/cli/ext/diff.rb +195 -0
  19. data/lib/r2ui/cli/ext/editor.rb +94 -0
  20. data/lib/r2ui/cli/ext/env.rb +108 -0
  21. data/lib/r2ui/cli/ext/examples.rb +62 -0
  22. data/lib/r2ui/cli/ext/filter.rb +269 -0
  23. data/lib/r2ui/cli/ext/format.rb +104 -0
  24. data/lib/r2ui/cli/ext/list.rb +117 -0
  25. data/lib/r2ui/cli/ext/log.rb +89 -0
  26. data/lib/r2ui/cli/ext/pager.rb +97 -0
  27. data/lib/r2ui/cli/ext/pairs.rb +62 -0
  28. data/lib/r2ui/cli/ext/parallel.rb +274 -0
  29. data/lib/r2ui/cli/ext/password.rb +158 -0
  30. data/lib/r2ui/cli/ext/progress.rb +330 -0
  31. data/lib/r2ui/cli/ext/reference.rb +129 -0
  32. data/lib/r2ui/cli/ext/spinner.rb +165 -0
  33. data/lib/r2ui/cli/ext/summary.rb +63 -0
  34. data/lib/r2ui/cli/ext/table.rb +141 -0
  35. data/lib/r2ui/cli/ext/tasks.rb +216 -0
  36. data/lib/r2ui/cli/ext/trace.rb +64 -0
  37. data/lib/r2ui/cli/ext/tree.rb +114 -0
  38. data/lib/r2ui/cli/ext/version.rb +67 -0
  39. data/lib/r2ui/cli/extension.rb +129 -0
  40. data/lib/r2ui/cli/help.rb +115 -0
  41. data/lib/r2ui/cli/helpers.rb +20 -0
  42. data/lib/r2ui/cli/live.rb +150 -0
  43. data/lib/r2ui/cli/parser.rb +165 -0
  44. data/lib/r2ui/cli/prompt.rb +133 -0
  45. data/lib/r2ui/cli/runner.rb +86 -0
  46. data/lib/r2ui/cli/shell.rb +185 -0
  47. data/lib/r2ui/cli/testing.rb +49 -0
  48. data/lib/r2ui/cli.rb +129 -0
  49. data/lib/r2ui/compat/bubbletea/LICENSE-bubbletea.txt +21 -0
  50. data/lib/r2ui/compat/bubbletea/ansi.rb +424 -0
  51. data/lib/r2ui/compat/bubbletea/commands.rb +143 -0
  52. data/lib/r2ui/compat/bubbletea/input.rb +269 -0
  53. data/lib/r2ui/compat/bubbletea/input_reader.rb +66 -0
  54. data/lib/r2ui/compat/bubbletea/messages.rb +251 -0
  55. data/lib/r2ui/compat/bubbletea/model.rb +65 -0
  56. data/lib/r2ui/compat/bubbletea/native.rb +254 -0
  57. data/lib/r2ui/compat/bubbletea/renderer.rb +192 -0
  58. data/lib/r2ui/compat/bubbletea/runner.rb +425 -0
  59. data/lib/r2ui/compat/bubbletea/terminal.rb +230 -0
  60. data/lib/r2ui/compat/bubbletea/version.rb +8 -0
  61. data/lib/r2ui/compat/bubbletea/width_table.rb +228 -0
  62. data/lib/r2ui/compat/bubbletea.rb +16 -0
  63. data/lib/r2ui/compat/lipgloss/border.rb +17 -0
  64. data/lib/r2ui/compat/lipgloss/color.rb +78 -0
  65. data/lib/r2ui/compat/lipgloss/color_blend.rb +166 -0
  66. data/lib/r2ui/compat/lipgloss/colorful.rb +585 -0
  67. data/lib/r2ui/compat/lipgloss/layout.rb +461 -0
  68. data/lib/r2ui/compat/lipgloss/list.rb +85 -0
  69. data/lib/r2ui/compat/lipgloss/position.rb +30 -0
  70. data/lib/r2ui/compat/lipgloss/style/border.rb +134 -0
  71. data/lib/r2ui/compat/lipgloss/style.rb +1062 -0
  72. data/lib/r2ui/compat/lipgloss/table/render.rb +518 -0
  73. data/lib/r2ui/compat/lipgloss/table.rb +185 -0
  74. data/lib/r2ui/compat/lipgloss/termenv.rb +768 -0
  75. data/lib/r2ui/compat/lipgloss/text/unicode_tables.rb +428 -0
  76. data/lib/r2ui/compat/lipgloss/text.rb +1555 -0
  77. data/lib/r2ui/compat/lipgloss/tree/go_tree.rb +243 -0
  78. data/lib/r2ui/compat/lipgloss/tree.rb +105 -0
  79. data/lib/r2ui/compat/lipgloss/version.rb +15 -0
  80. data/lib/r2ui/compat/lipgloss.rb +64 -0
  81. data/lib/r2ui/compat/load_path/bubbletea.rb +4 -0
  82. data/lib/r2ui/compat/load_path/lipgloss.rb +4 -0
  83. data/lib/r2ui/component.rb +67 -0
  84. data/lib/r2ui/context.rb +87 -0
  85. data/lib/r2ui/drop_in.rb +16 -0
  86. data/lib/r2ui/dsl/column.rb +11 -3
  87. data/lib/r2ui/dsl/dashboard.rb +45 -9
  88. data/lib/r2ui/dsl/resource.rb +12 -0
  89. data/lib/r2ui/ext/after.rb +54 -0
  90. data/lib/r2ui/ext/async.rb +81 -0
  91. data/lib/r2ui/ext/batch_sequence.rb +54 -0
  92. data/lib/r2ui/ext/countdown.rb +52 -0
  93. data/lib/r2ui/ext/data_table.rb +71 -0
  94. data/lib/r2ui/ext/every.rb +66 -0
  95. data/lib/r2ui/ext/exec.rb +116 -0
  96. data/lib/r2ui/ext/file_picker.rb +78 -0
  97. data/lib/r2ui/ext/focus_report.rb +69 -0
  98. data/lib/r2ui/ext/help.rb +62 -0
  99. data/lib/r2ui/ext/keys.rb +64 -0
  100. data/lib/r2ui/ext/list.rb +145 -0
  101. data/lib/r2ui/ext/messages.rb +87 -0
  102. data/lib/r2ui/ext/mouse.rb +134 -0
  103. data/lib/r2ui/ext/paginator.rb +86 -0
  104. data/lib/r2ui/ext/paste.rb +86 -0
  105. data/lib/r2ui/ext/println.rb +21 -0
  106. data/lib/r2ui/ext/progress.rb +85 -0
  107. data/lib/r2ui/ext/refresh.rb +56 -0
  108. data/lib/r2ui/ext/resize.rb +62 -0
  109. data/lib/r2ui/ext/screen.rb +47 -0
  110. data/lib/r2ui/ext/screen_mode.rb +63 -0
  111. data/lib/r2ui/ext/spinner.rb +59 -0
  112. data/lib/r2ui/ext/state.rb +45 -0
  113. data/lib/r2ui/ext/stopwatch.rb +67 -0
  114. data/lib/r2ui/ext/suspend.rb +56 -0
  115. data/lib/r2ui/ext/text_area.rb +73 -0
  116. data/lib/r2ui/ext/text_input.rb +83 -0
  117. data/lib/r2ui/ext/theme.rb +112 -0
  118. data/lib/r2ui/ext/view.rb +37 -0
  119. data/lib/r2ui/ext/viewport.rb +92 -0
  120. data/lib/r2ui/ext/window_title.rb +51 -0
  121. data/lib/r2ui/extension.rb +199 -0
  122. data/lib/r2ui/history.rb +4 -1
  123. data/lib/r2ui/keys.rb +35 -8
  124. data/lib/r2ui/motion.rb +169 -0
  125. data/lib/r2ui/panel_state.rb +46 -2
  126. data/lib/r2ui/renderer.rb +87 -22
  127. data/lib/r2ui/version.rb +1 -1
  128. data/lib/r2ui/widgets/box.rb +6 -3
  129. data/lib/r2ui/widgets/glyphs.rb +149 -0
  130. data/lib/r2ui/widgets/table.rb +109 -25
  131. data/lib/r2ui.rb +12 -1
  132. metadata +119 -4
  133. data/lib/r2ui/terminal.rb +0 -53
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ module CLI
5
+ # One CLI capability in one file, the same shape as the dashboard DSL's R2UI.extension
6
+ # (docs/dsl.md). Files in lib/r2ui/cli/ext/ load automatically, in name order. See docs/cli.md
7
+ # and the reference ones, lib/r2ui/cli/ext/tasks.rb and confirm.rb.
8
+ #
9
+ # R2UI::CLI.extension :version do
10
+ # dsl(:command) { def version(v) = declare(:version, v) }
11
+ # setup { flag :version, short: "V", desc: "Print the version" if declared(:version).any? }
12
+ # before_run { (say(program.declared(:version).last) || halt) if options[:version] }
13
+ # end
14
+ #
15
+ # dsl(:command) { def kw ... end } keywords on Builder (`definition`, `declare`), at definition
16
+ # helpers { def h ... end } methods on Helpers (so on every Context too)
17
+ # setup { } on the root's Builder, after the R2UI.cli block
18
+ # after_parse { } on the Context, argv parsed, before defaults, required
19
+ # checks and positional arguments (`args` is empty here)
20
+ # before_run { } on the Context, before the command's run block
21
+ # after_run { } on the Context, after it returned normally
22
+ # help_header { |command, shell| } returns a String (or nil) shown first in --help
23
+ # help_section { |command, shell| } returns [heading, lines] (or nil) to append to --help
24
+ # on_error(Klass) { |error| } on the Context when an error escapes; returning an Integer
25
+ # makes it the exit code and skips the core's report
26
+ class Extension
27
+ TARGETS = { command: -> { Builder } }.freeze
28
+
29
+ Hook = Data.define(:extension, :matcher, :order, :block) do
30
+ def match?(value) = matcher.nil? || matcher === value # rubocop:disable Style/CaseEquality
31
+ end
32
+
33
+ attr_reader :name, :hooks
34
+
35
+ def initialize(name)
36
+ @name = name.to_sym
37
+ @modules = []
38
+ @hooks = Hash.new { |h, k| h[k] = [] }
39
+ end
40
+
41
+ def dsl(target = :command, &body)
42
+ builder = TARGETS.fetch(target) { raise Error, "unknown DSL target #{target.inspect}" }.call
43
+ add_module(builder, Module.new(&body))
44
+ end
45
+
46
+ def helpers(&body) = add_module(Helpers, Module.new(&body), also: Context)
47
+
48
+ def setup(&block) = hook(:setup, nil, block)
49
+
50
+ def after_parse(&block) = hook(:after_parse, nil, block)
51
+
52
+ def before_run(&block) = hook(:before_run, nil, block)
53
+
54
+ def after_run(&block) = hook(:after_run, nil, block)
55
+
56
+ def help_section(&block) = hook(:help_section, nil, block)
57
+
58
+ def help_header(&block) = hook(:help_header, nil, block)
59
+
60
+ def on_error(matcher = StandardError, &block) = hook(:on_error, matcher, block)
61
+
62
+ def remove!
63
+ @modules.each do |mod|
64
+ (mod.instance_methods(false) + mod.private_instance_methods(false)).each { |m| mod.send(:remove_method, m) }
65
+ end
66
+ @hooks.clear
67
+ end
68
+
69
+ private
70
+
71
+ def hook(kind, matcher, block)
72
+ raise ArgumentError, "#{kind} needs a block" unless block
73
+
74
+ @hooks[kind] << Hook.new(extension: name, matcher:, order: Extensions.next_order, block:)
75
+ end
76
+
77
+ def add_module(target, mod, also: nil)
78
+ defined = mod.instance_methods(false) + mod.private_instance_methods(false)
79
+ taken = defined.select do |m|
80
+ [target, also].compact.any? { |t| t.method_defined?(m) || t.private_method_defined?(m) && t.instance_method(m).owner != Kernel }
81
+ end
82
+ raise Error, "extension #{name}: #{target} already has #{taken.join(", ")}" if taken.any?
83
+
84
+ target.include(mod)
85
+ @modules << mod
86
+ mod
87
+ end
88
+ end
89
+
90
+ # The loaded CLI extensions, in load order.
91
+ module Extensions
92
+ @all = {}
93
+ @order = 0
94
+ @lock = Mutex.new
95
+
96
+ class << self
97
+ def register(name, &block)
98
+ name = name.to_sym
99
+ raise Error, "CLI extension #{name} is already registered" if @all.key?(name)
100
+
101
+ extension = Extension.new(name)
102
+ @all[name] = extension
103
+ begin
104
+ extension.instance_eval(&block) if block
105
+ rescue StandardError
106
+ remove(name)
107
+ raise
108
+ end
109
+ extension
110
+ end
111
+
112
+ def [](name) = @all[name.to_sym]
113
+
114
+ def names = @all.keys
115
+
116
+ # Unregisters an extension and empties its modules (for tests).
117
+ def remove(name) = @all.delete(name.to_sym)&.remove!
118
+
119
+ def hooks(kind) = @all.values.flat_map { |e| e.hooks.fetch(kind, []) }.sort_by(&:order)
120
+
121
+ def next_order = @lock.synchronize { @order += 1 }
122
+
123
+ def load_all(dir = File.expand_path("ext", __dir__))
124
+ Dir[File.join(dir, "*.rb")].sort.each { |file| require file }
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ module CLI
5
+ # The generated `--help` for a command:
6
+ #
7
+ # Deploy an app to the fleet
8
+ #
9
+ # Usage
10
+ # deployer deploy <app> [sha] [options]
11
+ #
12
+ # Arguments
13
+ # app App to deploy
14
+ #
15
+ # Options
16
+ # -e, --env <env> Target environment (staging, production) [default: staging]
17
+ # --[no-]force Skip the checks
18
+ # -h, --help Show help
19
+ #
20
+ # Headings bold, names in the accent colour, defaults muted, when the shell has colour; the
21
+ # same text without escapes otherwise. Extensions put lines on top with `help_header` (e.g.
22
+ # "deployer 1.4.0") and append sections with `help_section`.
23
+ class Help
24
+ INDENT = " "
25
+ GAP = " "
26
+
27
+ def initialize(command, shell)
28
+ @command = command
29
+ @shell = shell
30
+ end
31
+
32
+ def to_s
33
+ sections = Extensions.hooks(:help_header).filter_map do |hook|
34
+ text = hook.block.call(@command, @shell)
35
+ text.nil? || text.to_s.empty? ? nil : text.to_s.chomp
36
+ end
37
+ about = @command.description || @command.summary
38
+ sections << about if about
39
+ sections << section("Usage", [INDENT + usage])
40
+ sections << table("Arguments", @command.arguments.map { |a| [a.name.to_s.tr("_", "-"), describe_argument(a)] })
41
+ sections << table("Commands", visible_commands.map { |c| [[c.name, *c.aliases].join(", "), c.summary.to_s] })
42
+ sections << table("Options", option_rows)
43
+ Extensions.hooks(:help_section).each do |hook|
44
+ heading, body = hook.block.call(@command, @shell)
45
+ sections << section(heading, Array(body).flat_map { |b| b.to_s.split("\n") }.map { |l| INDENT + l }) if heading
46
+ end
47
+ sections << footer if @command.group?
48
+ "#{sections.compact.join("\n\n")}\n"
49
+ end
50
+
51
+ def usage
52
+ parts = [@command.full_name]
53
+ parts << "<command>" if @command.group? && !@command.action
54
+ parts << "[command]" if @command.group? && @command.action
55
+ parts.concat(@command.arguments.map(&:usage))
56
+ parts << "[options]"
57
+ @shell.paint(parts.join(" "), :accent)
58
+ end
59
+
60
+ private
61
+
62
+ def visible_commands = @command.commands.reject(&:hidden)
63
+
64
+ def option_rows
65
+ options = @command.all_options.reject(&:hidden)
66
+ rows = options.map { |o| [option_label(o), describe_option(o)] }
67
+ rows << ["-h, --help", "Show help"]
68
+ rows
69
+ end
70
+
71
+ def option_label(option)
72
+ short = option.short ? "-#{option.short}, " : " "
73
+ long = option.boolean? && option.default == true ? "--[no-]#{option.long.delete_prefix("--")}" : option.long
74
+ long += " <#{option.value_name}>" unless option.boolean?
75
+ long += "..." if option.many
76
+ short + long
77
+ end
78
+
79
+ def describe_option(option)
80
+ notes = []
81
+ notes << option.choices.join(", ") if option.choices
82
+ notes << "required" if option.required
83
+ default = option.default
84
+ notes << "default: #{default}" if !default.nil? && !default.respond_to?(:call) && default != false && default != []
85
+ [option.desc, notes.empty? ? nil : @shell.paint("(#{notes.join("; ")})", :muted)].compact.join(" ")
86
+ end
87
+
88
+ def describe_argument(argument)
89
+ notes = argument.default.nil? ? nil : @shell.paint("(default: #{argument.default})", :muted)
90
+ [argument.desc, notes].compact.join(" ")
91
+ end
92
+
93
+ def section(heading, lines)
94
+ return nil if lines.empty?
95
+
96
+ [@shell.paint(heading, :heading), *lines].join("\n")
97
+ end
98
+
99
+ def table(heading, rows)
100
+ return nil if rows.empty?
101
+
102
+ width = rows.map { |name, _| name.size }.max
103
+ lines = rows.map do |name, text|
104
+ line = INDENT + @shell.paint(name, :accent)
105
+ text.to_s.empty? ? line : line + (" " * (width - name.size)) + GAP + text
106
+ end
107
+ section(heading, lines)
108
+ end
109
+
110
+ def footer
111
+ @shell.paint("Run '#{@command.full_name} <command> --help' for more about a command.", :muted)
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ module CLI
5
+ # The inline helpers (tasks, confirm, ...). Every extension's `helpers` block adds a module
6
+ # here, so they are available
7
+ # - in a command's `run` block (a Context, whose `shell` is the runner's),
8
+ # - in any script that does `include R2UI::CLI::Helpers` (they use R2UI::CLI.shell),
9
+ # - as `R2UI::CLI.confirm(...)` and friends.
10
+ #
11
+ # A helper writes only through `shell` (Shell#puts, #err_puts, #paint, #symbol), so it works on
12
+ # a terminal, in a pipe and in tests alike.
13
+ module Helpers
14
+ def shell = CLI.shell
15
+
16
+ # Prints a line, styled with theme styles when the shell has colour: `say "Done", :success`.
17
+ def say(text = "", *styles) = shell.puts(shell.paint(text, *styles))
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module R2UI
6
+ module CLI
7
+ # An inline region redrawn in place: spinners, task lists, progress bars. It draws with the
8
+ # compat engine's inline renderer (R2UI::Compat::Tea::Renderer, Bubbletea's standard
9
+ # renderer), so lines are redrawn and erased exactly the way an inline Bubbletea program's are,
10
+ # but it takes no raw mode and reads no keys: the work runs on the caller's thread, ctrl+c is an
11
+ # ordinary Interrupt, and typed input stays in the terminal's buffer.
12
+ #
13
+ # live = Live.new(shell) { |frame| "#{Live.spinner(frame)} Building" }
14
+ # live.run { build } # draws at FPS until the block returns, then the last frame stays
15
+ #
16
+ # Off a live terminal (a pipe, TERM=dumb) it draws nothing: `run` just yields and the caller
17
+ # prints its own plain lines (check `live?`). The cursor is hidden while drawing and shown
18
+ # again on every exit path (the block's return, an exception, ctrl+c).
19
+ class Live
20
+ # bubbles' Spinner::MINI_DOT frames, at its 12 fps.
21
+ SPINNER = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
22
+ FPS = 12
23
+ HIDE_CURSOR = "\e[?25l"
24
+ SHOW_CURSOR = "\e[?25h"
25
+ ERASE_BELOW = "\e[J"
26
+
27
+ def self.spinner(frame) = SPINNER[frame % SPINNER.size]
28
+
29
+ attr_reader :shell, :frame
30
+
31
+ # When true, `stop` erases the region instead of leaving its last frame (a spinner that
32
+ # disappears on success). Set it at any time before the region stops.
33
+ attr_accessor :clear
34
+
35
+ # `view` gets the frame number (it ticks at `fps`) and returns the region's lines.
36
+ def initialize(shell, fps: FPS, clear: false, &view)
37
+ raise ArgumentError, "Live needs a view block" unless view
38
+
39
+ @shell = shell
40
+ @fps = fps
41
+ @clear = clear
42
+ @view = view
43
+ @lock = Monitor.new
44
+ @frame = 0
45
+ @running = false
46
+ @lines = 0
47
+ @error = nil
48
+ end
49
+
50
+ def live? = shell.live?
51
+
52
+ def running? = @running
53
+
54
+ # Draws while the block runs; returns the block's value. If the view raised while the
55
+ # ticker was drawing, that error is raised here once the terminal is restored.
56
+ def run
57
+ start
58
+ value = yield self
59
+ stop
60
+ raise @error if @error
61
+
62
+ value
63
+ ensure
64
+ stop
65
+ end
66
+
67
+ def start
68
+ return self if @running || !live?
69
+
70
+ @running = true
71
+ @renderer = new_renderer
72
+ shell.print(HIDE_CURSOR)
73
+ shell.live = self
74
+ refresh
75
+ @ticker = Thread.new do
76
+ Thread.current.report_on_exception = false
77
+ loop do
78
+ sleep(1.0 / @fps)
79
+ @lock.synchronize do
80
+ @frame += 1
81
+ draw
82
+ end
83
+ end
84
+ rescue StandardError => e
85
+ @error = e # the ticker stops; `run` (or the next refresh) surfaces it
86
+ end
87
+ self
88
+ end
89
+
90
+ # Redraws now (call after changing what the view shows, so it doesn't wait for a tick).
91
+ def refresh
92
+ @lock.synchronize { draw if @running }
93
+ self
94
+ end
95
+
96
+ # Prints a line above the region; it stays in the scrollback. Shell#puts calls this while
97
+ # the region is drawing.
98
+ def println(text)
99
+ @lock.synchronize do
100
+ erase if @running
101
+ shell.output.write("#{text}\n")
102
+ next unless @running
103
+
104
+ @renderer = new_renderer
105
+ draw
106
+ end
107
+ nil
108
+ end
109
+
110
+ # Draws the last frame and leaves it in the scrollback (or erases the region when `clear`),
111
+ # then shows the cursor again. The terminal is restored even if the last draw raises or a
112
+ # second ctrl+c arrives while the ticker stops; that error then propagates.
113
+ def stop
114
+ return unless @running
115
+
116
+ begin
117
+ @ticker&.kill
118
+ @ticker&.join
119
+ @lock.synchronize { @clear ? erase : (draw unless @error) }
120
+ ensure
121
+ @running = false
122
+ shell.live = nil
123
+ shell.output.write(@clear ? SHOW_CURSOR : "\n#{SHOW_CURSOR}")
124
+ shell.output.flush if shell.output.respond_to?(:flush)
125
+ end
126
+ nil
127
+ end
128
+
129
+ private
130
+
131
+ def draw
132
+ raise @error if @error
133
+
134
+ view = @view.call(@frame).to_s
135
+ @renderer.render(view)
136
+ @lines = [view.empty? ? 1 : view.split("\n", -1).size, shell.height].min
137
+ end
138
+
139
+ # Moves to the region's first line and erases it and everything below.
140
+ def erase
141
+ up = @lines - 1
142
+ shell.output.write("#{up.positive? ? "\e[#{up}A" : ""}\r#{ERASE_BELOW}")
143
+ end
144
+
145
+ def new_renderer
146
+ Compat::Tea::Renderer.new(shell.output).tap { |r| r.set_size(shell.width, shell.height) }
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ module CLI
5
+ # argv → which command, its options and arguments. Errors are UsageErrors with a "did you
6
+ # mean" when a near name exists. Options may come anywhere after the command that owns them
7
+ # (or an ancestor that declared them); `--` ends options.
8
+ #
9
+ # Parsing happens in two steps so extensions can act in between (the runner's `after_parse`
10
+ # hooks): `parse` reads argv (commands and options); `finish` applies defaults, checks
11
+ # required options and reads the positional arguments.
12
+ class Parser
13
+ # `options` holds the given options (converted); `given` their names.
14
+ Invocation = Struct.new(:command, :options, :given, :args, :positionals, :help, :argv, keyword_init: true)
15
+
16
+ NEGATIVE_NUMBER = /\A-\d+(\.\d+)?\z/
17
+
18
+ def initialize(program)
19
+ @program = program
20
+ end
21
+
22
+ def parse(argv)
23
+ argv = argv.map(&:to_s)
24
+ command = @program
25
+ options = {}
26
+ positionals = []
27
+ help = false
28
+ only_positionals = false
29
+ i = 0
30
+
31
+ while i < argv.size
32
+ token = argv[i]
33
+ i += 1
34
+ if only_positionals
35
+ positionals << token
36
+ elsif token == "--"
37
+ only_positionals = true
38
+ elsif %w[--help -h].include?(token)
39
+ help = true
40
+ elsif token.start_with?("--")
41
+ i = long(command, token, argv, i, options)
42
+ elsif token.start_with?("-") && token.size > 1 && !(token.match?(NEGATIVE_NUMBER) && command.arguments.any?)
43
+ i = short(command, token, argv, i, options)
44
+ elsif positionals.empty? && command.group? && (sub = command.find(token))
45
+ command = sub
46
+ elsif positionals.empty? && command.group? && token == "help" && !help
47
+ help = true
48
+ elsif positionals.empty? && command.group? && !(command.action && command.arguments.any?)
49
+ raise UsageError, unknown("command", token, command.commands.reject(&:hidden).flat_map { |c| [c.name, *c.aliases] },
50
+ "for '#{command.full_name}'")
51
+ else
52
+ positionals << token
53
+ end
54
+ end
55
+
56
+ Invocation.new(command:, options:, given: options.keys, args: {}, positionals:, help:, argv:)
57
+ rescue UsageError => e
58
+ e.command ||= command
59
+ raise
60
+ end
61
+
62
+ # After the `after_parse` hooks: defaults for options still missing, required checks, then
63
+ # the positional arguments (filled into `invocation.args` in place, so a Context holding it
64
+ # sees them). A hook that halts (`--version`) never meets these checks.
65
+ def finish(invocation)
66
+ options = invocation.options
67
+ invocation.command.all_options.each do |option|
68
+ next if options.key?(option.name)
69
+
70
+ value = option.default_value
71
+ if option.required && (value.nil? || value == [])
72
+ raise UsageError, "missing required option #{option.long}"
73
+ end
74
+
75
+ options[option.name] = value
76
+ end
77
+ invocation.args.replace(arguments(invocation.command, invocation.positionals))
78
+ invocation
79
+ rescue UsageError => e
80
+ e.command ||= invocation.command
81
+ raise
82
+ end
83
+
84
+ private
85
+
86
+ def long(command, token, argv, i, options)
87
+ name, value = token.delete_prefix("--").split("=", 2)
88
+ option = find_long(command, name)
89
+ if option.nil? && name.start_with?("no-") && value.nil?
90
+ negated = find_long(command, name.delete_prefix("no-"))
91
+ return store(options, negated, false, i) if negated&.boolean?
92
+ end
93
+ raise UsageError, unknown("option", "--#{name}", command.all_options.reject(&:hidden).map(&:long)) unless option
94
+
95
+ if option.boolean?
96
+ store(options, option, value.nil? ? true : option.convert(value), i)
97
+ elsif value
98
+ store(options, option, option.convert(value), i)
99
+ else
100
+ raise UsageError, "option #{option.long} needs a value" if i >= argv.size
101
+
102
+ store(options, option, option.convert(argv[i]), i + 1)
103
+ end
104
+ end
105
+
106
+ def short(command, token, argv, i, options)
107
+ chars = token.delete_prefix("-").chars
108
+ chars.each_with_index do |char, j|
109
+ option = command.all_options.find { |o| o.short == char }
110
+ raise UsageError, unknown("option", "-#{char}", command.all_options.filter_map { |o| "-#{o.short}" if o.short }) unless option
111
+ next store(options, option, true, i) if option.boolean?
112
+
113
+ attached = chars[(j + 1)..].join
114
+ return store(options, option, option.convert(attached), i) unless attached.empty?
115
+ raise UsageError, "option -#{char} (#{option.long}) needs a value" if i >= argv.size
116
+
117
+ return store(options, option, option.convert(argv[i]), i + 1)
118
+ end
119
+ i
120
+ end
121
+
122
+ def store(options, option, value, i)
123
+ if option.many
124
+ (options[option.name] ||= []) << value
125
+ else
126
+ options[option.name] = value
127
+ end
128
+ i
129
+ end
130
+
131
+ def find_long(command, name)
132
+ name = name.tr("-", "_")
133
+ command.all_options.find { |o| o.name.to_s == name }
134
+ end
135
+
136
+ def arguments(command, positionals)
137
+ args = {}
138
+ command.arguments.each_with_index do |argument, index|
139
+ if argument.many
140
+ values = positionals[index..] || []
141
+ raise UsageError, "missing argument #{argument.usage}" if argument.required && values.empty?
142
+
143
+ args[argument.name] = values.empty? && argument.default ? Array(argument.default) : values.map { |v| argument.convert(v) }
144
+ elsif index < positionals.size
145
+ args[argument.name] = argument.convert(positionals[index])
146
+ elsif argument.required
147
+ raise UsageError, "missing argument #{argument.usage}"
148
+ else
149
+ args[argument.name] = argument.default
150
+ end
151
+ end
152
+ extra = command.arguments.last&.many ? [] : positionals.drop(command.arguments.size)
153
+ raise UsageError, "unexpected argument #{extra.first.inspect}" if extra.any?
154
+
155
+ args
156
+ end
157
+
158
+ def unknown(kind, word, candidates, suffix = nil)
159
+ message = "unknown #{kind} '#{word}'#{" #{suffix}" if suffix}"
160
+ guess = DidYouMean::SpellChecker.new(dictionary: candidates).correct(word).first if defined?(DidYouMean)
161
+ guess ? "#{message}. Did you mean '#{guess}'?" : message
162
+ end
163
+ end
164
+ end
165
+ end