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,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-prompt"
|
|
4
|
+
|
|
5
|
+
module Dry
|
|
6
|
+
class CLI
|
|
7
|
+
module UI
|
|
8
|
+
module Widgets
|
|
9
|
+
# Questions, choices and yes/no confirmations.
|
|
10
|
+
#
|
|
11
|
+
# When both the input and the output are interactive terminals, this
|
|
12
|
+
# uses arrow-key menus and line editing. Otherwise it prints the
|
|
13
|
+
# question and reads a line, so answers can be piped in:
|
|
14
|
+
#
|
|
15
|
+
# printf 'production\ny\n' | mycli deploy
|
|
16
|
+
#
|
|
17
|
+
# An exhausted input returns the default. A question without a
|
|
18
|
+
# default raises {NonInteractiveError} rather than inventing an answer.
|
|
19
|
+
class Prompt
|
|
20
|
+
# @param input [IO] where answers come from
|
|
21
|
+
# @param terminal [Terminal] where questions go
|
|
22
|
+
# @param backend [TTY::Prompt, nil] the interactive implementation; built on first use when nil
|
|
23
|
+
def initialize(input:, terminal:, backend: nil)
|
|
24
|
+
@input = input
|
|
25
|
+
@terminal = terminal
|
|
26
|
+
@backend = backend
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Asks for a free-form answer, or for one of a list of choices.
|
|
30
|
+
#
|
|
31
|
+
# @param question [String]
|
|
32
|
+
# @param default [Object, nil] returned for an empty answer; for choices, the name of one
|
|
33
|
+
# @param choices [Array<String>, Hash{String => Object}, nil]
|
|
34
|
+
# names to pick from, or names mapped to the values to return
|
|
35
|
+
# @return [Object, nil] the answer, or the chosen value
|
|
36
|
+
# @raise [NonInteractiveError] when the input is exhausted and there is no default
|
|
37
|
+
def ask(question, default: nil, choices: nil)
|
|
38
|
+
if interactive?
|
|
39
|
+
options = default.nil? ? {} : { default: default }
|
|
40
|
+
choices ? backend.select(question, choices, **options) : backend.ask(question, **options)
|
|
41
|
+
else
|
|
42
|
+
choices ? plain_select(question, pairs(choices), default) : plain_ask(question, default)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Asks a yes/no question.
|
|
47
|
+
#
|
|
48
|
+
# @param question [String]
|
|
49
|
+
# @param default [Boolean] returned for an empty answer or an exhausted input
|
|
50
|
+
# @return [Boolean]
|
|
51
|
+
def confirm(question, default: false)
|
|
52
|
+
return backend.yes?(question, default: default) if interactive?
|
|
53
|
+
|
|
54
|
+
loop do
|
|
55
|
+
terminal.print("#{question} #{default ? '(Y/n)' : '(y/N)'} ")
|
|
56
|
+
case read&.downcase
|
|
57
|
+
in nil | "" then return default
|
|
58
|
+
in "y" | "yes" then return true
|
|
59
|
+
in "n" | "no" then return false
|
|
60
|
+
else terminal.puts("Please answer y or n.")
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# @return [IO]
|
|
68
|
+
attr_reader :input
|
|
69
|
+
|
|
70
|
+
# @return [Terminal]
|
|
71
|
+
attr_reader :terminal
|
|
72
|
+
|
|
73
|
+
# @return [TTY::Prompt]
|
|
74
|
+
def backend
|
|
75
|
+
@backend ||= TTY::Prompt.new(
|
|
76
|
+
input: input,
|
|
77
|
+
output: terminal.io,
|
|
78
|
+
enable_color: terminal.color?,
|
|
79
|
+
interrupt: :signal
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
def interactive?
|
|
85
|
+
input.respond_to?(:tty?) && input.tty? && terminal.animated?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [String, nil] the next line without surrounding whitespace, or nil at the end of input
|
|
89
|
+
def read
|
|
90
|
+
input.gets&.strip
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @param question [String]
|
|
94
|
+
# @param default [Object, nil]
|
|
95
|
+
# @return [Object, nil]
|
|
96
|
+
def plain_ask(question, default)
|
|
97
|
+
terminal.print("#{question}#{" [#{default}]" unless default.nil?} ")
|
|
98
|
+
answer = read
|
|
99
|
+
raise NonInteractiveError, "no answer for #{question.inspect} and no default" if answer.nil? && default.nil?
|
|
100
|
+
|
|
101
|
+
answer.nil? || answer.empty? ? default : answer
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @param question [String]
|
|
105
|
+
# @param options [Array<Array(String, Object)>]
|
|
106
|
+
# @param default [Object, nil] the name of the default choice
|
|
107
|
+
# @return [Object]
|
|
108
|
+
def plain_select(question, options, default)
|
|
109
|
+
terminal.puts(question)
|
|
110
|
+
options.each_with_index { |(name, _), index| terminal.puts(" #{index + 1}) #{name}") }
|
|
111
|
+
loop do
|
|
112
|
+
terminal.print("Choose 1-#{options.size}#{" [#{default}]" unless default.nil?}: ")
|
|
113
|
+
line = read
|
|
114
|
+
answer = line.nil? || line.empty? ? default&.to_s : line
|
|
115
|
+
found = choice(options, answer) if answer
|
|
116
|
+
return found.last if found
|
|
117
|
+
raise NonInteractiveError, "no valid choice for #{question.inspect}" if line.nil?
|
|
118
|
+
|
|
119
|
+
terminal.puts("Choose a number from 1 to #{options.size}, or type a choice.")
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @param options [Array<Array(String, Object)>]
|
|
124
|
+
# @param answer [String] a 1-based position or a name
|
|
125
|
+
# @return [Array(String, Object), nil]
|
|
126
|
+
def choice(options, answer)
|
|
127
|
+
return options[answer.to_i - 1] if answer.match?(/\A\d+\z/) && answer.to_i.between?(1, options.size)
|
|
128
|
+
|
|
129
|
+
options.find { |name, _| name.to_s == answer }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @param choices [Array<String>, Hash{String => Object}]
|
|
133
|
+
# @return [Array<Array(String, Object)>]
|
|
134
|
+
def pairs(choices)
|
|
135
|
+
choices.is_a?(Hash) ? choices.to_a : choices.map { |name| [name, name] }
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-spinner"
|
|
4
|
+
|
|
5
|
+
module Dry
|
|
6
|
+
class CLI
|
|
7
|
+
module UI
|
|
8
|
+
module Widgets
|
|
9
|
+
# Shows that a block is running, then how it ended and how long it took.
|
|
10
|
+
#
|
|
11
|
+
# On an animated terminal the spinner turns until the block returns
|
|
12
|
+
# and is replaced by the outcome line. Anywhere else it prints
|
|
13
|
+
# `Label...` before the block and the outcome line after it.
|
|
14
|
+
class Spinner
|
|
15
|
+
# @param terminal [Terminal]
|
|
16
|
+
# @param clock [#call] returns monotonic seconds
|
|
17
|
+
def initialize(terminal, clock:)
|
|
18
|
+
@terminal = terminal
|
|
19
|
+
@clock = clock
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Runs the block under a spinner.
|
|
23
|
+
#
|
|
24
|
+
# @param label [String]
|
|
25
|
+
# @yield the work to show progress for
|
|
26
|
+
# @return [Object] whatever the block returns
|
|
27
|
+
# @raise [Exception] whatever the block raises, after marking the spinner failed
|
|
28
|
+
def run(label)
|
|
29
|
+
started = clock.call
|
|
30
|
+
spinner = start(label)
|
|
31
|
+
ok = false
|
|
32
|
+
result = yield
|
|
33
|
+
ok = true
|
|
34
|
+
result
|
|
35
|
+
ensure
|
|
36
|
+
spinner&.stop
|
|
37
|
+
terminal.puts(Outcome.line(terminal, ok ? :done : :failed, label, clock.call - started))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# @return [Terminal]
|
|
43
|
+
attr_reader :terminal
|
|
44
|
+
|
|
45
|
+
# @return [#call]
|
|
46
|
+
attr_reader :clock
|
|
47
|
+
|
|
48
|
+
# @param label [String]
|
|
49
|
+
# @return [TTY::Spinner, nil] the running spinner, or nil when not animating
|
|
50
|
+
def start(label)
|
|
51
|
+
unless terminal.animated?
|
|
52
|
+
terminal.puts("#{label}...")
|
|
53
|
+
return
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
TTY::Spinner.new(":spinner #{label}", output: terminal.io, format: :dots, hide_cursor: true, clear: true)
|
|
57
|
+
.tap(&:auto_spin)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
class CLI
|
|
5
|
+
module UI
|
|
6
|
+
module Widgets
|
|
7
|
+
# A single line with a coloured glyph: `✓ Connected to the database`.
|
|
8
|
+
# The light-weight sibling of {Box}.
|
|
9
|
+
module Status
|
|
10
|
+
# @param terminal [Terminal]
|
|
11
|
+
# @param level [Theme::Level]
|
|
12
|
+
# @param text [String]
|
|
13
|
+
# @return [String] the line, without a newline
|
|
14
|
+
def self.line(terminal, level, text)
|
|
15
|
+
"#{terminal.pastel.decorate(level.glyph, level.color)} #{text}"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-table"
|
|
4
|
+
|
|
5
|
+
module Dry
|
|
6
|
+
class CLI
|
|
7
|
+
module UI
|
|
8
|
+
module Widgets
|
|
9
|
+
# Rows and an optional bold header, framed with box-drawing lines.
|
|
10
|
+
#
|
|
11
|
+
# Tables are data, so they are never narrowed to fit the terminal: a
|
|
12
|
+
# column is not truncated and a table is not rotated. A table wider
|
|
13
|
+
# than the screen wraps the way any long line does.
|
|
14
|
+
class Table
|
|
15
|
+
# Wider than any table anyone will render. TTY::Table otherwise
|
|
16
|
+
# measures the screen, warns on STDERR, and turns a wide table on
|
|
17
|
+
# its side.
|
|
18
|
+
UNLIMITED = 1_000_000
|
|
19
|
+
|
|
20
|
+
# @param terminal [Terminal]
|
|
21
|
+
def initialize(terminal)
|
|
22
|
+
@terminal = terminal
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param rows [Array<Array<#to_s>>]
|
|
26
|
+
# @param header [Array<#to_s>, nil]
|
|
27
|
+
# @return [String] the table ending in a newline, or "" when there are no rows
|
|
28
|
+
def render(rows, header: nil)
|
|
29
|
+
return "" if rows.empty?
|
|
30
|
+
|
|
31
|
+
table = TTY::Table.new(
|
|
32
|
+
header: header&.map { |cell| terminal.pastel.bold(cell.to_s) },
|
|
33
|
+
rows: rows.map { |row| row.map(&:to_s) }
|
|
34
|
+
)
|
|
35
|
+
"#{table.render(:unicode, padding: [0, 1], width: UNLIMITED)}\n"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# @return [Terminal]
|
|
41
|
+
attr_reader :terminal
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
|
|
5
|
+
module Dry
|
|
6
|
+
class CLI
|
|
7
|
+
module UI
|
|
8
|
+
module Widgets
|
|
9
|
+
# Runs a tree of named tasks and shows the state of each: pending,
|
|
10
|
+
# running, done, failed or skipped, with elapsed times.
|
|
11
|
+
#
|
|
12
|
+
# The block only declares the tree; nothing runs until it returns, so
|
|
13
|
+
# the whole shape is known before the first task starts. Tasks run in
|
|
14
|
+
# order, except inside a group declared `concurrent: true`, whose
|
|
15
|
+
# tasks all run at once.
|
|
16
|
+
#
|
|
17
|
+
# On an animated terminal the tree is drawn once and redrawn in place,
|
|
18
|
+
# with a spinner beside every running task. Otherwise, or when the
|
|
19
|
+
# tree is taller than the screen, each line is printed once it has
|
|
20
|
+
# something final to say.
|
|
21
|
+
#
|
|
22
|
+
# When a task raises, it and every group above it are marked failed,
|
|
23
|
+
# tasks already running beside it finish, everything that had not
|
|
24
|
+
# started is marked skipped, and the error is re-raised.
|
|
25
|
+
#
|
|
26
|
+
# @example
|
|
27
|
+
# ui.tasks("Deploy") do |t|
|
|
28
|
+
# t.task("Build assets") { build }
|
|
29
|
+
# t.group("Migrate", concurrent: true) do |g|
|
|
30
|
+
# g.task("users") { migrate(:users) }
|
|
31
|
+
# g.task("orders") { migrate(:orders) }
|
|
32
|
+
# end
|
|
33
|
+
# end
|
|
34
|
+
class Tasks
|
|
35
|
+
# Frames a running task cycles through on an animated terminal.
|
|
36
|
+
FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
37
|
+
|
|
38
|
+
# Seconds between spinner frames.
|
|
39
|
+
INTERVAL = 0.1
|
|
40
|
+
|
|
41
|
+
# One task, or a group of them.
|
|
42
|
+
class Node
|
|
43
|
+
# @param name [String]
|
|
44
|
+
# @param job [Proc, nil] the work; nil for a group
|
|
45
|
+
# @param concurrent [Boolean] whether a group runs its tasks at once
|
|
46
|
+
def initialize(name, job = nil, concurrent: false)
|
|
47
|
+
@name = name
|
|
48
|
+
@job = job
|
|
49
|
+
@concurrent = concurrent
|
|
50
|
+
@children = []
|
|
51
|
+
@state = :pending
|
|
52
|
+
@seconds = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [String]
|
|
56
|
+
attr_reader :name
|
|
57
|
+
|
|
58
|
+
# @return [Proc, nil]
|
|
59
|
+
attr_reader :job
|
|
60
|
+
|
|
61
|
+
# @return [Boolean]
|
|
62
|
+
attr_reader :concurrent
|
|
63
|
+
alias concurrent? concurrent
|
|
64
|
+
|
|
65
|
+
# @return [Array<Node>]
|
|
66
|
+
attr_reader :children
|
|
67
|
+
|
|
68
|
+
# @return [Symbol] one of the keys of {Theme::STATES}
|
|
69
|
+
attr_accessor :state
|
|
70
|
+
|
|
71
|
+
# @return [Float, nil] how long the node ran, once it has finished
|
|
72
|
+
attr_accessor :seconds
|
|
73
|
+
|
|
74
|
+
# @return [Boolean] whether this node holds tasks rather than work
|
|
75
|
+
def group? = job.nil?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# What the declaration block is given.
|
|
79
|
+
class Builder
|
|
80
|
+
# @param nodes [Array<Node>] the list new nodes are appended to
|
|
81
|
+
def initialize(nodes)
|
|
82
|
+
@nodes = nodes
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Declares a task.
|
|
86
|
+
#
|
|
87
|
+
# @param name [String]
|
|
88
|
+
# @yield the work
|
|
89
|
+
# @return [self]
|
|
90
|
+
# @raise [ArgumentError] without a block
|
|
91
|
+
def task(name, &job)
|
|
92
|
+
raise ArgumentError, "task #{name.inspect} needs a block" unless job
|
|
93
|
+
|
|
94
|
+
nodes << Node.new(name, job)
|
|
95
|
+
self
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Declares a group of tasks.
|
|
99
|
+
#
|
|
100
|
+
# @param name [String]
|
|
101
|
+
# @param concurrent [Boolean] run the group's tasks at the same time
|
|
102
|
+
# @yieldparam group [Builder] declares the tasks inside the group
|
|
103
|
+
# @return [self]
|
|
104
|
+
# @raise [ArgumentError] without a block
|
|
105
|
+
def group(name, concurrent: false)
|
|
106
|
+
raise ArgumentError, "group #{name.inspect} needs a block" unless block_given?
|
|
107
|
+
|
|
108
|
+
node = Node.new(name, concurrent: concurrent)
|
|
109
|
+
nodes << node
|
|
110
|
+
yield Builder.new(node.children)
|
|
111
|
+
self
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
# @return [Array<Node>]
|
|
117
|
+
attr_reader :nodes
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @param terminal [Terminal]
|
|
121
|
+
# @param clock [#call] returns monotonic seconds
|
|
122
|
+
def initialize(terminal, clock:)
|
|
123
|
+
@terminal = terminal
|
|
124
|
+
@clock = clock
|
|
125
|
+
@roots = []
|
|
126
|
+
@live = nil
|
|
127
|
+
@lock = Mutex.new
|
|
128
|
+
@frame = 0
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Declares the tree with the block, then runs it.
|
|
132
|
+
#
|
|
133
|
+
# @param title [String, nil] a heading printed above the tree
|
|
134
|
+
# @param concurrent [Boolean] run the top-level tasks at the same time
|
|
135
|
+
# @yieldparam tasks [Builder]
|
|
136
|
+
# @return [nil]
|
|
137
|
+
def run(title = nil, concurrent: false)
|
|
138
|
+
yield Builder.new(roots)
|
|
139
|
+
terminal.puts(terminal.pastel.bold(title)) if title
|
|
140
|
+
ticker = start_ticker
|
|
141
|
+
begin
|
|
142
|
+
run_all(roots, concurrent)
|
|
143
|
+
ensure
|
|
144
|
+
ticker&.shutdown
|
|
145
|
+
ticker&.wait_for_termination(1)
|
|
146
|
+
skip_pending
|
|
147
|
+
end
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
# @return [Terminal]
|
|
154
|
+
attr_reader :terminal
|
|
155
|
+
|
|
156
|
+
# @return [#call]
|
|
157
|
+
attr_reader :clock
|
|
158
|
+
|
|
159
|
+
# @return [Array<Node>]
|
|
160
|
+
attr_reader :roots
|
|
161
|
+
|
|
162
|
+
# @return [Mutex] held while the tree changes or is drawn
|
|
163
|
+
attr_reader :lock
|
|
164
|
+
|
|
165
|
+
# @return [Integer] the spinner frame to draw next
|
|
166
|
+
attr_accessor :frame
|
|
167
|
+
|
|
168
|
+
# @param nodes [Array<Node>]
|
|
169
|
+
# @param concurrent [Boolean]
|
|
170
|
+
# @return [void]
|
|
171
|
+
def run_all(nodes, concurrent)
|
|
172
|
+
return nodes.each { |node| execute(node) } unless concurrent
|
|
173
|
+
|
|
174
|
+
futures = nodes.map { |node| Concurrent::Promises.future(node) { |each| execute(each) } }
|
|
175
|
+
futures.each(&:wait)
|
|
176
|
+
failed = futures.find(&:rejected?)
|
|
177
|
+
raise failed.reason if failed
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# @param node [Node]
|
|
181
|
+
# @return [void]
|
|
182
|
+
def execute(node)
|
|
183
|
+
started = clock.call
|
|
184
|
+
change(node, :running)
|
|
185
|
+
ok = false
|
|
186
|
+
node.group? ? run_all(node.children, node.concurrent?) : node.job.call
|
|
187
|
+
ok = true
|
|
188
|
+
ensure
|
|
189
|
+
change(node, ok ? :done : :failed, seconds: clock.call - started)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# @return [void]
|
|
193
|
+
def skip_pending
|
|
194
|
+
rows.each_key { |node| change(node, :skipped) if node.state == :pending }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# @param node [Node]
|
|
198
|
+
# @param state [Symbol]
|
|
199
|
+
# @param seconds [Float, nil]
|
|
200
|
+
# @return [void]
|
|
201
|
+
def change(node, state, seconds: nil)
|
|
202
|
+
lock.synchronize do
|
|
203
|
+
node.state = state
|
|
204
|
+
node.seconds = seconds
|
|
205
|
+
if live?
|
|
206
|
+
redraw
|
|
207
|
+
elsif settled?(node)
|
|
208
|
+
terminal.puts(line(node))
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Whether a node has reached the state plain output reports: a group
|
|
214
|
+
# once it starts, so its children appear beneath it, and a task once
|
|
215
|
+
# it ends.
|
|
216
|
+
#
|
|
217
|
+
# @param node [Node]
|
|
218
|
+
# @return [Boolean]
|
|
219
|
+
def settled?(node)
|
|
220
|
+
return true if node.state == :skipped
|
|
221
|
+
|
|
222
|
+
node.group? ? node.state == :running : node.state != :running
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Whether to redraw in place. Needs cursor movement, and a tree that
|
|
226
|
+
# fits on screen: the cursor cannot move above the top row.
|
|
227
|
+
#
|
|
228
|
+
# @return [Boolean]
|
|
229
|
+
def live?
|
|
230
|
+
@live = terminal.animated? && rows.size < terminal.height if @live.nil?
|
|
231
|
+
@live
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Draws the tree and starts turning the spinners, when live.
|
|
235
|
+
#
|
|
236
|
+
# @return [Concurrent::TimerTask, nil]
|
|
237
|
+
def start_ticker
|
|
238
|
+
return unless live?
|
|
239
|
+
|
|
240
|
+
rows.each_key { |node| terminal.puts(line(node)) }
|
|
241
|
+
Concurrent::TimerTask.new(execution_interval: INTERVAL) { tick }.tap(&:execute)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# @return [void]
|
|
245
|
+
def tick
|
|
246
|
+
lock.synchronize do
|
|
247
|
+
self.frame += 1
|
|
248
|
+
redraw
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# @return [void]
|
|
253
|
+
def redraw
|
|
254
|
+
terminal.print(terminal.cursor.up(rows.size))
|
|
255
|
+
rows.each_key { |node| terminal.print("#{terminal.cursor.clear_line}#{line(node)}\n") }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# @param node [Node]
|
|
259
|
+
# @return [String]
|
|
260
|
+
def line(node)
|
|
261
|
+
pastel = terminal.pastel
|
|
262
|
+
glyph, color = Theme::STATES.fetch(node.state)
|
|
263
|
+
glyph = FRAMES[frame % FRAMES.size] if node.state == :running && live?
|
|
264
|
+
elapsed = " #{pastel.bright_black("(#{Duration.format(node.seconds)})")}" if node.seconds
|
|
265
|
+
"#{pastel.bright_black(rows.fetch(node))}#{pastel.decorate(glyph, color)} #{node.name}#{elapsed}"
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Every node in display order, with the tree branch drawn before it.
|
|
269
|
+
#
|
|
270
|
+
# @return [Hash{Node => String}]
|
|
271
|
+
def rows
|
|
272
|
+
@rows ||= layout(roots, "", {})
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# @param nodes [Array<Node>]
|
|
276
|
+
# @param prefix [String]
|
|
277
|
+
# @param acc [Hash{Node => String}]
|
|
278
|
+
# @return [Hash{Node => String}]
|
|
279
|
+
def layout(nodes, prefix, acc)
|
|
280
|
+
nodes.each_with_index do |node, index|
|
|
281
|
+
last = index == nodes.size - 1
|
|
282
|
+
acc[node] = "#{prefix}#{last ? '└─ ' : '├─ '}"
|
|
283
|
+
layout(node.children, "#{prefix}#{last ? ' ' : '│ '}", acc)
|
|
284
|
+
end
|
|
285
|
+
acc
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dry
|
|
4
|
+
class CLI
|
|
5
|
+
module UI
|
|
6
|
+
# The renderers behind {Console}. Each one takes a {Terminal} and owns
|
|
7
|
+
# both its rich form and its plain fallback. Nothing outside this
|
|
8
|
+
# namespace touches a TTY toolkit class.
|
|
9
|
+
module Widgets
|
|
10
|
+
autoload :Box, File.expand_path("widgets/box", __dir__)
|
|
11
|
+
autoload :Outcome, File.expand_path("widgets/outcome", __dir__)
|
|
12
|
+
autoload :Progress, File.expand_path("widgets/progress", __dir__)
|
|
13
|
+
autoload :Prompt, File.expand_path("widgets/prompt", __dir__)
|
|
14
|
+
autoload :Spinner, File.expand_path("widgets/spinner", __dir__)
|
|
15
|
+
autoload :Status, File.expand_path("widgets/status", __dir__)
|
|
16
|
+
autoload :Table, File.expand_path("widgets/table", __dir__)
|
|
17
|
+
autoload :Tasks, File.expand_path("widgets/tasks", __dir__)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/dry/cli/ui.rb
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ui/version"
|
|
4
|
+
|
|
5
|
+
# The dry-rb namespace, which dry-cli lives in. This gem is not part of dry-rb.
|
|
6
|
+
module Dry
|
|
7
|
+
class CLI
|
|
8
|
+
# Runtime presentation for Dry::CLI commands: boxes, status lines,
|
|
9
|
+
# spinners, progress bars, task trees, tables and prompts.
|
|
10
|
+
#
|
|
11
|
+
# Include it in a command, or in the base class every command inherits
|
|
12
|
+
# from, and call {#ui}. Everything else loads the first time it is used,
|
|
13
|
+
# so including the module costs a command nothing at boot.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# class Import < Dry::CLI::Command
|
|
17
|
+
# include Dry::CLI::UI
|
|
18
|
+
#
|
|
19
|
+
# def call(**)
|
|
20
|
+
# ui.spinner("Loading YAML") { load_rules }
|
|
21
|
+
# ui.success "Imported #{rules.size} rules"
|
|
22
|
+
# rescue => e
|
|
23
|
+
# ui.error("Import failed", e.message)
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
module UI
|
|
27
|
+
# Base class for every error this gem raises.
|
|
28
|
+
class Error < StandardError; end
|
|
29
|
+
|
|
30
|
+
# Raised when a prompt needs an answer, no default was given, and the
|
|
31
|
+
# input stream is exhausted.
|
|
32
|
+
class NonInteractiveError < Error; end
|
|
33
|
+
|
|
34
|
+
autoload :Console, File.expand_path("ui/console", __dir__)
|
|
35
|
+
autoload :Duration, File.expand_path("ui/duration", __dir__)
|
|
36
|
+
autoload :Terminal, File.expand_path("ui/terminal", __dir__)
|
|
37
|
+
autoload :Theme, File.expand_path("ui/theme", __dir__)
|
|
38
|
+
autoload :Widgets, File.expand_path("ui/widgets", __dir__)
|
|
39
|
+
|
|
40
|
+
# The console this command presents through. Writes to the command's own
|
|
41
|
+
# `out` and `err` when dry-cli has set them, and to `$stdout` and
|
|
42
|
+
# `$stderr` otherwise.
|
|
43
|
+
#
|
|
44
|
+
# Override it to configure the console:
|
|
45
|
+
#
|
|
46
|
+
# @example
|
|
47
|
+
# def ui = @ui ||= Dry::CLI::UI::Console.new(box_width: 72)
|
|
48
|
+
#
|
|
49
|
+
# @return [Dry::CLI::UI::Console]
|
|
50
|
+
def ui
|
|
51
|
+
@ui ||= Console.new(
|
|
52
|
+
out: (respond_to?(:out, true) && __send__(:out)) || $stdout,
|
|
53
|
+
err: (respond_to?(:err, true) && __send__(:err)) || $stderr
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/dry-cli-ui.rb
ADDED
data/sig/dry/cli/ui.rbs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Dry
|
|
2
|
+
class CLI
|
|
3
|
+
module UI
|
|
4
|
+
VERSION: String
|
|
5
|
+
|
|
6
|
+
class Error < StandardError
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class NonInteractiveError < Error
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def ui: () -> Console
|
|
13
|
+
|
|
14
|
+
class Console
|
|
15
|
+
def debug: (*_ToS paragraphs, ?width: Integer?) -> nil
|
|
16
|
+
def info: (*_ToS paragraphs, ?width: Integer?) -> nil
|
|
17
|
+
def success: (*_ToS paragraphs, ?width: Integer?) -> nil
|
|
18
|
+
def warn: (*_ToS paragraphs, ?width: Integer?) -> nil
|
|
19
|
+
def error: (*_ToS paragraphs, ?width: Integer?) -> nil
|
|
20
|
+
def fatal: (*_ToS paragraphs, ?width: Integer?) -> nil
|
|
21
|
+
def box: (*_ToS paragraphs, ?title: String?, ?level: Symbol?, ?width: Integer?) -> nil
|
|
22
|
+
def status: (*_ToS words, ?level: Symbol) -> nil
|
|
23
|
+
def spinner: [T] (String label) { () -> T } -> T
|
|
24
|
+
def progress: [T] (String label, total: Integer) { (untyped progress) -> T } -> T
|
|
25
|
+
def tasks: (?String? title, ?concurrent: bool) { (untyped tasks) -> void } -> nil
|
|
26
|
+
def table: (Array[Array[_ToS]] rows, ?header: Array[_ToS]?) -> nil
|
|
27
|
+
def prompt: (String question, ?default: untyped, ?choices: (Array[String] | Hash[String, untyped])?) -> untyped
|
|
28
|
+
def confirm: (String question, ?default: bool) -> bool
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|