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
data/lib/r2ui/canvas.rb CHANGED
@@ -14,6 +14,10 @@ module R2UI
14
14
  end
15
15
 
16
16
  # A grid of styled characters. Widgets draw into it; the terminal (or a snapshot) prints it.
17
+ #
18
+ # Cells hold Integer codepoints, so drawing text allocates nothing per character. Text in another
19
+ # encoding, or with invalid bytes, keeps its one-character Strings instead; both append to a line
20
+ # the same way (`String#<<`). The second cell of a wide character holds "" (prints nothing).
17
21
  class Canvas
18
22
  STYLES = {
19
23
  plain: "0", bold: "1", dim: "2", reverse: "7",
@@ -21,46 +25,163 @@ module R2UI
21
25
  ok: "32", warn: "33", alert: "31", accent: "35", muted: "90"
22
26
  }.freeze
23
27
 
28
+ SGR = /\A\e\[([0-9;:]*)m\z/
29
+ # SGR, any other escape sequence (CSI, OSC, two-byte; dropped), or a run of text.
30
+ TOKEN = /\e\[[0-9;:]*m|\e(?:\[[0-?]*[ -\/]*[@-~]|\][^\a\e]*(?:\a|\e\\)|[@-_])|[^\e]+|\e/
31
+ RESET = /\A0*\z/
32
+
33
+ SPACE = 32
34
+ WIDE_TAIL = ""
35
+ CODEPOINT_ENCODINGS = [Encoding::UTF_8, Encoding::US_ASCII].freeze
36
+ MEMO_LIMIT = 4096
37
+
38
+ # Memos of pure functions, shared by every canvas: cell width per codepoint (ANSI.string_width
39
+ # of that one character), and the "\e[0;...m" prefix per SGR String style and per named style
40
+ # of the default palette.
41
+ WIDTHS = {} # rubocop:disable Style/MutableConstant
42
+ STRING_PREFIXES = {} # rubocop:disable Style/MutableConstant
43
+ PALETTE_PREFIXES = {} # rubocop:disable Style/MutableConstant
44
+
45
+ def self.cell_width(cp)
46
+ return 1 if cp >= 0x20 && cp < 0x7F
47
+
48
+ WIDTHS[cp] ||= Compat::Tea::ANSI.string_width(cp.chr(Encoding::UTF_8))
49
+ end
50
+
24
51
  attr_reader :width, :height
25
52
 
26
- def initialize(width, height)
53
+ # `styles` overrides or adds named styles ({ name => "SGR params" }).
54
+ def initialize(width, height, styles: nil)
27
55
  @width = width
28
56
  @height = height
29
- @chars = Array.new(height) { Array.new(width, " ") }
57
+ @palette = styles ? STYLES.merge(styles) : STYLES
58
+ @prefixes = styles ? {} : PALETTE_PREFIXES
59
+ @chars = Array.new(height) { Array.new(width, SPACE) }
30
60
  @styles = Array.new(height) { Array.new(width, :plain) }
31
61
  end
32
62
 
63
+ # Writes one line of styled text (a lipgloss render, a bubbles view): its SGR colours are
64
+ # kept per cell, other escapes dropped. Wide characters take two cells. Returns the cells used.
65
+ def write_ansi(x, y, text, max: nil)
66
+ return 0 if y.negative? || y >= height
67
+
68
+ limit = [max || width, width - x].min
69
+ used = 0
70
+ sgr = nil
71
+ text.to_s.scan(TOKEN).each do |part|
72
+ if part.getbyte(0) == 27
73
+ if (m = part.match(SGR))
74
+ params = m[1]
75
+ sgr = params.match?(RESET) ? nil : (sgr ? "#{sgr};#{params}" : params)
76
+ end
77
+ next
78
+ end
79
+
80
+ style = sgr || :plain
81
+ if codepoints?(part)
82
+ part.each_codepoint do |cp|
83
+ w = Canvas.cell_width(cp)
84
+ next if w.zero?
85
+ return used if used + w > limit
86
+
87
+ put(x + used, y, cp, style)
88
+ put(x + used + 1, y, WIDE_TAIL, style) if w == 2
89
+ used += w
90
+ end
91
+ else
92
+ part.each_char do |ch|
93
+ w = Compat::Tea::ANSI.string_width(ch)
94
+ next if w.zero?
95
+ return used if used + w > limit
96
+
97
+ put(x + used, y, ch, style)
98
+ put(x + used + 1, y, WIDE_TAIL, style) if w == 2
99
+ used += w
100
+ end
101
+ end
102
+ end
103
+ used
104
+ end
105
+
33
106
  def write(x, y, text, style = :plain, max: nil)
34
107
  return if y.negative? || y >= height
35
108
 
36
- limit = [max || width, width - x].min
37
- text.to_s.each_char.first([limit, 0].max).each_with_index do |ch, i|
38
- next if (x + i).negative?
109
+ count = [[max || width, width - x].min, 0].max.to_i
110
+ return if count.zero?
111
+
112
+ chars = @chars[y]
113
+ styles = @styles[y]
114
+ str = text.to_s
115
+ i = 0
116
+ cells = codepoints?(str) ? str.each_codepoint : str.each_char
117
+ cells.each do |cell|
118
+ break if i >= count
39
119
 
40
- @chars[y][x + i] = ch
41
- @styles[y][x + i] = style
120
+ unless (x + i).negative?
121
+ chars[x + i] = cell
122
+ styles[x + i] = style
123
+ end
124
+ i += 1
42
125
  end
126
+ nil
43
127
  end
44
128
 
45
129
  def fill(rect, char = " ", style = :plain)
46
130
  rect.height.times { |dy| write(rect.x, rect.y + dy, char * rect.width, style) }
47
131
  end
48
132
 
49
- def plain_lines = @chars.map { |row| row.join.rstrip }
133
+ def plain_lines
134
+ @chars.map do |row|
135
+ line = String.new(capacity: row.size, encoding: Encoding::UTF_8)
136
+ row.each { |cell| line << cell }
137
+ line.rstrip!
138
+ line
139
+ end
140
+ end
50
141
 
51
142
  # ANSI for each line, so the terminal can redraw only lines that changed.
52
143
  def ansi_lines
53
- @chars.each_index.map do |y|
54
- out = +""
144
+ @chars.each_with_index.map do |row, y|
145
+ styles = @styles[y]
146
+ out = String.new(capacity: row.size * 4 + 128, encoding: Encoding::UTF_8)
55
147
  current = nil
56
- @chars[y].each_with_index do |ch, x|
57
- style = @styles[y][x]
58
- out << "\e[0;#{STYLES.fetch(style)}m" if style != current
148
+ x = 0
149
+ n = row.size
150
+ while x < n
151
+ style = styles[x]
152
+ out << prefix(style) if style != current
59
153
  current = style
60
- out << ch
154
+ out << row[x]
155
+ x += 1
61
156
  end
62
157
  out << "\e[0m"
63
158
  end
64
159
  end
160
+
161
+ private
162
+
163
+ # Text whose characters can be stored as Integer codepoints (appending one to a UTF-8 line
164
+ # gives the same bytes as appending the character).
165
+ def codepoints?(str) = CODEPOINT_ENCODINGS.include?(str.encoding) && str.valid_encoding?
166
+
167
+ def prefix(style)
168
+ if style.is_a?(String)
169
+ STRING_PREFIXES[style] || memo(STRING_PREFIXES, style, "\e[0;#{style}m")
170
+ else
171
+ @prefixes[style] || memo(@prefixes, style, "\e[0;#{@palette.fetch(style)}m")
172
+ end
173
+ end
174
+
175
+ def memo(hash, key, value)
176
+ hash.clear if hash.size >= MEMO_LIMIT
177
+ hash[key] = value.freeze
178
+ end
179
+
180
+ def put(x, y, ch, style)
181
+ return if x.negative? || x >= width
182
+
183
+ @chars[y][x] = ch
184
+ @styles[y][x] = style
185
+ end
65
186
  end
66
187
  end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ module CLI
5
+ # The keywords inside `R2UI.cli do ... end` and `command :x do ... end`. Extensions add more
6
+ # with `dsl(:command) { def keyword ... end }`; inside them `definition` is the Command being
7
+ # built and `declare(key, value)` records plain data on it.
8
+ class Builder
9
+ attr_reader :definition
10
+
11
+ def initialize(definition)
12
+ @definition = definition
13
+ end
14
+
15
+ # One line, shown in the parent's command list and atop this command's help.
16
+ def summary(text) = definition.summary = text
17
+
18
+ # Longer text for this command's help (the summary when there is none).
19
+ def description(text) = definition.description = text
20
+
21
+ def option(name, type = :string, short: nil, default: nil, required: false, desc: nil, in: nil,
22
+ many: false, placeholder: nil, hidden: false, **meta)
23
+ short = short&.to_s&.delete_prefix("-")
24
+ raise ArgumentError, "short option must be one character, got #{short.inspect}" if short && short.size != 1
25
+
26
+ choices = binding.local_variable_get(:in)
27
+ definition.add_option(Option.new(name: name.to_sym, type:, short:, default:, required:, desc:, choices:,
28
+ many:, placeholder:, hidden:, meta:))
29
+ end
30
+
31
+ # A boolean option (`--force`, `--no-force`).
32
+ def flag(name, short: nil, default: false, desc: nil, hidden: false, **meta)
33
+ option(name, :boolean, short:, default:, desc:, hidden:, **meta)
34
+ end
35
+
36
+ # A positional argument, in order. `default:` makes it optional.
37
+ def argument(name, type = :string, required: true, many: false, desc: nil, default: nil)
38
+ required = false unless default.nil?
39
+ definition.add_argument(Argument.new(name: name.to_sym, type:, required:, many:, desc:, default:))
40
+ end
41
+
42
+ # A subcommand; its block runs on a Builder for it, so commands nest
43
+ # (`command :db do command :migrate do ... end end`).
44
+ def command(name, summary = nil, hidden: false, &block)
45
+ sub = Command.new(name, parent: definition)
46
+ sub.summary = summary
47
+ sub.hidden = hidden
48
+ definition.add_command(sub)
49
+ Builder.new(sub).instance_exec(&block) if block
50
+ sub
51
+ end
52
+
53
+ # Other names the command answers to (`aliases :d, :ship`).
54
+ def aliases(*names) = definition.aliases.concat(names.map(&:to_s))
55
+
56
+ # What the command does. The block runs on a Context: `args`, `options`, every helper.
57
+ def run(&block)
58
+ raise ArgumentError, "run needs a block" unless block
59
+
60
+ definition.action = block
61
+ end
62
+
63
+ # Hides the command from its parent's command list (it still runs).
64
+ def hidden = definition.hidden = true
65
+
66
+ # For extensions: record plain data on the command (`definition.declared(key)` reads it).
67
+ def declare(key, value) = definition.declare(key, value)
68
+
69
+ def declared(key) = definition.declared(key)
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ module CLI
5
+ # What a command's `run` block (and every runner hook) runs on. Has the parsed input, the
6
+ # shell, every helper, and the ways out:
7
+ #
8
+ # args[:app] positional arguments by name
9
+ # options[:env] options by name (defaults filled in); given?(:env) if typed
10
+ # abort!("msg", code: 1) "✖ msg" on stderr, exit 1 (no message: just the exit code)
11
+ # halt(0) stop now, quietly, with that code
12
+ # usage_error!("msg") like a bad option: message, "--help" hint, exit 2
13
+ class Context
14
+ include Helpers
15
+
16
+ attr_reader :shell, :command, :options, :args, :argv
17
+
18
+ def initialize(shell:, command:, options: {}, args: {}, argv: [], given: [])
19
+ @shell = shell
20
+ @command = command
21
+ @options = options
22
+ @args = args
23
+ @argv = argv
24
+ @given = given
25
+ @stores = {}
26
+ end
27
+
28
+ def program = command.root
29
+
30
+ def given?(name) = @given.include?(name.to_sym)
31
+
32
+ def abort!(message = nil, code: 1) = raise(Abort.new(message, code:))
33
+
34
+ def halt(code = 0) = raise(Halt.new(code))
35
+
36
+ def usage_error!(message) = raise(UsageError, message)
37
+
38
+ # An extension's private Hash for this run.
39
+ def store(name) = @stores[name.to_sym] ||= {}
40
+
41
+ # Runs a user block here (so it sees args, options and the helpers).
42
+ def call(block, *args) = instance_exec(*args, &block)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module R2UI
4
+ module CLI
5
+ # Value types for options and arguments: a Symbol from TYPES, or anything with #call that
6
+ # turns the string into a value (raising ArgumentError/TypeError for a bad one).
7
+ TYPES = {
8
+ string: ->(s) { s },
9
+ integer: ->(s) { Integer(s, 10) },
10
+ float: ->(s) { Float(s) },
11
+ boolean: ->(s) { Option.boolean(s) },
12
+ path: ->(s) { File.expand_path(s) }
13
+ }.freeze
14
+
15
+ # A typed option. `--name value`, `--name=value`, `-n value`, `-nvalue`; booleans are
16
+ # `--name` / `--no-name` and cluster (`-vf`). `many: true` collects repeats into an Array.
17
+ # `default:` may be a lambda (called when the option isn't given: `-> { ENV["TOKEN"] }`).
18
+ # `meta` keeps any other keyword, for extensions (`option :token, secret: true`).
19
+ Option = Data.define(:name, :type, :short, :default, :required, :desc, :choices, :many,
20
+ :placeholder, :hidden, :meta) do
21
+ def self.boolean(string)
22
+ case string.to_s.downcase
23
+ when "true", "yes", "y", "1", "on" then true
24
+ when "false", "no", "n", "0", "off" then false
25
+ else raise ArgumentError, "expects true or false"
26
+ end
27
+ end
28
+
29
+ def long = "--#{name.to_s.tr("_", "-")}"
30
+
31
+ def boolean? = type == :boolean
32
+
33
+ # The value name in help: `--env <env>`.
34
+ def value_name = placeholder || name.to_s.tr("_", "-")
35
+
36
+ def convert(string)
37
+ value = CLI.convert(type, string)
38
+ if choices && !choices.include?(value)
39
+ raise UsageError, "#{long} must be one of #{choices.join(", ")} (got #{string.inspect})"
40
+ end
41
+
42
+ value
43
+ rescue ArgumentError, TypeError => e
44
+ raise UsageError, "#{long} #{type_error(e)}, got #{string.inspect}"
45
+ end
46
+
47
+ def default_value
48
+ value = default.respond_to?(:call) ? default.call : default
49
+ value = convert(value) if value.is_a?(String) && type != :string && !type.respond_to?(:call)
50
+ many && value.nil? ? [] : value.dup
51
+ end
52
+
53
+ private
54
+
55
+ def type_error(error)
56
+ type.is_a?(Symbol) && type != :boolean ? "expects #{type == :integer ? "an" : "a"} #{type}" : error.message
57
+ end
58
+ end
59
+
60
+ # A positional argument. `many: true` (the last one only) takes the rest as an Array.
61
+ Argument = Data.define(:name, :type, :required, :many, :desc, :default) do
62
+ def usage
63
+ base = "#{name.to_s.tr("_", "-")}#{"..." if many}"
64
+ required ? "<#{base}>" : "[#{base}]"
65
+ end
66
+
67
+ def convert(string)
68
+ CLI.convert(type, string)
69
+ rescue ArgumentError, TypeError
70
+ raise UsageError, "<#{name}> expects #{type == :integer ? "an" : "a"} #{type}, got #{string.inspect}"
71
+ end
72
+ end
73
+
74
+ def self.convert(type, string)
75
+ converter = type.respond_to?(:call) ? type : TYPES.fetch(type) { raise ArgumentError, "unknown type #{type.inspect}" }
76
+ converter.call(string)
77
+ end
78
+
79
+ # One command: the root (the Program) or a subcommand. Plain data, built by Builder; nothing
80
+ # here parses or prints. `declared(key)` holds what extension keywords recorded.
81
+ class Command
82
+ attr_reader :name, :parent, :options, :arguments, :commands, :aliases, :declarations
83
+ attr_accessor :summary, :description, :action, :hidden
84
+
85
+ def initialize(name, parent: nil)
86
+ @name = name.to_s
87
+ @parent = parent
88
+ @options = []
89
+ @arguments = []
90
+ @commands = []
91
+ @aliases = []
92
+ @declarations = Hash.new { |h, k| h[k] = [] }
93
+ @hidden = false
94
+ end
95
+
96
+ def root? = parent.nil?
97
+
98
+ def root = root? ? self : parent.root
99
+
100
+ def group? = commands.any?
101
+
102
+ # ["deployer", "db", "migrate"]
103
+ def path = root? ? [name] : parent.path + [name]
104
+
105
+ def full_name = path.join(" ")
106
+
107
+ def declare(key, value)
108
+ @declarations[key.to_sym] << value
109
+ value
110
+ end
111
+
112
+ def declared(key) = @declarations.fetch(key.to_sym, [])
113
+
114
+ # The subcommand called `name` (or aliased so).
115
+ def find(name)
116
+ name = name.to_s
117
+ commands.find { |c| c.name == name || c.aliases.include?(name) }
118
+ end
119
+
120
+ # Options this command accepts: its own, then its ancestors' (a group's options apply to
121
+ # its whole subtree), nearest first.
122
+ def all_options = options + (root? ? [] : parent.all_options)
123
+
124
+ def option(name) = all_options.find { |o| o.name == name.to_sym }
125
+
126
+ def add_option(option)
127
+ taken = all_options.find { |o| o.name == option.name || (option.short && o.short == option.short) }
128
+ raise ArgumentError, "#{full_name}: option #{option.long} clashes with #{taken.long}" if taken
129
+ raise ArgumentError, "#{full_name}: -h is reserved for --help" if option.short == "h" || option.name == :help
130
+
131
+ @options << option
132
+ option
133
+ end
134
+
135
+ def add_argument(argument)
136
+ raise ArgumentError, "#{full_name}: only the last argument can be many: true" if arguments.last&.many
137
+ if argument.required && arguments.any? { |a| !a.required }
138
+ raise ArgumentError, "#{full_name}: required argument <#{argument.name}> after an optional one"
139
+ end
140
+
141
+ @arguments << argument
142
+ argument
143
+ end
144
+
145
+ def add_command(command)
146
+ raise ArgumentError, "#{full_name}: command #{command.name} is defined twice" if find(command.name)
147
+
148
+ @commands << command
149
+ command
150
+ end
151
+
152
+ def inspect = "#<#{self.class.name} #{full_name}>"
153
+ end
154
+
155
+ # The root command of a tool, plus how to run it.
156
+ class Program < Command
157
+ def self.build(name, &block)
158
+ program = new(name)
159
+ builder = Builder.new(program)
160
+ builder.instance_exec(&block) if block
161
+ Extensions.hooks(:setup).each { |hook| builder.instance_exec(&hook.block) }
162
+ program
163
+ end
164
+
165
+ # Runs argv and returns the exit code. Tests call this with a Shell on StringIOs.
166
+ def call(argv = ARGV, shell: CLI.shell) = Runner.new(self, shell:).call(argv)
167
+
168
+ # Runs argv and exits the process with the code.
169
+ def start(argv = ARGV, shell: CLI.shell) = exit(call(argv, shell:))
170
+ end
171
+ end
172
+ end
173
+
174
+ require_relative "builder"
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ # c17-ask: a line of text.
4
+ #
5
+ # name = ask("Project name?") # => "my-app"
6
+ # name = ask("Project name?", default: "app") # enter alone → "app"
7
+ # name = ask("Project name?", placeholder: "my-app",
8
+ # validate: ->(v) { "too short" if v.size < 2 }) # a String is the error message
9
+ #
10
+ # On a terminal (Shell#interactive?) it hosts bubbles' TextInput inline, like gum input:
11
+ #
12
+ # ? Project name? › my-app▌ the default (or `placeholder:`) shows muted
13
+ # ✖ too short until something valid is typed
14
+ #
15
+ # enter submits (the default when nothing is typed); an answer `validate:` rejects keeps the prompt
16
+ # open with its message under the field until the text is fixed. It leaves
17
+ # "✔ Project name? · my-app" in the scrollback. ctrl+c or esc raises Interrupt (a command exits
18
+ # 130) after the terminal is restored. Answers are stripped of surrounding spaces.
19
+ #
20
+ # Off a terminal it reads one line; an empty line or end of input means `default:`. When a person
21
+ # can still type (stdin is a terminal, stdout piped) it asks "Project name? (app) " on stderr and
22
+ # asks again after an answer `validate:` rejects. From a pipe (`echo web | tool`, CI) it doesn't
23
+ # ask, records "✔ Project name? · web" on stderr, and a rejected answer is an error. End of input
24
+ # with no default raises a CLI::Error naming the question.
25
+ module R2UI
26
+ module CLI
27
+ module Ext
28
+ module Ask
29
+ # The inline prompt: bubbles' TextInput hosted on a Prompt::Model.
30
+ class Model < Prompt::Model
31
+ attr_reader :question, :default, :message
32
+
33
+ def initialize(shell, question, default: nil, placeholder: nil, validate: nil)
34
+ super(shell)
35
+ CLI.require_bubbles!
36
+ @question = question
37
+ @default = default
38
+ @validate = validate
39
+ @message = nil
40
+ input = Bubbles::TextInput.new
41
+ input.prompt = ""
42
+ input.placeholder = (placeholder || default).to_s
43
+ input.placeholder_style = shell.color? ? shell.theme.style(:muted) : Lipgloss::Style.new
44
+ input.width = [shell.width - Lipgloss.width(question) - 6, 10].max
45
+ @focus_command = input.focus
46
+ self.component = input
47
+ end
48
+
49
+ def init = [self, @focus_command]
50
+
51
+ # Re-checks after every edit once a message shows, so it goes away when the text is fixed.
52
+ def update(message)
53
+ result = super
54
+ @message = Ask.check(@validate, answer) if @message && !done?
55
+ result
56
+ end
57
+
58
+ def view
59
+ return "#{shell.symbol(:error)} #{question}" if interrupted?
60
+ return Ask.answered(shell, question, value) if done?
61
+
62
+ line = "#{shell.paint("?", :accent)} #{question} #{shell.paint(shell.theme.symbol(:pointer), :muted)} #{component.view}"
63
+ return line unless @message
64
+
65
+ "#{line}\n #{shell.paint("#{shell.theme.symbol(:error)} #{@message}", :error)}"
66
+ end
67
+
68
+ private
69
+
70
+ def key(name, _message)
71
+ return nil unless name == "enter"
72
+
73
+ value = answer
74
+ @message = Ask.check(@validate, value)
75
+ return [self, nil] if @message
76
+
77
+ submit(value)
78
+ end
79
+
80
+ # What enter would submit now.
81
+ def answer
82
+ text = component.value.strip
83
+ text.empty? && !default.nil? ? default.to_s : text
84
+ end
85
+ end
86
+
87
+ module_function
88
+
89
+ def answered(shell, question, value)
90
+ "#{shell.symbol(:success)} #{question} #{shell.paint("·", :muted)} #{shell.paint(value, :accent)}"
91
+ end
92
+
93
+ # The validation message for `value`, or nil when it's fine (or there is no validator).
94
+ def check(validate, value)
95
+ return nil unless validate
96
+
97
+ result = validate.call(value)
98
+ result.is_a?(String) && !result.empty? ? result : nil
99
+ end
100
+
101
+ def ask_line(shell, question, default, validate)
102
+ hint = default.nil? || default.to_s.empty? ? "" : " (#{default})"
103
+ loop do
104
+ line = Prompt.read_line(shell, "#{question}#{hint} ")
105
+ raise Error, "no answer for #{question.inspect} (end of input); pass it as a command-line option, or run in a terminal" if line.nil? && default.nil?
106
+
107
+ value = line.to_s.strip
108
+ value = default.to_s if value.empty? && !default.nil?
109
+ message = check(validate, value)
110
+ if message.nil?
111
+ shell.err_puts("#{shell.symbol(:success)} #{question} · #{value}") unless shell.input_tty?
112
+ return value
113
+ end
114
+ raise Error, "invalid answer for #{question.inspect}: #{message} (got #{value.inspect})" if line.nil? || !shell.input_tty?
115
+
116
+ shell.err_puts("#{shell.symbol(:error)} #{message}")
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ extension :ask do
123
+ helpers do
124
+ def ask(question, default: nil, placeholder: nil, validate: nil)
125
+ return Ext::Ask.ask_line(shell, question, default, validate) unless shell.interactive?
126
+
127
+ model = Ext::Ask::Model.new(shell, question, default:, placeholder:, validate:)
128
+ Prompt.run(shell, model).value
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end