r2ui 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/README.md +79 -0
  4. data/lib/r2ui/app.rb +311 -34
  5. data/lib/r2ui/canvas.rb +135 -14
  6. data/lib/r2ui/cli/builder.rb +72 -0
  7. data/lib/r2ui/cli/context.rb +45 -0
  8. data/lib/r2ui/cli/definition.rb +174 -0
  9. data/lib/r2ui/cli/ext/ask.rb +133 -0
  10. data/lib/r2ui/cli/ext/box.rb +112 -0
  11. data/lib/r2ui/cli/ext/choose.rb +162 -0
  12. data/lib/r2ui/cli/ext/choose_many.rb +241 -0
  13. data/lib/r2ui/cli/ext/color_option.rb +64 -0
  14. data/lib/r2ui/cli/ext/completion.rb +520 -0
  15. data/lib/r2ui/cli/ext/config.rb +179 -0
  16. data/lib/r2ui/cli/ext/confirm.rb +110 -0
  17. data/lib/r2ui/cli/ext/dashboard.rb +105 -0
  18. data/lib/r2ui/cli/ext/diff.rb +195 -0
  19. data/lib/r2ui/cli/ext/editor.rb +94 -0
  20. data/lib/r2ui/cli/ext/env.rb +108 -0
  21. data/lib/r2ui/cli/ext/examples.rb +62 -0
  22. data/lib/r2ui/cli/ext/filter.rb +269 -0
  23. data/lib/r2ui/cli/ext/format.rb +104 -0
  24. data/lib/r2ui/cli/ext/list.rb +117 -0
  25. data/lib/r2ui/cli/ext/log.rb +89 -0
  26. data/lib/r2ui/cli/ext/pager.rb +97 -0
  27. data/lib/r2ui/cli/ext/pairs.rb +62 -0
  28. data/lib/r2ui/cli/ext/parallel.rb +274 -0
  29. data/lib/r2ui/cli/ext/password.rb +158 -0
  30. data/lib/r2ui/cli/ext/progress.rb +330 -0
  31. data/lib/r2ui/cli/ext/reference.rb +129 -0
  32. data/lib/r2ui/cli/ext/spinner.rb +165 -0
  33. data/lib/r2ui/cli/ext/summary.rb +63 -0
  34. data/lib/r2ui/cli/ext/table.rb +141 -0
  35. data/lib/r2ui/cli/ext/tasks.rb +216 -0
  36. data/lib/r2ui/cli/ext/trace.rb +64 -0
  37. data/lib/r2ui/cli/ext/tree.rb +114 -0
  38. data/lib/r2ui/cli/ext/version.rb +67 -0
  39. data/lib/r2ui/cli/extension.rb +129 -0
  40. data/lib/r2ui/cli/help.rb +115 -0
  41. data/lib/r2ui/cli/helpers.rb +20 -0
  42. data/lib/r2ui/cli/live.rb +150 -0
  43. data/lib/r2ui/cli/parser.rb +165 -0
  44. data/lib/r2ui/cli/prompt.rb +133 -0
  45. data/lib/r2ui/cli/runner.rb +86 -0
  46. data/lib/r2ui/cli/shell.rb +185 -0
  47. data/lib/r2ui/cli/testing.rb +49 -0
  48. data/lib/r2ui/cli.rb +129 -0
  49. data/lib/r2ui/compat/bubbletea/LICENSE-bubbletea.txt +21 -0
  50. data/lib/r2ui/compat/bubbletea/ansi.rb +424 -0
  51. data/lib/r2ui/compat/bubbletea/commands.rb +143 -0
  52. data/lib/r2ui/compat/bubbletea/input.rb +269 -0
  53. data/lib/r2ui/compat/bubbletea/input_reader.rb +66 -0
  54. data/lib/r2ui/compat/bubbletea/messages.rb +251 -0
  55. data/lib/r2ui/compat/bubbletea/model.rb +65 -0
  56. data/lib/r2ui/compat/bubbletea/native.rb +254 -0
  57. data/lib/r2ui/compat/bubbletea/renderer.rb +192 -0
  58. data/lib/r2ui/compat/bubbletea/runner.rb +425 -0
  59. data/lib/r2ui/compat/bubbletea/terminal.rb +230 -0
  60. data/lib/r2ui/compat/bubbletea/version.rb +8 -0
  61. data/lib/r2ui/compat/bubbletea/width_table.rb +228 -0
  62. data/lib/r2ui/compat/bubbletea.rb +16 -0
  63. data/lib/r2ui/compat/lipgloss/border.rb +17 -0
  64. data/lib/r2ui/compat/lipgloss/color.rb +78 -0
  65. data/lib/r2ui/compat/lipgloss/color_blend.rb +166 -0
  66. data/lib/r2ui/compat/lipgloss/colorful.rb +585 -0
  67. data/lib/r2ui/compat/lipgloss/layout.rb +461 -0
  68. data/lib/r2ui/compat/lipgloss/list.rb +85 -0
  69. data/lib/r2ui/compat/lipgloss/position.rb +30 -0
  70. data/lib/r2ui/compat/lipgloss/style/border.rb +134 -0
  71. data/lib/r2ui/compat/lipgloss/style.rb +1062 -0
  72. data/lib/r2ui/compat/lipgloss/table/render.rb +518 -0
  73. data/lib/r2ui/compat/lipgloss/table.rb +185 -0
  74. data/lib/r2ui/compat/lipgloss/termenv.rb +768 -0
  75. data/lib/r2ui/compat/lipgloss/text/unicode_tables.rb +428 -0
  76. data/lib/r2ui/compat/lipgloss/text.rb +1555 -0
  77. data/lib/r2ui/compat/lipgloss/tree/go_tree.rb +243 -0
  78. data/lib/r2ui/compat/lipgloss/tree.rb +105 -0
  79. data/lib/r2ui/compat/lipgloss/version.rb +15 -0
  80. data/lib/r2ui/compat/lipgloss.rb +64 -0
  81. data/lib/r2ui/compat/load_path/bubbletea.rb +4 -0
  82. data/lib/r2ui/compat/load_path/lipgloss.rb +4 -0
  83. data/lib/r2ui/component.rb +67 -0
  84. data/lib/r2ui/context.rb +87 -0
  85. data/lib/r2ui/drop_in.rb +16 -0
  86. data/lib/r2ui/dsl/column.rb +11 -3
  87. data/lib/r2ui/dsl/dashboard.rb +45 -9
  88. data/lib/r2ui/dsl/resource.rb +12 -0
  89. data/lib/r2ui/ext/after.rb +54 -0
  90. data/lib/r2ui/ext/async.rb +81 -0
  91. data/lib/r2ui/ext/batch_sequence.rb +54 -0
  92. data/lib/r2ui/ext/countdown.rb +52 -0
  93. data/lib/r2ui/ext/data_table.rb +71 -0
  94. data/lib/r2ui/ext/every.rb +66 -0
  95. data/lib/r2ui/ext/exec.rb +116 -0
  96. data/lib/r2ui/ext/file_picker.rb +78 -0
  97. data/lib/r2ui/ext/focus_report.rb +69 -0
  98. data/lib/r2ui/ext/help.rb +62 -0
  99. data/lib/r2ui/ext/keys.rb +64 -0
  100. data/lib/r2ui/ext/list.rb +145 -0
  101. data/lib/r2ui/ext/messages.rb +87 -0
  102. data/lib/r2ui/ext/mouse.rb +134 -0
  103. data/lib/r2ui/ext/paginator.rb +86 -0
  104. data/lib/r2ui/ext/paste.rb +86 -0
  105. data/lib/r2ui/ext/println.rb +21 -0
  106. data/lib/r2ui/ext/progress.rb +85 -0
  107. data/lib/r2ui/ext/refresh.rb +56 -0
  108. data/lib/r2ui/ext/resize.rb +62 -0
  109. data/lib/r2ui/ext/screen.rb +47 -0
  110. data/lib/r2ui/ext/screen_mode.rb +63 -0
  111. data/lib/r2ui/ext/spinner.rb +59 -0
  112. data/lib/r2ui/ext/state.rb +45 -0
  113. data/lib/r2ui/ext/stopwatch.rb +67 -0
  114. data/lib/r2ui/ext/suspend.rb +56 -0
  115. data/lib/r2ui/ext/text_area.rb +73 -0
  116. data/lib/r2ui/ext/text_input.rb +83 -0
  117. data/lib/r2ui/ext/theme.rb +112 -0
  118. data/lib/r2ui/ext/view.rb +37 -0
  119. data/lib/r2ui/ext/viewport.rb +92 -0
  120. data/lib/r2ui/ext/window_title.rb +51 -0
  121. data/lib/r2ui/extension.rb +199 -0
  122. data/lib/r2ui/history.rb +4 -1
  123. data/lib/r2ui/keys.rb +35 -8
  124. data/lib/r2ui/motion.rb +169 -0
  125. data/lib/r2ui/panel_state.rb +46 -2
  126. data/lib/r2ui/renderer.rb +87 -22
  127. data/lib/r2ui/version.rb +1 -1
  128. data/lib/r2ui/widgets/box.rb +6 -3
  129. data/lib/r2ui/widgets/glyphs.rb +149 -0
  130. data/lib/r2ui/widgets/table.rb +109 -25
  131. data/lib/r2ui.rb +12 -1
  132. metadata +119 -4
  133. data/lib/r2ui/terminal.rb +0 -53
@@ -0,0 +1,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