dry-cli-ui 0.1.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 +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +248 -0
- data/SPECIFICATION.md +322 -0
- data/lib/dry/cli/ui/console.rb +223 -0
- data/lib/dry/cli/ui/duration.rb +30 -0
- data/lib/dry/cli/ui/terminal.rb +114 -0
- data/lib/dry/cli/ui/theme.rb +53 -0
- data/lib/dry/cli/ui/version.rb +17 -0
- data/lib/dry/cli/ui/widgets/box.rb +89 -0
- data/lib/dry/cli/ui/widgets/outcome.rb +25 -0
- data/lib/dry/cli/ui/widgets/progress.rb +119 -0
- data/lib/dry/cli/ui/widgets/prompt.rb +141 -0
- data/lib/dry/cli/ui/widgets/spinner.rb +63 -0
- data/lib/dry/cli/ui/widgets/status.rb +21 -0
- data/lib/dry/cli/ui/widgets/table.rb +46 -0
- data/lib/dry/cli/ui/widgets/tasks.rb +291 -0
- data/lib/dry/cli/ui/widgets.rb +21 -0
- data/lib/dry/cli/ui.rb +58 -0
- data/lib/dry-cli-ui.rb +3 -0
- data/sig/dry/cli/ui.rbs +32 -0
- metadata +223 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
class CLI
|
|
5
|
+
module UI
|
|
6
|
+
# The object {UI#ui} returns: a small semantic API for what a command
|
|
7
|
+
# wants to say, independent of how the terminal renders it.
|
|
8
|
+
#
|
|
9
|
+
# Messages about the command itself (`debug`, `warn`, `error`, `fatal`),
|
|
10
|
+
# spinners, progress bars, task trees and prompts go to `err`. The
|
|
11
|
+
# command's results (`info`, `success`, tables, plain boxes) go to
|
|
12
|
+
# `out`. Piping a command's output therefore captures its results and
|
|
13
|
+
# nothing else.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# ui = Dry::CLI::UI::Console.new
|
|
17
|
+
# ui.info "Importing tax rules..."
|
|
18
|
+
# rules = ui.spinner("Loading YAML") { load_rules }
|
|
19
|
+
# ui.progress("Importing", total: rules.size) do |bar|
|
|
20
|
+
# rules.each do |rule|
|
|
21
|
+
# import(rule)
|
|
22
|
+
# bar.advance
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
# ui.success "Imported #{rules.size} rules"
|
|
26
|
+
class Console
|
|
27
|
+
# @!method debug(*paragraphs, width: nil)
|
|
28
|
+
# A grey "Debug" box on `err`.
|
|
29
|
+
# @param paragraphs [Array<#to_s>] each one wrapped on its own, separated by a blank line
|
|
30
|
+
# @param width [Integer, nil] columns, overriding the console's box width
|
|
31
|
+
# @return [nil]
|
|
32
|
+
# @!method info(*paragraphs, width: nil)
|
|
33
|
+
# A cyan "Info" box on `out`.
|
|
34
|
+
# @param (see #debug)
|
|
35
|
+
# @return [nil]
|
|
36
|
+
# @!method success(*paragraphs, width: nil)
|
|
37
|
+
# A green "Success" box on `out`.
|
|
38
|
+
# @param (see #debug)
|
|
39
|
+
# @return [nil]
|
|
40
|
+
# @!method warn(*paragraphs, width: nil)
|
|
41
|
+
# A yellow "Warning" box on `err`.
|
|
42
|
+
# @param (see #debug)
|
|
43
|
+
# @return [nil]
|
|
44
|
+
# @!method error(*paragraphs, width: nil)
|
|
45
|
+
# A red "Error" box on `err`.
|
|
46
|
+
# @param (see #debug)
|
|
47
|
+
# @return [nil]
|
|
48
|
+
# @!method fatal(*paragraphs, width: nil)
|
|
49
|
+
# A magenta "Fatal" box on `err`.
|
|
50
|
+
# @param (see #debug)
|
|
51
|
+
# @return [nil]
|
|
52
|
+
Theme::LEVELS.each_key do |level|
|
|
53
|
+
define_method(level) do |*paragraphs, width: nil|
|
|
54
|
+
box(*paragraphs, level: level, width: width)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @param out [IO] where results go
|
|
59
|
+
# @param err [IO] where diagnostics, progress and prompts go
|
|
60
|
+
# @param input [IO] where prompt answers come from
|
|
61
|
+
# @param env [Hash{String => String}] read for NO_COLOR and TERM
|
|
62
|
+
# @param color [Boolean, nil] force colour on or off; nil decides per stream
|
|
63
|
+
# @param animate [Boolean, nil] force animation on or off; nil decides per stream
|
|
64
|
+
# @param width [Integer, nil] force the terminal width; nil asks the terminal
|
|
65
|
+
# @param box_width [Integer, nil] box width in columns; nil fills the terminal
|
|
66
|
+
# @param clock [#call] returns monotonic seconds
|
|
67
|
+
def initialize(out: $stdout, err: $stderr, input: $stdin, env: ENV, color: nil, animate: nil,
|
|
68
|
+
width: nil, box_width: nil, clock: Duration::CLOCK)
|
|
69
|
+
@out = Terminal.new(out, env: env, color: color, animate: animate, width: width)
|
|
70
|
+
@err = Terminal.new(err, env: env, color: color, animate: animate, width: width)
|
|
71
|
+
@input = input
|
|
72
|
+
@box_width = box_width
|
|
73
|
+
@clock = clock
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A framed panel. Given a level, it takes that level's title, colour
|
|
77
|
+
# and stream; without one it is untitled unless given a title, and goes
|
|
78
|
+
# to `out`.
|
|
79
|
+
#
|
|
80
|
+
# @example
|
|
81
|
+
# ui.box("Name: Alan Turing", "Role: Cryptanalyst", title: "Profile")
|
|
82
|
+
#
|
|
83
|
+
# @param paragraphs [Array<#to_s>] each one wrapped on its own, separated by a blank line
|
|
84
|
+
# @param title [String, nil] overrides the level's title
|
|
85
|
+
# @param level [Symbol, nil] one of {Theme::LEVELS}
|
|
86
|
+
# @param width [Integer, nil] columns, overriding the console's box width
|
|
87
|
+
# @return [nil]
|
|
88
|
+
def box(*paragraphs, title: nil, level: nil, width: nil)
|
|
89
|
+
theme = level && Theme.level(level)
|
|
90
|
+
terminal = theme ? stream(theme) : out
|
|
91
|
+
widget = Widgets::Box.new(terminal, width: width || box_width)
|
|
92
|
+
terminal.print(widget.render(paragraphs, title: title || theme&.title, color: theme&.color))
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# One line with a coloured glyph, going to the level's stream.
|
|
97
|
+
#
|
|
98
|
+
# @example
|
|
99
|
+
# ui.status "Connected to the database", level: :success # ✓ Connected to the database
|
|
100
|
+
#
|
|
101
|
+
# @param words [Array<#to_s>] joined with spaces
|
|
102
|
+
# @param level [Symbol] one of {Theme::LEVELS}
|
|
103
|
+
# @return [nil]
|
|
104
|
+
def status(*words, level: :info)
|
|
105
|
+
theme = Theme.level(level)
|
|
106
|
+
terminal = stream(theme)
|
|
107
|
+
terminal.puts(Widgets::Status.line(terminal, theme, words.join(" ")))
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Runs a block under a spinner and leaves `✓ label (1.2s)` behind, or
|
|
112
|
+
# `✗ label` when the block raises.
|
|
113
|
+
#
|
|
114
|
+
# @param label [String]
|
|
115
|
+
# @yield the work
|
|
116
|
+
# @return [Object] whatever the block returns
|
|
117
|
+
# @raise [ArgumentError] without a block
|
|
118
|
+
def spinner(label, &)
|
|
119
|
+
raise ArgumentError, "spinner needs a block" unless block_given?
|
|
120
|
+
|
|
121
|
+
Widgets::Spinner.new(err, clock: clock).run(label, &)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Runs a block with a progress bar showing percent, count and ETA.
|
|
125
|
+
#
|
|
126
|
+
# @param label [String]
|
|
127
|
+
# @param total [Integer] units of work
|
|
128
|
+
# @yieldparam progress [Widgets::Progress::Handle] call `advance` as units complete
|
|
129
|
+
# @return [Object] whatever the block returns
|
|
130
|
+
# @raise [ArgumentError] without a block, or when total is not a non-negative Integer
|
|
131
|
+
def progress(label, total:, &)
|
|
132
|
+
raise ArgumentError, "progress needs a block" unless block_given?
|
|
133
|
+
|
|
134
|
+
Widgets::Progress.new(err, clock: clock).run(label, total: total, &)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Declares a tree of tasks, then runs it, showing each task's state
|
|
138
|
+
# and elapsed time. See {Widgets::Tasks}.
|
|
139
|
+
#
|
|
140
|
+
# @example Several operations at once
|
|
141
|
+
# ui.tasks("Fetching", concurrent: true) do |t|
|
|
142
|
+
# t.task("fonts") { fetch(:fonts) }
|
|
143
|
+
# t.task("images") { fetch(:images) }
|
|
144
|
+
# end
|
|
145
|
+
#
|
|
146
|
+
# @param title [String, nil]
|
|
147
|
+
# @param concurrent [Boolean] run the top-level tasks at the same time
|
|
148
|
+
# @yieldparam tasks [Widgets::Tasks::Builder] declares `task`s and `group`s
|
|
149
|
+
# @return [nil]
|
|
150
|
+
# @raise [ArgumentError] without a block
|
|
151
|
+
def tasks(title = nil, concurrent: false, &)
|
|
152
|
+
raise ArgumentError, "tasks needs a block" unless block_given?
|
|
153
|
+
|
|
154
|
+
Widgets::Tasks.new(err, clock: clock).run(title, concurrent: concurrent, &)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Prints a table to `out`.
|
|
158
|
+
#
|
|
159
|
+
# @example
|
|
160
|
+
# ui.table([["Alan Turing", 41]], header: ["Name", "Age"])
|
|
161
|
+
#
|
|
162
|
+
# @param rows [Array<Array<#to_s>>]
|
|
163
|
+
# @param header [Array<#to_s>, nil]
|
|
164
|
+
# @return [nil]
|
|
165
|
+
def table(rows, header: nil)
|
|
166
|
+
out.print(Widgets::Table.new(out).render(rows, header: header))
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Asks a question. See {Widgets::Prompt#ask}.
|
|
171
|
+
#
|
|
172
|
+
# @example
|
|
173
|
+
# ui.prompt("Environment?", choices: %w[staging production], default: "staging")
|
|
174
|
+
#
|
|
175
|
+
# @param question [String]
|
|
176
|
+
# @param default [Object, nil]
|
|
177
|
+
# @param choices [Array<String>, Hash{String => Object}, nil]
|
|
178
|
+
# @return [Object, nil]
|
|
179
|
+
# @raise [NonInteractiveError] when the input is exhausted and there is no default
|
|
180
|
+
def prompt(question, default: nil, choices: nil)
|
|
181
|
+
prompter.ask(question, default: default, choices: choices)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Asks a yes/no question.
|
|
185
|
+
#
|
|
186
|
+
# @param question [String]
|
|
187
|
+
# @param default [Boolean]
|
|
188
|
+
# @return [Boolean]
|
|
189
|
+
def confirm(question, default: false)
|
|
190
|
+
prompter.confirm(question, default: default)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
# @return [Terminal]
|
|
196
|
+
attr_reader :out
|
|
197
|
+
|
|
198
|
+
# @return [Terminal]
|
|
199
|
+
attr_reader :err
|
|
200
|
+
|
|
201
|
+
# @return [IO]
|
|
202
|
+
attr_reader :input
|
|
203
|
+
|
|
204
|
+
# @return [Integer, nil]
|
|
205
|
+
attr_reader :box_width
|
|
206
|
+
|
|
207
|
+
# @return [#call]
|
|
208
|
+
attr_reader :clock
|
|
209
|
+
|
|
210
|
+
# @param theme [Theme::Level]
|
|
211
|
+
# @return [Terminal]
|
|
212
|
+
def stream(theme)
|
|
213
|
+
theme.stream == :err ? err : out
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# @return [Widgets::Prompt]
|
|
217
|
+
def prompter
|
|
218
|
+
@prompter ||= Widgets::Prompt.new(input: input, terminal: err)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
class CLI
|
|
5
|
+
module UI
|
|
6
|
+
# Measures and formats elapsed time.
|
|
7
|
+
module Duration
|
|
8
|
+
# The default clock: monotonic seconds, immune to wall-clock changes.
|
|
9
|
+
CLOCK = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
10
|
+
|
|
11
|
+
# Formats a number of seconds for a human.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# Duration.format(0.42) # => "0.4s"
|
|
15
|
+
# Duration.format(62) # => "1m 02s"
|
|
16
|
+
# Duration.format(3720) # => "1h 02m"
|
|
17
|
+
#
|
|
18
|
+
# @param seconds [Numeric]
|
|
19
|
+
# @return [String]
|
|
20
|
+
def self.format(seconds)
|
|
21
|
+
case seconds
|
|
22
|
+
in ...60 then Kernel.format("%<s>.1fs", s: seconds)
|
|
23
|
+
in ...3600 then Kernel.format("%<m>dm %<s>02ds", m: seconds / 60, s: seconds % 60)
|
|
24
|
+
else Kernel.format("%<h>dh %<m>02dm", h: seconds / 3600, m: (seconds % 3600) / 60)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
require "tty-cursor"
|
|
5
|
+
require "tty-screen"
|
|
6
|
+
|
|
7
|
+
module Dry
|
|
8
|
+
class CLI
|
|
9
|
+
module UI
|
|
10
|
+
# One output stream, and what it can do.
|
|
11
|
+
#
|
|
12
|
+
# A stream that is not a TTY, or runs under `TERM=dumb`, gets no
|
|
13
|
+
# animation and no cursor movement. `NO_COLOR` (https://no-color.org)
|
|
14
|
+
# additionally switches colour off. Widgets ask these questions rather
|
|
15
|
+
# than inspecting the IO themselves, which is what keeps the fallback in
|
|
16
|
+
# one place.
|
|
17
|
+
class Terminal
|
|
18
|
+
# Width assumed for a stream that is not a TTY.
|
|
19
|
+
DEFAULT_WIDTH = 80
|
|
20
|
+
|
|
21
|
+
# Height assumed for a stream that is not a TTY.
|
|
22
|
+
DEFAULT_HEIGHT = 24
|
|
23
|
+
|
|
24
|
+
# @param io [IO] the stream to write to
|
|
25
|
+
# @param env [Hash{String => String}] the environment to read NO_COLOR and TERM from
|
|
26
|
+
# @param color [Boolean, nil] force colour on or off; nil decides from the stream
|
|
27
|
+
# @param animate [Boolean, nil] force animation on or off; nil decides from the stream
|
|
28
|
+
# @param width [Integer, nil] force the width; nil asks the terminal
|
|
29
|
+
def initialize(io, env: ENV, color: nil, animate: nil, width: nil)
|
|
30
|
+
@io = io
|
|
31
|
+
@env = env
|
|
32
|
+
@color = color
|
|
33
|
+
@animate = animate
|
|
34
|
+
@width = width
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [IO] the stream this terminal writes to
|
|
38
|
+
attr_reader :io
|
|
39
|
+
|
|
40
|
+
# Whether the stream is an interactive terminal.
|
|
41
|
+
#
|
|
42
|
+
# @return [Boolean]
|
|
43
|
+
def tty?
|
|
44
|
+
io.respond_to?(:tty?) && io.tty?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Whether spinners may animate and the cursor may move.
|
|
48
|
+
#
|
|
49
|
+
# @return [Boolean]
|
|
50
|
+
def animated?
|
|
51
|
+
return animate unless animate.nil?
|
|
52
|
+
|
|
53
|
+
tty? && env["TERM"] != "dumb"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Whether to emit ANSI colour codes.
|
|
57
|
+
#
|
|
58
|
+
# @return [Boolean]
|
|
59
|
+
def color?
|
|
60
|
+
return color unless color.nil?
|
|
61
|
+
|
|
62
|
+
animated? && env["NO_COLOR"].to_s.empty?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Integer] columns available
|
|
66
|
+
def width
|
|
67
|
+
@width || (tty? ? TTY::Screen.width : DEFAULT_WIDTH)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [Integer] rows available
|
|
71
|
+
def height
|
|
72
|
+
tty? ? TTY::Screen.height : DEFAULT_HEIGHT
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Pastel::Delegator] a colouriser that is a no-op when colour is off
|
|
76
|
+
def pastel
|
|
77
|
+
@pastel ||= Pastel.new(enabled: color?)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Module] TTY::Cursor, for widgets that redraw in place
|
|
81
|
+
def cursor = TTY::Cursor
|
|
82
|
+
|
|
83
|
+
# Writes a string without a newline. Flushes, so that output on `out`
|
|
84
|
+
# and `err` stays in the order it was written even when piped.
|
|
85
|
+
#
|
|
86
|
+
# @param text [String]
|
|
87
|
+
# @return [void]
|
|
88
|
+
def print(text)
|
|
89
|
+
io.print(text)
|
|
90
|
+
io.flush if io.respond_to?(:flush)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Writes a line, flushing as {#print} does.
|
|
94
|
+
#
|
|
95
|
+
# @param text [String]
|
|
96
|
+
# @return [void]
|
|
97
|
+
def puts(text = "")
|
|
98
|
+
print("#{text}\n")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# @return [Hash{String => String}]
|
|
104
|
+
attr_reader :env
|
|
105
|
+
|
|
106
|
+
# @return [Boolean, nil]
|
|
107
|
+
attr_reader :color
|
|
108
|
+
|
|
109
|
+
# @return [Boolean, nil]
|
|
110
|
+
attr_reader :animate
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
class CLI
|
|
5
|
+
module UI
|
|
6
|
+
# The six message levels and how each one looks.
|
|
7
|
+
module Theme
|
|
8
|
+
# How one message level is presented.
|
|
9
|
+
#
|
|
10
|
+
# @!attribute [r] name
|
|
11
|
+
# @return [Symbol] the level, e.g. `:error`
|
|
12
|
+
# @!attribute [r] title
|
|
13
|
+
# @return [String] the box title, e.g. "Error"
|
|
14
|
+
# @!attribute [r] glyph
|
|
15
|
+
# @return [String] the status-line marker, e.g. "✗"
|
|
16
|
+
# @!attribute [r] color
|
|
17
|
+
# @return [Symbol] a Pastel colour for the title and glyph
|
|
18
|
+
# @!attribute [r] stream
|
|
19
|
+
# @return [Symbol] `:out` or `:err`, where messages at this level go
|
|
20
|
+
Level = ::Data.define(:name, :title, :glyph, :color, :stream)
|
|
21
|
+
|
|
22
|
+
# Every level, keyed by name. Diagnostics go to STDERR so that a
|
|
23
|
+
# command's real output can still be piped.
|
|
24
|
+
LEVELS = {
|
|
25
|
+
debug: Level.new(name: :debug, title: "Debug", glyph: "·", color: :bright_black, stream: :err),
|
|
26
|
+
info: Level.new(name: :info, title: "Info", glyph: "ℹ", color: :cyan, stream: :out),
|
|
27
|
+
success: Level.new(name: :success, title: "Success", glyph: "✓", color: :green, stream: :out),
|
|
28
|
+
warn: Level.new(name: :warn, title: "Warning", glyph: "⚠", color: :yellow, stream: :err),
|
|
29
|
+
error: Level.new(name: :error, title: "Error", glyph: "✗", color: :red, stream: :err),
|
|
30
|
+
fatal: Level.new(name: :fatal, title: "Fatal", glyph: "✖", color: :magenta, stream: :err)
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# Glyphs for the states an operation passes through.
|
|
34
|
+
STATES = {
|
|
35
|
+
pending: ["○", :bright_black],
|
|
36
|
+
running: ["▸", :cyan],
|
|
37
|
+
done: ["✓", :green],
|
|
38
|
+
failed: ["✗", :red],
|
|
39
|
+
skipped: ["–", :bright_black]
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
# Looks up a level by name.
|
|
43
|
+
#
|
|
44
|
+
# @param name [Symbol] one of {LEVELS}' keys
|
|
45
|
+
# @return [Level]
|
|
46
|
+
# @raise [ArgumentError] when the level does not exist
|
|
47
|
+
def self.level(name)
|
|
48
|
+
LEVELS.fetch(name) { raise ArgumentError, "unknown level #{name.inspect}, expected one of #{LEVELS.keys}" }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
# Reopened rather than defined: dry-cli declares `class CLI`, not a module,
|
|
5
|
+
# and getting that wrong raises TypeError the moment both are loaded.
|
|
6
|
+
#
|
|
7
|
+
# Declared here without requiring dry-cli, because the gemspec loads this
|
|
8
|
+
# file at build time when the dependency may not be installed. dry-cli's CLI
|
|
9
|
+
# inherits from Object, so an empty reopening is compatible either way.
|
|
10
|
+
class CLI
|
|
11
|
+
# Presentation helpers for Dry::CLI commands.
|
|
12
|
+
module UI
|
|
13
|
+
# The gem version.
|
|
14
|
+
VERSION = "0.1.0"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strings"
|
|
4
|
+
require "tty-box"
|
|
5
|
+
|
|
6
|
+
module Dry
|
|
7
|
+
class CLI
|
|
8
|
+
module UI
|
|
9
|
+
module Widgets
|
|
10
|
+
# A framed panel with a single white border and an optional coloured
|
|
11
|
+
# title.
|
|
12
|
+
#
|
|
13
|
+
# The box is as wide as it is told to be: a fixed number of columns,
|
|
14
|
+
# or the whole terminal less {MARGIN}. It never grows wider than the
|
|
15
|
+
# terminal.
|
|
16
|
+
class Box
|
|
17
|
+
# Columns left free on the right when the box fills the terminal.
|
|
18
|
+
MARGIN = 2
|
|
19
|
+
|
|
20
|
+
# Narrowest box drawn, however small the terminal.
|
|
21
|
+
MIN_WIDTH = 20
|
|
22
|
+
|
|
23
|
+
# Blank rows above and below the text, and columns either side of it.
|
|
24
|
+
PADDING = [1, 2].freeze
|
|
25
|
+
|
|
26
|
+
# @param terminal [Terminal]
|
|
27
|
+
# @param width [Integer, nil] columns; nil fills the terminal
|
|
28
|
+
def initialize(terminal, width: nil)
|
|
29
|
+
@terminal = terminal
|
|
30
|
+
@width = width
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Renders a box. Each paragraph is wrapped on its own and separated
|
|
34
|
+
# from the next by a blank line.
|
|
35
|
+
#
|
|
36
|
+
# @param paragraphs [Array<#to_s>]
|
|
37
|
+
# @param title [String, nil]
|
|
38
|
+
# @param color [Symbol, nil] Pastel colour for the title
|
|
39
|
+
# @return [String] the box, ending in a newline
|
|
40
|
+
def render(paragraphs, title: nil, color: nil)
|
|
41
|
+
TTY::Box.frame(
|
|
42
|
+
wrap(paragraphs),
|
|
43
|
+
width: box_width,
|
|
44
|
+
padding: PADDING,
|
|
45
|
+
border: :light,
|
|
46
|
+
title: title ? { top_left: heading(title, color) } : {},
|
|
47
|
+
style: { border: { fg: :white } },
|
|
48
|
+
enable_color: terminal.color?
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# @return [Terminal]
|
|
55
|
+
attr_reader :terminal
|
|
56
|
+
|
|
57
|
+
# @return [Integer, nil]
|
|
58
|
+
attr_reader :width
|
|
59
|
+
|
|
60
|
+
# TTY::Box wraps text itself but sizes the box from the unwrapped
|
|
61
|
+
# lines, which silently drops everything past the first few rows.
|
|
62
|
+
# Wrapping here first means its own wrap has nothing left to do.
|
|
63
|
+
#
|
|
64
|
+
# @param paragraphs [Array<#to_s>]
|
|
65
|
+
# @return [String]
|
|
66
|
+
def wrap(paragraphs)
|
|
67
|
+
text_width = box_width - 2 - (PADDING[1] * 2)
|
|
68
|
+
paragraphs.map { |p| Strings::Wrap.wrap(p.to_s, text_width).gsub(/[ \t]+$/, "") }.join("\n\n")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [Integer]
|
|
72
|
+
def box_width
|
|
73
|
+
available = terminal.width - MARGIN
|
|
74
|
+
[width ? [width, available].min : available, MIN_WIDTH].max
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @param title [String]
|
|
78
|
+
# @param color [Symbol, nil]
|
|
79
|
+
# @return [String]
|
|
80
|
+
def heading(title, color)
|
|
81
|
+
pastel = terminal.pastel
|
|
82
|
+
styled = color ? pastel.decorate(title, color, :bold) : pastel.bold(title)
|
|
83
|
+
"─ #{styled} "
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
class CLI
|
|
5
|
+
module UI
|
|
6
|
+
module Widgets
|
|
7
|
+
# The line an operation leaves behind when it ends: `✓ Loading (1.2s)`.
|
|
8
|
+
# Spinners and progress bars share it, so every operation finishes the
|
|
9
|
+
# same way whether or not the terminal could animate it.
|
|
10
|
+
module Outcome
|
|
11
|
+
# @param terminal [Terminal]
|
|
12
|
+
# @param state [Symbol] `:done` or `:failed`
|
|
13
|
+
# @param label [String] what the operation was
|
|
14
|
+
# @param seconds [Numeric] how long it took
|
|
15
|
+
# @return [String] the line, without a newline
|
|
16
|
+
def self.line(terminal, state, label, seconds)
|
|
17
|
+
glyph, color = Theme::STATES.fetch(state)
|
|
18
|
+
pastel = terminal.pastel
|
|
19
|
+
"#{pastel.decorate(glyph, color)} #{label} #{pastel.bright_black("(#{Duration.format(seconds)})")}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-progressbar"
|
|
4
|
+
|
|
5
|
+
module Dry
|
|
6
|
+
class CLI
|
|
7
|
+
module UI
|
|
8
|
+
module Widgets
|
|
9
|
+
# A progress bar with a percentage, a count and an ETA, followed by
|
|
10
|
+
# the outcome line once the block ends.
|
|
11
|
+
#
|
|
12
|
+
# Without an animated terminal it prints `Label...` before the block
|
|
13
|
+
# and the outcome line, with the final count, after it.
|
|
14
|
+
class Progress
|
|
15
|
+
# What the block is given to report progress through.
|
|
16
|
+
class Handle
|
|
17
|
+
# @param total [Integer]
|
|
18
|
+
# @param bar [TTY::ProgressBar, nil]
|
|
19
|
+
def initialize(total, bar)
|
|
20
|
+
@total = total
|
|
21
|
+
@bar = bar
|
|
22
|
+
@current = 0
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Integer] the number of units the operation has
|
|
26
|
+
attr_reader :total
|
|
27
|
+
|
|
28
|
+
# @return [Integer] the number of units completed so far
|
|
29
|
+
attr_reader :current
|
|
30
|
+
|
|
31
|
+
# Marks units as complete. Progress never passes {#total}.
|
|
32
|
+
#
|
|
33
|
+
# @param step [Integer]
|
|
34
|
+
# @return [self]
|
|
35
|
+
def advance(step = 1)
|
|
36
|
+
self.current = (current + step).clamp(0, total)
|
|
37
|
+
bar&.advance(step)
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# @return [TTY::ProgressBar, nil]
|
|
44
|
+
attr_reader :bar
|
|
45
|
+
|
|
46
|
+
attr_writer :current
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Columns kept for the label, percentage, count and ETA around the bar.
|
|
50
|
+
CHROME = 34
|
|
51
|
+
|
|
52
|
+
# Narrowest bar drawn.
|
|
53
|
+
MIN_BAR = 10
|
|
54
|
+
|
|
55
|
+
# @param terminal [Terminal]
|
|
56
|
+
# @param clock [#call] returns monotonic seconds
|
|
57
|
+
def initialize(terminal, clock:)
|
|
58
|
+
@terminal = terminal
|
|
59
|
+
@clock = clock
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Runs the block with a progress bar.
|
|
63
|
+
#
|
|
64
|
+
# @param label [String]
|
|
65
|
+
# @param total [Integer] the number of units of work
|
|
66
|
+
# @yieldparam progress [Handle]
|
|
67
|
+
# @return [Object] whatever the block returns
|
|
68
|
+
# @raise [ArgumentError] when total is not a non-negative Integer
|
|
69
|
+
def run(label, total:)
|
|
70
|
+
raise ArgumentError, "total must be a non-negative Integer, got #{total.inspect}" unless total.is_a?(Integer) && total >= 0
|
|
71
|
+
|
|
72
|
+
started = clock.call
|
|
73
|
+
bar = start(label, total)
|
|
74
|
+
handle = Handle.new(total, bar)
|
|
75
|
+
ok = false
|
|
76
|
+
result = yield handle
|
|
77
|
+
ok = true
|
|
78
|
+
result
|
|
79
|
+
ensure
|
|
80
|
+
if handle
|
|
81
|
+
bar&.stop
|
|
82
|
+
summary = "#{label} #{handle.current}/#{total}"
|
|
83
|
+
terminal.puts(Outcome.line(terminal, ok ? :done : :failed, summary, clock.call - started))
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# @return [Terminal]
|
|
90
|
+
attr_reader :terminal
|
|
91
|
+
|
|
92
|
+
# @return [#call]
|
|
93
|
+
attr_reader :clock
|
|
94
|
+
|
|
95
|
+
# @param label [String]
|
|
96
|
+
# @param total [Integer]
|
|
97
|
+
# @return [TTY::ProgressBar, nil]
|
|
98
|
+
def start(label, total)
|
|
99
|
+
unless terminal.animated? && total.positive?
|
|
100
|
+
terminal.puts("#{label}...")
|
|
101
|
+
return
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
TTY::ProgressBar.new(
|
|
105
|
+
"#{label} :bar :percent :current/:total ETA :eta",
|
|
106
|
+
total: total,
|
|
107
|
+
width: [terminal.width - label.length - CHROME, MIN_BAR].max,
|
|
108
|
+
output: terminal.io,
|
|
109
|
+
complete: "█",
|
|
110
|
+
incomplete: "░",
|
|
111
|
+
clear: true,
|
|
112
|
+
hide_cursor: true
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|