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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +64 -0
- data/README.md +79 -0
- data/lib/r2ui/app.rb +311 -34
- data/lib/r2ui/canvas.rb +135 -14
- data/lib/r2ui/cli/builder.rb +72 -0
- data/lib/r2ui/cli/context.rb +45 -0
- data/lib/r2ui/cli/definition.rb +174 -0
- data/lib/r2ui/cli/ext/ask.rb +133 -0
- data/lib/r2ui/cli/ext/box.rb +112 -0
- data/lib/r2ui/cli/ext/choose.rb +162 -0
- data/lib/r2ui/cli/ext/choose_many.rb +241 -0
- data/lib/r2ui/cli/ext/color_option.rb +64 -0
- data/lib/r2ui/cli/ext/completion.rb +520 -0
- data/lib/r2ui/cli/ext/config.rb +179 -0
- data/lib/r2ui/cli/ext/confirm.rb +110 -0
- data/lib/r2ui/cli/ext/dashboard.rb +105 -0
- data/lib/r2ui/cli/ext/diff.rb +195 -0
- data/lib/r2ui/cli/ext/editor.rb +94 -0
- data/lib/r2ui/cli/ext/env.rb +108 -0
- data/lib/r2ui/cli/ext/examples.rb +62 -0
- data/lib/r2ui/cli/ext/filter.rb +269 -0
- data/lib/r2ui/cli/ext/format.rb +104 -0
- data/lib/r2ui/cli/ext/list.rb +117 -0
- data/lib/r2ui/cli/ext/log.rb +89 -0
- data/lib/r2ui/cli/ext/pager.rb +97 -0
- data/lib/r2ui/cli/ext/pairs.rb +62 -0
- data/lib/r2ui/cli/ext/parallel.rb +274 -0
- data/lib/r2ui/cli/ext/password.rb +158 -0
- data/lib/r2ui/cli/ext/progress.rb +330 -0
- data/lib/r2ui/cli/ext/reference.rb +129 -0
- data/lib/r2ui/cli/ext/spinner.rb +165 -0
- data/lib/r2ui/cli/ext/summary.rb +63 -0
- data/lib/r2ui/cli/ext/table.rb +141 -0
- data/lib/r2ui/cli/ext/tasks.rb +216 -0
- data/lib/r2ui/cli/ext/trace.rb +64 -0
- data/lib/r2ui/cli/ext/tree.rb +114 -0
- data/lib/r2ui/cli/ext/version.rb +67 -0
- data/lib/r2ui/cli/extension.rb +129 -0
- data/lib/r2ui/cli/help.rb +115 -0
- data/lib/r2ui/cli/helpers.rb +20 -0
- data/lib/r2ui/cli/live.rb +150 -0
- data/lib/r2ui/cli/parser.rb +165 -0
- data/lib/r2ui/cli/prompt.rb +133 -0
- data/lib/r2ui/cli/runner.rb +86 -0
- data/lib/r2ui/cli/shell.rb +185 -0
- data/lib/r2ui/cli/testing.rb +49 -0
- data/lib/r2ui/cli.rb +129 -0
- data/lib/r2ui/compat/bubbletea/LICENSE-bubbletea.txt +21 -0
- data/lib/r2ui/compat/bubbletea/ansi.rb +424 -0
- data/lib/r2ui/compat/bubbletea/commands.rb +143 -0
- data/lib/r2ui/compat/bubbletea/input.rb +269 -0
- data/lib/r2ui/compat/bubbletea/input_reader.rb +66 -0
- data/lib/r2ui/compat/bubbletea/messages.rb +251 -0
- data/lib/r2ui/compat/bubbletea/model.rb +65 -0
- data/lib/r2ui/compat/bubbletea/native.rb +254 -0
- data/lib/r2ui/compat/bubbletea/renderer.rb +192 -0
- data/lib/r2ui/compat/bubbletea/runner.rb +425 -0
- data/lib/r2ui/compat/bubbletea/terminal.rb +230 -0
- data/lib/r2ui/compat/bubbletea/version.rb +8 -0
- data/lib/r2ui/compat/bubbletea/width_table.rb +228 -0
- data/lib/r2ui/compat/bubbletea.rb +16 -0
- data/lib/r2ui/compat/lipgloss/border.rb +17 -0
- data/lib/r2ui/compat/lipgloss/color.rb +78 -0
- data/lib/r2ui/compat/lipgloss/color_blend.rb +166 -0
- data/lib/r2ui/compat/lipgloss/colorful.rb +585 -0
- data/lib/r2ui/compat/lipgloss/layout.rb +461 -0
- data/lib/r2ui/compat/lipgloss/list.rb +85 -0
- data/lib/r2ui/compat/lipgloss/position.rb +30 -0
- data/lib/r2ui/compat/lipgloss/style/border.rb +134 -0
- data/lib/r2ui/compat/lipgloss/style.rb +1062 -0
- data/lib/r2ui/compat/lipgloss/table/render.rb +518 -0
- data/lib/r2ui/compat/lipgloss/table.rb +185 -0
- data/lib/r2ui/compat/lipgloss/termenv.rb +768 -0
- data/lib/r2ui/compat/lipgloss/text/unicode_tables.rb +428 -0
- data/lib/r2ui/compat/lipgloss/text.rb +1555 -0
- data/lib/r2ui/compat/lipgloss/tree/go_tree.rb +243 -0
- data/lib/r2ui/compat/lipgloss/tree.rb +105 -0
- data/lib/r2ui/compat/lipgloss/version.rb +15 -0
- data/lib/r2ui/compat/lipgloss.rb +64 -0
- data/lib/r2ui/compat/load_path/bubbletea.rb +4 -0
- data/lib/r2ui/compat/load_path/lipgloss.rb +4 -0
- data/lib/r2ui/component.rb +67 -0
- data/lib/r2ui/context.rb +87 -0
- data/lib/r2ui/drop_in.rb +16 -0
- data/lib/r2ui/dsl/column.rb +11 -3
- data/lib/r2ui/dsl/dashboard.rb +45 -9
- data/lib/r2ui/dsl/resource.rb +12 -0
- data/lib/r2ui/ext/after.rb +54 -0
- data/lib/r2ui/ext/async.rb +81 -0
- data/lib/r2ui/ext/batch_sequence.rb +54 -0
- data/lib/r2ui/ext/countdown.rb +52 -0
- data/lib/r2ui/ext/data_table.rb +71 -0
- data/lib/r2ui/ext/every.rb +66 -0
- data/lib/r2ui/ext/exec.rb +116 -0
- data/lib/r2ui/ext/file_picker.rb +78 -0
- data/lib/r2ui/ext/focus_report.rb +69 -0
- data/lib/r2ui/ext/help.rb +62 -0
- data/lib/r2ui/ext/keys.rb +64 -0
- data/lib/r2ui/ext/list.rb +145 -0
- data/lib/r2ui/ext/messages.rb +87 -0
- data/lib/r2ui/ext/mouse.rb +134 -0
- data/lib/r2ui/ext/paginator.rb +86 -0
- data/lib/r2ui/ext/paste.rb +86 -0
- data/lib/r2ui/ext/println.rb +21 -0
- data/lib/r2ui/ext/progress.rb +85 -0
- data/lib/r2ui/ext/refresh.rb +56 -0
- data/lib/r2ui/ext/resize.rb +62 -0
- data/lib/r2ui/ext/screen.rb +47 -0
- data/lib/r2ui/ext/screen_mode.rb +63 -0
- data/lib/r2ui/ext/spinner.rb +59 -0
- data/lib/r2ui/ext/state.rb +45 -0
- data/lib/r2ui/ext/stopwatch.rb +67 -0
- data/lib/r2ui/ext/suspend.rb +56 -0
- data/lib/r2ui/ext/text_area.rb +73 -0
- data/lib/r2ui/ext/text_input.rb +83 -0
- data/lib/r2ui/ext/theme.rb +112 -0
- data/lib/r2ui/ext/view.rb +37 -0
- data/lib/r2ui/ext/viewport.rb +92 -0
- data/lib/r2ui/ext/window_title.rb +51 -0
- data/lib/r2ui/extension.rb +199 -0
- data/lib/r2ui/history.rb +4 -1
- data/lib/r2ui/keys.rb +35 -8
- data/lib/r2ui/motion.rb +169 -0
- data/lib/r2ui/panel_state.rb +46 -2
- data/lib/r2ui/renderer.rb +87 -22
- data/lib/r2ui/version.rb +1 -1
- data/lib/r2ui/widgets/box.rb +6 -3
- data/lib/r2ui/widgets/glyphs.rb +149 -0
- data/lib/r2ui/widgets/table.rb +109 -25
- data/lib/r2ui.rb +12 -1
- metadata +119 -4
- 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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
|
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.
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 <<
|
|
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
|