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
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c05-progress: a progress bar with rate and ETA.
|
|
4
|
+
#
|
|
5
|
+
# progress(total: 10_000_000, title: "Downloading", unit: :bytes) do |bar|
|
|
6
|
+
# download { |chunk| bar.advance(chunk.bytesize) } # or bar.current = n; bar.total = n
|
|
7
|
+
# end # => the block's value
|
|
8
|
+
#
|
|
9
|
+
# On a terminal (Shell#live?) one line redraws in place, the bar sized to the width and filled
|
|
10
|
+
# with a lipgloss colour blend:
|
|
11
|
+
#
|
|
12
|
+
# Downloading ━━━━━━━━━━━╸─────────────── 42% 4.2/10 MB · 1.3 MB/s · ETA 4s
|
|
13
|
+
#
|
|
14
|
+
# and resolves to "✔ Downloading 10 MB 7.6s" (or "✖ Downloading 4.2/10 MB 3.1s" when the block
|
|
15
|
+
# raises; the exception propagates, so a command exits 1, or 130 on ctrl+c). On a narrow terminal
|
|
16
|
+
# the ETA, rate and amount drop off before the bar gets too short. `total: nil` (or `bar.total =
|
|
17
|
+
# nil`) shows an indeterminate bar: a segment sweeping across, and the amount so far.
|
|
18
|
+
#
|
|
19
|
+
# Off a terminal it prints "Downloading 25%", "Downloading 50%" and "Downloading 75%" as the bar
|
|
20
|
+
# passes them, then the resolved line: no bar, no escape codes.
|
|
21
|
+
#
|
|
22
|
+
# `unit:` is :bytes (sizes like "4.2 MB", rates "1.3 MB/s"), a label ("files": "12/40 files",
|
|
23
|
+
# "3 files/s") or nil (plain counts). `say` inside the block prints above the bar.
|
|
24
|
+
module R2UI
|
|
25
|
+
module CLI
|
|
26
|
+
module Ext
|
|
27
|
+
module Progress
|
|
28
|
+
FILL = "━"
|
|
29
|
+
HALF = "╸"
|
|
30
|
+
TRACK = "─"
|
|
31
|
+
# bubbles progress's default gradient.
|
|
32
|
+
GRADIENT = %w[#5A56E0 #EE6FF8].freeze
|
|
33
|
+
MIN_BAR = 10
|
|
34
|
+
MAX_BAR = 50
|
|
35
|
+
MILESTONES = [25, 50, 75].freeze
|
|
36
|
+
RATE_WINDOW = 5.0 # seconds of samples the rate is measured over
|
|
37
|
+
BYTE_UNITS = %w[B kB MB GB TB PB].freeze
|
|
38
|
+
|
|
39
|
+
# What the block gets: move the bar.
|
|
40
|
+
class Handle
|
|
41
|
+
def initialize(bar) = @bar = bar
|
|
42
|
+
|
|
43
|
+
def advance(by = 1) = @bar.update { |b| b.current + by }
|
|
44
|
+
|
|
45
|
+
def current = @bar.current
|
|
46
|
+
|
|
47
|
+
def current=(value)
|
|
48
|
+
@bar.update { value }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def total = @bar.total
|
|
52
|
+
|
|
53
|
+
def total=(value)
|
|
54
|
+
@bar.set_total(value)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def title = @bar.title
|
|
58
|
+
|
|
59
|
+
def title=(text)
|
|
60
|
+
@bar.title = text.to_s
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# One bar: its numbers, how it draws on a terminal and what it prints off one.
|
|
65
|
+
class Bar
|
|
66
|
+
attr_reader :current, :total, :title
|
|
67
|
+
|
|
68
|
+
def initialize(shell, total:, title:, unit:)
|
|
69
|
+
@shell = shell
|
|
70
|
+
@total = Progress.check_total(total)
|
|
71
|
+
@title = title.to_s
|
|
72
|
+
@unit = unit
|
|
73
|
+
@current = 0
|
|
74
|
+
@status = :running
|
|
75
|
+
@lock = Monitor.new
|
|
76
|
+
@milestone = 0
|
|
77
|
+
@styles = {}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def title=(text)
|
|
81
|
+
@lock.synchronize { @title = text }
|
|
82
|
+
@live&.refresh
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def set_total(value)
|
|
86
|
+
@lock.synchronize { @total = Progress.check_total(value) }
|
|
87
|
+
changed
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def update
|
|
91
|
+
value = yield self
|
|
92
|
+
raise ArgumentError, "progress needs a number, got #{value.inspect}" unless value.is_a?(Numeric)
|
|
93
|
+
|
|
94
|
+
@lock.synchronize do
|
|
95
|
+
@current = value
|
|
96
|
+
sample
|
|
97
|
+
end
|
|
98
|
+
changed
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Runs the block with the bar drawing (on a terminal) and returns its value.
|
|
102
|
+
def run(&block)
|
|
103
|
+
@started = now
|
|
104
|
+
@samples = [[@started, @current]]
|
|
105
|
+
# Inside another live region (a tasks step) there is one region per shell: print plain.
|
|
106
|
+
@live = Live.new(@shell) { |frame| view(frame) } unless @shell.live
|
|
107
|
+
body = lambda do
|
|
108
|
+
value = block.call(Handle.new(self))
|
|
109
|
+
finish(:done)
|
|
110
|
+
value
|
|
111
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- ctrl+c and halt end the bar too
|
|
112
|
+
finish(e.is_a?(Halt) ? :done : :failed)
|
|
113
|
+
raise
|
|
114
|
+
end
|
|
115
|
+
@live ? @live.run { body.call } : body.call
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def view(frame)
|
|
119
|
+
@lock.synchronize { @status == :running ? running_line(frame) : resolved_line }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def drawing? = @live&.running?
|
|
125
|
+
|
|
126
|
+
def finish(status)
|
|
127
|
+
@lock.synchronize do
|
|
128
|
+
@status = status
|
|
129
|
+
@finished = now
|
|
130
|
+
end
|
|
131
|
+
@shell.puts(resolved_line) unless drawing?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Off a terminal: the milestones passed. On one: redraw when the percent changes.
|
|
135
|
+
def changed
|
|
136
|
+
percent = @lock.synchronize { self.percent }
|
|
137
|
+
if drawing?
|
|
138
|
+
@live.refresh if percent != @drawn_percent
|
|
139
|
+
@drawn_percent = percent
|
|
140
|
+
elsif percent && @total.positive?
|
|
141
|
+
MILESTONES.each do |m|
|
|
142
|
+
next if m <= @milestone || percent < m
|
|
143
|
+
|
|
144
|
+
@milestone = m
|
|
145
|
+
@shell.puts("#{@title} #{m}%")
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def ratio
|
|
151
|
+
return nil if @total.nil?
|
|
152
|
+
return 1.0 if @total.zero?
|
|
153
|
+
|
|
154
|
+
(@current.to_f / @total).clamp(0.0, 1.0)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Whole percent done, rounded down in exact arithmetic (100 only when complete).
|
|
158
|
+
def percent
|
|
159
|
+
return nil if @total.nil?
|
|
160
|
+
return 100 if @total.zero?
|
|
161
|
+
|
|
162
|
+
(@current.to_r * 100 / @total).floor.clamp(0, 100)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Keeps a few seconds of [time, current] samples (at most ten a second) for the rate.
|
|
166
|
+
def sample
|
|
167
|
+
t = now
|
|
168
|
+
@samples << [t, @current] if t - @samples.last[0] >= 0.1
|
|
169
|
+
@samples.shift while @samples.size > 1 && t - @samples[1][0] > RATE_WINDOW
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def rate
|
|
173
|
+
t0, c0 = @samples.first
|
|
174
|
+
span = now - t0
|
|
175
|
+
return nil if span < 0.2
|
|
176
|
+
|
|
177
|
+
per_second = (@current - c0) / span
|
|
178
|
+
per_second.positive? ? per_second : nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def running_line(frame)
|
|
182
|
+
r = ratio
|
|
183
|
+
percent = r && "#{self.percent}%"
|
|
184
|
+
per_second = rate
|
|
185
|
+
parts = [amount(with_total: true)]
|
|
186
|
+
parts << "#{Progress.amount(per_second, @unit)}/s" if per_second
|
|
187
|
+
parts << "ETA #{Progress.eta((@total - @current) / per_second)}" if per_second && r && r < 1
|
|
188
|
+
|
|
189
|
+
# Drop the ETA, then the rate, then the amount before the bar gets shorter than MIN_BAR.
|
|
190
|
+
loop do
|
|
191
|
+
tail = [percent, parts.join(" · ")].reject { |s| s.nil? || s.empty? }.join(" ")
|
|
192
|
+
# Spaces around the bar, and the last column left free so the line never wraps.
|
|
193
|
+
used = Lipgloss.width(@title) + (tail.empty? ? 0 : Lipgloss.width(tail) + 1) + 2
|
|
194
|
+
width = @shell.width - used
|
|
195
|
+
if width >= MIN_BAR || parts.empty?
|
|
196
|
+
bar = width.positive? ? bar(steady(width.clamp(1, MAX_BAR)), r, frame) : nil
|
|
197
|
+
info = parts.empty? ? nil : @shell.paint(parts.join(" · "), :muted)
|
|
198
|
+
return [@title, bar, percent, info].compact.join(" ")
|
|
199
|
+
end
|
|
200
|
+
parts.pop
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# The bar only shrinks while the terminal keeps its width, so an ETA going from "10s" to
|
|
205
|
+
# "9s" doesn't make it twitch.
|
|
206
|
+
def steady(width)
|
|
207
|
+
@steady = nil if @steady && @steady[0] != @shell.width
|
|
208
|
+
@steady = [@shell.width, [width, @steady&.last || width].min]
|
|
209
|
+
@steady.last
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def resolved_line
|
|
213
|
+
time = @shell.paint(Progress.duration(@finished - @started), :muted)
|
|
214
|
+
if @status == :failed
|
|
215
|
+
"#{@shell.symbol(:error)} #{@title} #{amount(with_total: true)} #{time}"
|
|
216
|
+
else
|
|
217
|
+
"#{@shell.symbol(:success)} #{@title} #{amount(with_total: false)} #{time}"
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def amount(with_total:)
|
|
222
|
+
return Progress.amount(@current, @unit) unless with_total && @total
|
|
223
|
+
|
|
224
|
+
Progress.amount_of(@current, @total, @unit)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def bar(width, r, frame)
|
|
228
|
+
colors = (@gradients ||= {})[width] ||= Lipgloss::ColorBlend.blends(*GRADIENT, width)
|
|
229
|
+
cells = Array.new(width) { [TRACK, nil] }
|
|
230
|
+
if r
|
|
231
|
+
halves = (r * width * 2).floor
|
|
232
|
+
(halves / 2).times { |i| cells[i] = [FILL, colors[i]] }
|
|
233
|
+
cells[halves / 2] = [HALF, colors[halves / 2]] if halves.odd?
|
|
234
|
+
else
|
|
235
|
+
segment = (width / 4).clamp(3, 12).clamp(1, width)
|
|
236
|
+
span = width - segment
|
|
237
|
+
pos = span.zero? ? 0 : frame % (2 * span)
|
|
238
|
+
pos = (2 * span) - pos if pos > span
|
|
239
|
+
segment.times { |i| cells[pos + i] = [FILL, colors[pos + i]] }
|
|
240
|
+
end
|
|
241
|
+
cells.chunk { |glyph, color| [glyph == TRACK, color] }.map do |(track, color), run|
|
|
242
|
+
text = run.map(&:first).join
|
|
243
|
+
track ? @shell.paint(text, :muted) : paint_color(text, color)
|
|
244
|
+
end.join
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def paint_color(text, color)
|
|
248
|
+
return text unless @shell.color?
|
|
249
|
+
|
|
250
|
+
(@styles[color] ||= Lipgloss::Style.new.foreground(color)).render(text)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
module_function
|
|
257
|
+
|
|
258
|
+
def check_total(total)
|
|
259
|
+
return nil if total.nil?
|
|
260
|
+
raise ArgumentError, "progress total must be a number, got #{total.inspect}" unless total.is_a?(Numeric)
|
|
261
|
+
raise ArgumentError, "progress total can't be negative" if total.negative?
|
|
262
|
+
|
|
263
|
+
total
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# 4_200_000, :bytes → "4.2 MB"; 12, "files" → "12 files"; 12, nil → "12".
|
|
267
|
+
def amount(n, unit)
|
|
268
|
+
return "#{number(n / (1000.0**byte_exponent(n)), byte_exponent(n))} #{BYTE_UNITS[byte_exponent(n)]}" if unit == :bytes
|
|
269
|
+
|
|
270
|
+
[number(n, 0), unit].compact.join(" ")
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# 4_200_000 of 10_000_000, :bytes → "4.2/10 MB" (in the total's unit); 3, 10 → "3/10".
|
|
274
|
+
def amount_of(n, total, unit)
|
|
275
|
+
if unit == :bytes
|
|
276
|
+
e = byte_exponent(total)
|
|
277
|
+
scale = 1000.0**e
|
|
278
|
+
return "#{number(n / scale, e)}/#{number(total / scale, e)} #{BYTE_UNITS[e]}"
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
["#{number(n, 0)}/#{number(total, 0)}", unit].compact.join(" ")
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def byte_exponent(n)
|
|
285
|
+
e = 0
|
|
286
|
+
e += 1 while n.abs >= 1000**(e + 1) && e < BYTE_UNITS.size - 1
|
|
287
|
+
e
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Whole numbers stay whole; scaled ones and fractions under 10 keep one decimal.
|
|
291
|
+
def number(value, exponent)
|
|
292
|
+
return value.to_s if value.is_a?(Integer)
|
|
293
|
+
return value.round.to_s if value.abs >= 10 || (exponent.zero? && value == value.round)
|
|
294
|
+
|
|
295
|
+
format("%.1f", value).delete_suffix(".0")
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Seconds left: "4s", "1m 5s", "2h 3m".
|
|
299
|
+
def eta(seconds)
|
|
300
|
+
s = seconds.ceil
|
|
301
|
+
return "#{s}s" if s < 60
|
|
302
|
+
return "#{s / 60}m #{s % 60}s" if s < 3600
|
|
303
|
+
|
|
304
|
+
"#{s / 3600}h #{(s % 3600) / 60}m"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# 0.12 → "120ms", 3.456 → "3.5s", 75 → "1m 15s" (the tasks story's format).
|
|
308
|
+
def duration(seconds)
|
|
309
|
+
return "#{(seconds * 1000).round}ms" if (seconds * 1000).round < 1000
|
|
310
|
+
|
|
311
|
+
tenths = (seconds * 10).round
|
|
312
|
+
return "#{format("%.1f", tenths / 10.0)}s" if tenths < 600
|
|
313
|
+
|
|
314
|
+
total = seconds.round
|
|
315
|
+
"#{total / 60}m #{total % 60}s"
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
extension :progress do
|
|
321
|
+
helpers do
|
|
322
|
+
def progress(total: nil, title: "Working", unit: nil, &block)
|
|
323
|
+
raise ArgumentError, "progress needs a block" unless block
|
|
324
|
+
|
|
325
|
+
Ext::Progress::Bar.new(shell, total:, title:, unit:).run(&block)
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c30-reference: a Markdown reference for the whole tool, for a README or a docs site.
|
|
4
|
+
#
|
|
5
|
+
# R2UI.cli "deployer" do
|
|
6
|
+
# reference_command # adds a hidden `deployer reference`
|
|
7
|
+
# # reference_command :docs # ... or under another name
|
|
8
|
+
# command(:deploy, "Deploy an app") { argument :app; option :env, default: "staging"; run { ... } }
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# deployer reference > docs/cli-reference.md
|
|
12
|
+
#
|
|
13
|
+
# prints, for the root and every visible command under it (depth first, in definition order), a
|
|
14
|
+
# heading by its path (`#` for the root, `##` one level down, ...), its summary and description,
|
|
15
|
+
# its aliases, its usage in a code block, and Markdown tables of its arguments and its options
|
|
16
|
+
# (inherited ones too, as --help shows them; hidden commands and options are left out, and so is
|
|
17
|
+
# the reference command itself):
|
|
18
|
+
#
|
|
19
|
+
# ## deployer deploy
|
|
20
|
+
#
|
|
21
|
+
# Deploy an app
|
|
22
|
+
#
|
|
23
|
+
# ```
|
|
24
|
+
# deployer deploy <app> [options]
|
|
25
|
+
# ```
|
|
26
|
+
#
|
|
27
|
+
# | Option | Description |
|
|
28
|
+
# |---|---|
|
|
29
|
+
# | `--env <env>` | (default: staging) |
|
|
30
|
+
#
|
|
31
|
+
# The text depends only on the definition, so it's the same on every run and in every
|
|
32
|
+
# environment: piped or redirected it is plain Markdown. On a terminal with colour the same text
|
|
33
|
+
# has its headings and names styled for reading; strip the escapes and it's byte-identical.
|
|
34
|
+
module R2UI
|
|
35
|
+
module CLI
|
|
36
|
+
module Ext
|
|
37
|
+
module Reference
|
|
38
|
+
# Help#usage paints through a shell; this one leaves the text plain.
|
|
39
|
+
PLAIN = Object.new
|
|
40
|
+
def PLAIN.paint(text, *) = text.to_s
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
def markdown(program, shell, skip: nil)
|
|
45
|
+
visible(program, skip).map { |command| section(command, shell) }.join("\n")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The root and its visible subtree, depth first.
|
|
49
|
+
def visible(command, skip)
|
|
50
|
+
children = command.commands.reject { |c| c.hidden || c.equal?(skip) }
|
|
51
|
+
[command, *children.flat_map { |c| visible(c, skip) }]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def section(command, shell)
|
|
55
|
+
level = [command.path.size, 6].min
|
|
56
|
+
blocks = [shell.paint("#{"#" * level} #{command.full_name}", :heading)]
|
|
57
|
+
blocks << inline(command.summary) if present?(command.summary)
|
|
58
|
+
blocks << command.description.to_s.strip if present?(command.description) && command.description != command.summary
|
|
59
|
+
blocks << "Aliases: #{command.aliases.map { |a| code(a, shell) }.join(", ")}" if command.aliases.any?
|
|
60
|
+
blocks << "```\n#{shell.paint(Help.new(command, PLAIN).usage, :accent)}\n```"
|
|
61
|
+
blocks << table("Argument", command.arguments.map { |a| [a.name.to_s.tr("_", "-"), argument_text(a)] }, shell)
|
|
62
|
+
blocks << table("Option", command.all_options.reject(&:hidden).map { |o| [label(o), option_text(o)] }, shell)
|
|
63
|
+
"#{blocks.compact.join("\n\n")}\n"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def table(heading, rows, shell)
|
|
67
|
+
return nil if rows.empty?
|
|
68
|
+
|
|
69
|
+
lines = ["| #{heading} | Description |", "|---|---|"]
|
|
70
|
+
rows.each { |name, text| lines << "| #{code(name, shell)} | #{cell(text)} |" }
|
|
71
|
+
lines.join("\n")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def label(option)
|
|
75
|
+
long = option.boolean? && option.default == true ? "--[no-]#{option.long.delete_prefix("--")}" : option.long
|
|
76
|
+
long += " <#{option.value_name}>" unless option.boolean?
|
|
77
|
+
long += "..." if option.many
|
|
78
|
+
option.short ? "-#{option.short}, #{long}" : long
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def option_text(option)
|
|
82
|
+
notes = []
|
|
83
|
+
notes << option.choices.join(", ") if option.choices
|
|
84
|
+
notes << "required" if option.required
|
|
85
|
+
default = option.default
|
|
86
|
+
notes << "default: #{default}" if !default.nil? && !default.respond_to?(:call) && default != false && default != []
|
|
87
|
+
[option.desc, notes.empty? ? nil : "(#{notes.join("; ")})"].compact.join(" ")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def argument_text(argument)
|
|
91
|
+
notes = []
|
|
92
|
+
notes << "default: #{argument.default}" unless argument.default.nil?
|
|
93
|
+
notes << "optional" unless argument.required
|
|
94
|
+
notes << "one or more" if argument.many && argument.required
|
|
95
|
+
[argument.desc, notes.empty? ? nil : "(#{notes.join("; ")})"].compact.join(" ")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def code(text, shell) = shell.paint("`#{text}`", :accent)
|
|
99
|
+
|
|
100
|
+
# One table cell: one line, pipes escaped.
|
|
101
|
+
def cell(text) = inline(text).gsub("|", "\\|")
|
|
102
|
+
|
|
103
|
+
def inline(text) = text.to_s.split.join(" ")
|
|
104
|
+
|
|
105
|
+
def present?(text) = !text.nil? && !text.to_s.strip.empty?
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
extension :reference do
|
|
110
|
+
dsl(:command) do
|
|
111
|
+
# Adds a hidden command (`reference` unless named) printing the tool's Markdown reference.
|
|
112
|
+
def reference_command(name = :reference)
|
|
113
|
+
raise ArgumentError, "reference_command goes on the root, not on #{definition.full_name}" unless definition.root?
|
|
114
|
+
|
|
115
|
+
declare(:reference_command, name.to_sym)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
setup do
|
|
120
|
+
name = declared(:reference_command).last
|
|
121
|
+
if name
|
|
122
|
+
command(name, "Print a Markdown reference for every command", hidden: true) do
|
|
123
|
+
run { shell.print(Ext::Reference.markdown(program, shell, skip: command)) }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c04-spinner: one spinner line while some work runs (ora, gum spin), resolving to a ✔ / ✖ line.
|
|
4
|
+
#
|
|
5
|
+
# spin("Installing", done: "Installed") do |s|
|
|
6
|
+
# packages.each_with_index { |pkg, i| s.text = "Installing #{pkg} (#{i + 1}/#{packages.size})"; install(pkg) }
|
|
7
|
+
# end
|
|
8
|
+
#
|
|
9
|
+
# files = spin("Scanning", done: ->(found) { "Found #{found.size} files" }) { scan }
|
|
10
|
+
# spin("Checking for updates", clear: true) { check } # vanishes when it succeeds
|
|
11
|
+
#
|
|
12
|
+
# On a terminal (Shell#live?) it draws one line, redrawn in place, the cursor hidden meanwhile:
|
|
13
|
+
#
|
|
14
|
+
# ⠹ Installing react (3/12)
|
|
15
|
+
#
|
|
16
|
+
# and resolves to
|
|
17
|
+
#
|
|
18
|
+
# ✔ Installed 1.2s
|
|
19
|
+
#
|
|
20
|
+
# The block's handle sets the text shown while it runs (`s.text = "..."`). `done:` is the
|
|
21
|
+
# resolved text: a String, or a lambda given the block's value; it defaults to the title. When the
|
|
22
|
+
# block raises, the line resolves to "✖ Installing" (the title) and the exception propagates (a
|
|
23
|
+
# command then exits 1 with "✖ message"; ctrl+c exits 130). `clear: true` erases the line when the
|
|
24
|
+
# block succeeds and prints nothing (a failure still leaves its ✖ line). `say` inside prints
|
|
25
|
+
# above the spinner. `spin` returns the block's value.
|
|
26
|
+
#
|
|
27
|
+
# Off a terminal (a pipe, CI, TERM=dumb) it prints only the resolved line ("✔ Installed 1.2s"),
|
|
28
|
+
# nothing while it runs: no spinner frames, no escape codes.
|
|
29
|
+
#
|
|
30
|
+
# Inside another live region (a running `tasks` step or another `spin`) the inner spin doesn't
|
|
31
|
+
# draw a second region: its resolved line prints above the outer one.
|
|
32
|
+
module R2UI
|
|
33
|
+
module CLI
|
|
34
|
+
module Ext
|
|
35
|
+
module Spin
|
|
36
|
+
# What the block gets: change the text shown beside the spinner.
|
|
37
|
+
class Handle
|
|
38
|
+
def initialize(line)
|
|
39
|
+
@line = line
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def text = @line.text
|
|
43
|
+
|
|
44
|
+
def text=(value)
|
|
45
|
+
@line.text = value.to_s
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# One spinner line: what it shows, how it runs and how it resolves.
|
|
50
|
+
class Line
|
|
51
|
+
def initialize(shell, title, done:, clear:)
|
|
52
|
+
@shell = shell
|
|
53
|
+
@title = title.to_s
|
|
54
|
+
@done = done
|
|
55
|
+
@clear = clear
|
|
56
|
+
@text = @title
|
|
57
|
+
@status = :running
|
|
58
|
+
@lock = Monitor.new
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def text = @lock.synchronize { @text }
|
|
62
|
+
|
|
63
|
+
def text=(value)
|
|
64
|
+
@lock.synchronize { @text = value }
|
|
65
|
+
@live&.refresh
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Runs the block; returns its value, or re-raises its exception once the line resolved.
|
|
69
|
+
def run(block)
|
|
70
|
+
@started = now
|
|
71
|
+
if @shell.live? && @shell.live.nil?
|
|
72
|
+
@live = Live.new(@shell) { |frame| view(frame) }
|
|
73
|
+
@live.run { call(block) }
|
|
74
|
+
else
|
|
75
|
+
call(block)
|
|
76
|
+
end
|
|
77
|
+
raise @error if @error
|
|
78
|
+
|
|
79
|
+
@value
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def call(block)
|
|
85
|
+
begin
|
|
86
|
+
@value = block.call(Handle.new(self))
|
|
87
|
+
rescue Exception => e # rubocop:disable Lint/RescueException -- ctrl+c and halt resolve the line too
|
|
88
|
+
@error = e
|
|
89
|
+
end
|
|
90
|
+
resolve(@error && !@error.is_a?(Halt) ? :failed : :done)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def resolve(status)
|
|
94
|
+
elapsed = now - @started
|
|
95
|
+
text = @title
|
|
96
|
+
begin
|
|
97
|
+
text = done_text if status == :done
|
|
98
|
+
rescue StandardError => e # a `done:` lambda that raises fails the line with its error
|
|
99
|
+
status = :failed
|
|
100
|
+
@error = e
|
|
101
|
+
end
|
|
102
|
+
@lock.synchronize do
|
|
103
|
+
@status = status
|
|
104
|
+
@elapsed = elapsed
|
|
105
|
+
@resolved = text
|
|
106
|
+
end
|
|
107
|
+
cleared = @clear && status == :done
|
|
108
|
+
if @live&.running?
|
|
109
|
+
@live.clear = cleared
|
|
110
|
+
@live.refresh unless cleared
|
|
111
|
+
elsif !cleared
|
|
112
|
+
@shell.puts(view(0))
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def view(frame)
|
|
117
|
+
@lock.synchronize do
|
|
118
|
+
case @status
|
|
119
|
+
when :running then "#{@shell.paint(Live.spinner(frame), :running)} #{@text}"
|
|
120
|
+
when :done
|
|
121
|
+
"#{@shell.symbol(:success)} #{@resolved} #{@shell.paint(Spin.duration(@elapsed), :muted)}"
|
|
122
|
+
when :failed then "#{@shell.symbol(:error)} #{@resolved}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The ✔ text: `done:` as given, or its lambda's result for the block's value (the title
|
|
128
|
+
# when a halt left no value).
|
|
129
|
+
def done_text
|
|
130
|
+
case @done
|
|
131
|
+
when nil then @title
|
|
132
|
+
when Proc then @error ? @title : @done.call(@value).to_s
|
|
133
|
+
else @done.to_s
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
module_function
|
|
141
|
+
|
|
142
|
+
# 0.12 → "120ms", 3.456 → "3.5s", 75 → "1m 15s" (the same format as the task list's).
|
|
143
|
+
def duration(seconds)
|
|
144
|
+
return "#{(seconds * 1000).round}ms" if (seconds * 1000).round < 1000
|
|
145
|
+
|
|
146
|
+
tenths = (seconds * 10).round
|
|
147
|
+
return "#{format("%.1f", tenths / 10.0)}s" if tenths < 600
|
|
148
|
+
|
|
149
|
+
total = seconds.round
|
|
150
|
+
"#{total / 60}m #{total % 60}s"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
extension :spinner do
|
|
156
|
+
helpers do
|
|
157
|
+
def spin(title, done: nil, clear: false, &block)
|
|
158
|
+
raise ArgumentError, "spin needs a block" unless block
|
|
159
|
+
|
|
160
|
+
Ext::Spin::Line.new(shell, title, done:, clear:).run(block)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c15-summary: a header line to open a run and an npm-style closing line that says how long it
|
|
4
|
+
# took.
|
|
5
|
+
#
|
|
6
|
+
# heading "deployer v1.4.0" # bold line, then a blank line
|
|
7
|
+
# tasks { ... }
|
|
8
|
+
# done "Deployed 3 apps" # ✔ Deployed 3 apps in 4.2s
|
|
9
|
+
# done # Done in 4.2s (pnpm's closing line)
|
|
10
|
+
#
|
|
11
|
+
# On a terminal the heading is bold, the ✔ green and " in 4.2s" muted. Off a terminal (pipe, CI)
|
|
12
|
+
# the same text with no escape codes. Both write to stdout; inside a live region (`tasks`, ...)
|
|
13
|
+
# they print above it.
|
|
14
|
+
#
|
|
15
|
+
# The time counts from the start of the command (just after its argv is parsed; each run starts
|
|
16
|
+
# its own clock). In a script that includes R2UI::CLI::Helpers it counts from when `r2ui/cli`
|
|
17
|
+
# was loaded, so everything the script did before its first helper call is in it too. The format
|
|
18
|
+
# is the task list's: "120ms", "4.2s", "1m 15s".
|
|
19
|
+
module R2UI
|
|
20
|
+
module CLI
|
|
21
|
+
module Ext
|
|
22
|
+
module Summary
|
|
23
|
+
LOADED_AT = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
24
|
+
|
|
25
|
+
# When the command running on each shell started (shells a command never ran on use
|
|
26
|
+
# LOADED_AT).
|
|
27
|
+
@started = ObjectSpace::WeakMap.new
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
32
|
+
|
|
33
|
+
def start(shell)
|
|
34
|
+
@started[shell] = now
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def elapsed(shell) = now - (@started[shell] || LOADED_AT)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
extension :summary do
|
|
42
|
+
after_parse { Ext::Summary.start(shell) }
|
|
43
|
+
|
|
44
|
+
helpers do
|
|
45
|
+
def heading(text)
|
|
46
|
+
shell.puts(shell.paint(text, :heading))
|
|
47
|
+
shell.puts
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def done(message = nil)
|
|
52
|
+
time = Ext::Tasks.duration(Ext::Summary.elapsed(shell))
|
|
53
|
+
if message.nil?
|
|
54
|
+
shell.puts("Done#{shell.paint(" in #{time}", :muted)}")
|
|
55
|
+
else
|
|
56
|
+
shell.puts("#{shell.symbol(:success)} #{message}#{shell.paint(" in #{time}", :muted)}")
|
|
57
|
+
end
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|