Runtime terminal UI for dry-cli commands: spinners, progress bars, boxes, status lines, task trees, tables and prompts.
Note
The design, and the reasons behind it, are in SPECIFICATION.
A long-running command has more to say than puts can show well: what it is doing now, how far along it is, what went wrong. Include one module and the command gets a ui that says it, in colour and in place on a terminal, and as plain lines when the output is piped to a file or a CI log.
gem "dry-cli-ui"Requires Ruby 4.0 or later and dry-cli 1.0 or later. The rendering comes from the TTY toolkit (tty-box, tty-spinner, tty-progressbar, tty-table, tty-prompt, tty-cursor, tty-screen), pastel, strings and concurrent-ruby, which Bundler installs with the gem.
Either require works, and both load the same file:
require "dry-cli-ui" # matches the gem name
require "dry/cli/ui" # matches the constant Dry::CLI::UIrequire "dry/cli"
require "dry-cli-ui"
class Import < Dry::CLI::Command
include Dry::CLI::UI
def call(**)
ui.info "Importing tax rules..."
rules = ui.spinner("Loading tax rules") { load_rules }
ui.progress("Importing rules", total: rules.size) do |bar|
rules.each do |rule|
import(rule)
bar.advance
end
end
ui.success "Imported #{rules.size} rules"
rescue => e
ui.error("Import failed", e.message)
end
endWhen the output is piped, and the import fails part way:
Loading tax rules...
✓ Loading tax rules (0.3s)
Importing rules...
𝘅 Importing rules 1482/1900 (4.1s)
┌─ Error ────────────────────────────────────────────────────────────────────┐
│ │
│ Import failed │
│ │
│ Could not validate rule US.2026.IRC.199A: missing dependency │
│ taxable_income │
│ │
└────────────────────────────────────────────────────────────────────────────┘
A stream that is not a terminal is taken to be 80 columns wide, so a piped box is 78.
On a terminal the spinner turns and the bar fills in place, with percent, count and ETA, and each is replaced by the same ✓ or 𝘅 line when its block ends.
Include the module once in a base class and every command has ui. Including it loads nothing: the TTY gems load the first time ui is used.
class ApplicationCommand < Dry::CLI::Command
include Dry::CLI::UI
end
class Import < ApplicationCommand
def call(**) = ui.success("Nothing to import")
endui writes to the command's out and err when dry-cli has set them, and to $stdout and $stderr otherwise.
Dry::CLI::UI::Console needs nothing from dry-cli, so a Rake task or a plain script can use it directly:
require "dry-cli-ui"
ui = Dry::CLI::UI::Console.new
ui.spinner("Compacting the database") { compact! }
ui.success "Done"ui.debug "Resolved 1900 rules from 14 files"
ui.info "Importing tax rules..."
ui.success "Imported 1900 rules"
ui.warn "3 rules have no effective date"
ui.error "Import failed", e.message
ui.fatal "Database unreachable"Each prints a blank line, then a box with a single white border and the level's name as a coloured title, and returns nil. Every argument is a paragraph, wrapped to fit, with a blank line between paragraphs. The box fills the terminal less a two-column margin, or takes a fixed width:
ui.info "Short and narrow", width: 40
┌─ Info ───────────────────────────────┐
│ │
│ Short and narrow │
│ │
└──────────────────────────────────────┘
A width: is never wider than the terminal less its margin, and never narrower than 20 columns.
| Method | Title | Colour | Stream |
|---|---|---|---|
debug |
Debug | gray | err |
info |
Info | cyan | out |
success |
Success | green | out |
warn |
Warning | yellow | err |
error |
Error | red | err |
fatal |
Fatal | magenta | err |
ui.box is the general form. Without level: it is untitled unless given a title:, uncoloured, and goes to out:
ui.box "Name: Alan Turing", "Role: Cryptanalyst", title: "Profile", width: 40
┌─ Profile ────────────────────────────┐
│ │
│ Name: Alan Turing │
│ │
│ Role: Cryptanalyst │
│ │
└──────────────────────────────────────┘
With level: it takes that level's colour and stream, and its title unless title: replaces it:
ui.box "Disk is 91% full", level: :warn, title: "Disk" # a yellow "Disk" box on err
ui.box "Plain and untitled" # no title, on outAn unknown level raises ArgumentError.
ui.popup "h help", "q quit", title: "Keys"On a terminal, a box drawn over whatever is on the screen: only as wide as its text (at least 20 columns, at most width: or the box width), centred, and leaving the cursor where it was, so a spinner or a redrawn screen carries on underneath. Piped, it is the same box ui.box draws, on err:
┌─ Keys ─────────────────────┐
│ │
│ h help │
│ │
│ q quit │
│ │
└────────────────────────────┘
One line with a coloured glyph, on the level's stream. The level defaults to :info, and every argument is joined with a space:
ui.status "Connected to the database", level: :success # ✓ Connected to the database
ui.status "Disk nearly full", level: :warn # ⚠ Disk nearly full
ui.status "Loaded", rules.size, "rules" # ℹ Loaded 1900 rules| Level | Glyph | Stream |
|---|---|---|
:debug |
· |
err |
:info |
ℹ |
out |
:success |
✓ |
out |
:warn |
⚠ |
err |
:error |
✗ |
err |
:fatal |
✖ |
err |
rules = ui.spinner("Loading tax rules") { load_rules }Returns the block's value. Leaves ✓ Loading tax rules (0.3s) behind, or 𝘅 and the re-raised error when the block fails.
The block is given a Dry::CLI::UI::Line, for work that has more to say while it runs, or that can fail without raising:
ui.spinner("Importing rules") do |line|
rules.each { |rule| line.detail = rule.name; import(rule) }
line.fail("#{skipped.size} rules skipped") if skipped.any?
endline.detail = "..." shows text after the label, redrawn in place as it changes. Piped, the detail is kept and never printed, since it can change many times a second. line.fail(reason) ends the spinner as 𝘅 Importing rules: 3 rules skipped (4.1s) without raising, and the block's value is still returned. line.failed?, line.reason and line.detail read it back. Every Line method is safe to call from any thread.
Piped, each of these prints its label with ... when it starts, then its outcome:
Loading tax rules...
✓ Loading tax rules (0.3s)
Importing rules...
𝘅 Importing rules: 3 rules skipped (4.1s)
A block that raises leaves 𝘅 Loading tax rules (0.3s) and the error propagates, so rescue it around the call. A lambda that takes no arguments is called without a Line, so a method object or a stored lambda can be passed as it is:
load = -> { YAML.load_file("rules.yml") }
rules = ui.spinner("Loading tax rules", &load)Without a block, spinner raises ArgumentError. The same is true of progress, multi_spinner, multi_progress, tasks and status_bar.
Elapsed times read 0.4s under a minute, 1m 02s under an hour, and 1h 02m after that.
ui.progress("Importing rules", total: rules.size) do |bar|
rules.each do |rule|
import(rule)
bar.advance # or bar.advance(10)
end
endThe bar shows percent, current/total and ETA, Importing rules [◼◼◼◼◼◼ ] 61% 1159/1900 ETA 2.7s, and ends with ✓ Importing rules 1900/1900 (4.2s). On a terminal the ◼s are green, between brackets, on no background; see Configuration to change either.
The block is given a Dry::CLI::UI::Widgets::Progress::Handle, never the underlying TTY::ProgressBar:
ui.progress("Copying", total: files.sum(&:size)) do |bar|
files.each do |file|
copy(file) { |bytes| bar.advance(bytes) }
ui.status "#{bar.current} of #{bar.total} bytes" if bar.current == bar.total
end
:copied # progress returns the block's value
endadvance(step = 1)adds tocurrentand returns the handle;currentnever passestotal.total = nsets the total once the work finds out, such as a download learning its size, and lowerscurrentto fit. Anything but a non-negative Integer raisesArgumentError.total:must be a non-negative Integer, orprogressraisesArgumentError.total: 0draws no bar, and ends✓ Copying 0/0.color:paints this bar's finished part in any Pastel style, such ascolor: :red, instead of the configuredbar_color. A style Pastel does not know raisesArgumentErrorbefore the block runs.- The outcome is
✓whenever the block returns, even short of the total (✓ Copying 12/20), and𝘅when it raises.
Piped, it prints Importing rules... when it starts and the outcome line when it ends, with no bar in between.
ui.multi_spinner("Fetching", concurrent: 2) do |m|
m.spinner("fonts") { fetch(:fonts) }
m.spinner("images") do |line|
fetch(:images) { |done, all| line.detail = "#{done} of #{all}" }
end
m.spinner("video") { fetch(:video) }
endThe block declares the jobs; they run once it returns, all at once by default, or at most concurrent: 2 at a time. It returns what each job returned, in declaration order. While they run, every job has a row of its own under a headline spinner, and a job still waiting shows [ ]:
[⠹] Fetching
├─ [⠹] fonts
├─ [⠹] images 12 of 40
└─ [ ] video
Once they finish, the headline ends ✓, or 𝘅 when any job failed:
[𝘅] Fetching (0.5s)
├─ [✓] fonts (0.3s)
├─ [𝘅] images: 2 timed out (0.3s)
└─ [✓] video (0.2s)
Each job is given a Line, as a single spinner's block is. When a job raises, jobs already running finish, jobs not yet started are marked skipped ([—]), and the first error is re-raised. A job that never ran has nil in the returned array, which matters only when you rescue the error.
concurrent: takes true (all at once, the default), false (one at a time, in order), or a positive Integer. Anything else, 0 included, raises ArgumentError before any job runs.
Piped, it prints Fetching..., then each job's outcome as it ends, then the headline's:
Fetching...
[✓] fonts (0.3s)
[𝘅] images: 2 timed out (0.3s)
[✓] video (0.2s)
𝘅 Fetching (0.5s)
When a job raises under concurrent: 1:
Fetching...
[𝘅] fonts (0.1s)
[—] images
[—] video
𝘅 Fetching (0.1s)
On a terminal with fewer rows than the widget needs, the cursor cannot reach them all, so only the jobs running are shown under the headline, as many as fit. Jobs leave the screen as they end, and the headline is all that remains:
[⠋] Fetching
├─ [⠋] video
└─ [⠋] audio
ui.multi_progress("Downloading") do |m|
files.each do |file|
m.progress(file.name, total: file.size) do |bar|
download(file) { |bytes| bar.advance(bytes) }
end
end
endThe same shape as multi_spinner, with a bar per job and a headline bar that counts them all. Bars start and end in the same columns, and counts are right-aligned:
[⠋] Downloading [◼◼◼◼ ] 27% 66/240 ETA 2.1s
├─ [⠋] fonts.zip [◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ ] 95% 38/40 ETA 0.1s
├─ [⠋] images.tar.gz [◼◼◼ ] 23% 28/120 ETA 2.4s
└─ [ ] video.mp4
Each job is given the same handle as ui.progress, with advance(step = 1), current and total. m.progress takes color: as ui.progress does, so bars side by side can differ:
ui.multi_progress("Probing #{hosts.size} hosts", total: hosts.size) do |m|
m.progress("Answered", total: hosts.size, color: :green) { |bar| ... }
m.progress("No answer", total: hosts.size, color: :red) { |bar| ... }
endThe headline bar keeps the configured colour. A finished job's row reads [✓] fonts.zip 40/40 (0.1s), and the headline's [✓] Downloading 240/240 (0.3s). It returns what each job returned, in declaration order, and takes concurrent: as multi_spinner does. Every m.progress needs a block, and a total: that is nil or a non-negative Integer, or raises ArgumentError.
A job that learns its size only as it runs declares total: nil and sets bar.total = once it knows. Until then its bar is empty and its count reads 12/?.
The headline bar adds up every job's bar. Two options change that:
count: :jobscounts the jobs that have ended, out of all of them, whatever each bar measures.total: nsets the headline's total, for bars that overlap, such as two bars that each count some of the same hosts.
ui.multi_progress("Downloading", concurrent: 2, count: :jobs) do |m|
files.each do |file|
m.progress(file.name, total: nil) do |bar|
bar.total = size_of(file)
download(file) { |bytes| bar.advance(bytes) }
end
end
endPiped, where images.tar.gz never learned its size:
Downloading...
[✓] fonts.zip 40/40 (0.1s)
[✓] images.tar.gz 120/? (0.1s)
[✓] video.mp4 80/80 (0.1s)
✓ Downloading 3/3 (0.1s)
Any other count:, or a total: that is not a non-negative Integer, raises ArgumentError.
Piped:
Downloading...
[✓] fonts.zip 40/40 (0.1s)
[✓] images.tar.gz 120/120 (0.2s)
[✓] video.mp4 80/80 (0.3s)
✓ Downloading 240/240 (0.3s)
With multi_spinner, each URL gets a spinner, and the call returns every page in the same order as urls. Nothing writes to a shared file from several threads:
bodies = ui.multi_spinner("Fetching #{urls.size} URLs", concurrent: 8) do |m|
urls.each { |url| m.spinner(url) { fetch(url) } }
end
File.write("urls.txt", bodies.join("\n"))With multi_progress, each URL gets a bar that fills as the body arrives, and the headline counts URLs. When a URL's turn comes, a HEAD request asks for its size, which becomes the bar's total:
bodies = ui.multi_progress("Fetching #{urls.size} URLs", concurrent: 8, count: :jobs) do |m|
urls.each do |url|
m.progress(url, total: nil) do |bar|
size = content_length(url)
bar.total = size if size
body = download(url) { |bytes| bar.advance(bytes) }
bar.total = bar.current unless size # no Content-Length: full once done
body
end
end
end
File.write("urls.txt", bodies.join("\n"))The two helpers, with Net::HTTP:
require "net/http"
def content_length(url)
uri = URI(url)
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
http.head(uri.request_uri).content_length # nil when the server does not say
end
end
def download(url)
uri = URI(url)
body = +""
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
http.request_get(uri.request_uri) do |response|
response.read_body do |chunk|
body << chunk
yield chunk.bytesize
end
end
end
body
endIn both, at most eight requests run at once, in the order of urls, and the rest wait as [ ] rows. A server that sends no Content-Length leaves its bar empty, counting 12/?, until its download ends. These helpers are kept short: a real one follows redirects, sends Accept-Encoding: identity so the bytes counted match Content-Length, and writes each body to its file as it arrives rather than holding it in memory; examples/bin/mycli download-urls does all three. With more URLs than the screen has rows, only the URLs downloading are shown.
ui.stoppable runs its block with Ctrl-C asking for a stop instead of killing the command. Hand the stop it yields to multi_spinner or multi_progress as stop:: once Ctrl-C is pressed, the jobs running finish, no more start, and the command carries on to report what it did. A second Ctrl-C interrupts as usual.
ui.stoppable do |stop|
files = ui.multi_spinner("Downloading #{urls.size} URLs", concurrent: 10, stop: stop) do |m|
urls.each { |url| m.spinner(url) { download(url) } }
end
next ui.success("Downloaded #{files.size} URLs") unless stop.stopped?
ui.info "Stopped", "Downloaded #{files.compact.size} of #{urls.size} URLs"
endWhile the running jobs finish, the headline reads [⠋] Downloading 1628 URLs stopping. Jobs that never started are marked skipped and return nil, and the headline ends skipped too, unless a job failed. Piped:
Fetching...
[✓] fonts (0.1s)
[—] images
[—] video
— Fetching (0.1s)
stop.stopped? says whether a stop was asked for, and stop.stop! asks for one from code, from any thread. Dry::CLI::UI::Stop.new.trap(signal) traps a signal other than INT. Your own worker threads can check stop.stopped? before taking the next item, as examples/bin/mycli find-hosts does. With concurrent: true every job has started already, so there is nothing left to skip.
The block declares the tasks; they run once it returns, so the tree is drawn complete before the first one starts.
ui.tasks("Deploy") do |t|
t.task("Build assets") { build }
t.group("Migrate") do |g|
g.task("users") { migrate(:users) }
g.task("orders") { migrate(:orders) }
end
t.group("Warm caches", concurrent: true) do |g|
g.task("fonts") { warm(:fonts) }
g.task("images") { warm(:images) }
end
t.task("Restart") { restart }
endOn a terminal, once it finishes:
Deploy
├─ [✓] Build assets (0.4s)
├─ [✓] Migrate (0.3s)
│ ├─ [✓] users (0.1s)
│ └─ [✓] orders (0.2s)
├─ [✓] Warm caches (0.5s)
│ ├─ [✓] fonts (0.5s)
│ └─ [✓] images (0.3s)
└─ [✓] Restart (0.3s)
While it runs, the tree redraws in place and every running task has its own spinner. Every row is marked in brackets, in bold yellow while it waits, [ ], and while it runs, a turning [⠏]; then a green [✓] when it is done, a red [𝘅] when it failed, or a yellow [—] when it was skipped. Piped, each line is printed once it is final, and a group's line appears as [▸] when it starts. concurrent: true runs a group's tasks at the same time, on a group or on ui.tasks itself, and concurrent: 3 runs at most three at once. Without it, tasks run one after another. When a task raises, it is marked [𝘅], tasks already running finish, the rest are marked skipped ([—]), and the error is re-raised. ui.tasks returns nil, and its title is optional: ui.tasks { |t| ... } draws the tree without a heading.
Piped, the same tree as the example above, with one migration failing:
Deploy
├─ [✓] Build assets (0.4s)
├─ [▸] Migrate
│ ├─ [✓] users (0.1s)
│ └─ [𝘅] orders: table locked (0.2s)
├─ [▸] Warm caches
│ ├─ [✓] fonts (0.5s)
│ └─ [✓] images (0.3s)
└─ [✓] Restart (0.3s)
And when Build assets raises instead:
Deploy
├─ [𝘅] Build assets (0.4s)
├─ [—] Migrate
│ ├─ [—] users
│ └─ [—] orders
├─ [—] Warm caches
│ ├─ [—] fonts
│ └─ [—] images
└─ [—] Restart
task and group each need a block, and concurrent: takes the same values as on the multi widgets; either mistake raises ArgumentError while the tree is being declared, before anything runs.
Each task is given a Line, as a spinner's block is. Its detail is drawn after the task's name while it runs, and line.fail(reason) marks the task 𝘅 name: reason and its groups 𝘅, while the rest of the tree runs on:
ui.tasks("Fetching", concurrent: 4) do |t|
assets.each do |asset|
t.task(asset.name) do |line|
fetch(asset) { |percent| line.detail = "#{percent}%" }
rescue Timeout::Error
line.fail("timed out")
end
end
endui.status_bar("deploy", hints: ["^C cancel"]) do
ui.spinner("Building assets") { build }
ui.multi_progress("Uploading", concurrent: 2) { |m| ... }
ui.tasks("Migrate") { |t| ... }
endWhile the block runs, the bottom of the screen shows how the whole command is doing, under a rule: what started last, how many things are running, done and failed, a bar over every progress bar so far, the elapsed time, and your hints at the right edge. Everything the widgets print scrolls above it, and it disappears when the block ends:
✓ Building assets (0.2s)
[⠙] Uploading [◼◼◼◼◼◼◼◼◼◼◼◼◼◼ ] 37% 49/132 ETA 0.3s
├─ [✓] app.js 40/40 (0.2s)
├─ [⠙] app.css [◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼◼ ] 75% 9/12 ETA 0.1s
└─ [⠙] fonts.zip [ ] 0% 0/80 ETA --
──────────────────────────────────────────────────────────────────────────────────────────
⠸ deploy · fonts.zip · 2 running · 2 done · [◼◼◼ ] 37% · 0.4s ^C cancel
Nothing reports to it by hand: every spinner, progress, multi_spinner, multi_progress and task started inside the block does so on its own. Hints that do not fit are left out, and a status that does not fit is cut short with ….
It sets no scroll region, so the scrollback keeps everything, and an interrupted command leaves nothing behind. Piped, or without animation, it just runs the block, and a status_bar inside another one does the same. Either way it returns the block's value. Write through ui while it runs: a bare puts lands where the bar is, until the next ui call draws the bar again.
Both arguments are optional, and hints: takes one string or several:
report = ui.status_bar { build_report } # no title, no hints
ui.status_bar("sync", hints: "q quit") { sync }
ui.status_bar("sync", hints: ["^C cancel", "? help"]) { sync }class Deploy < Dry::CLI::Command
include Dry::CLI::UI
def call(**)
ui.status_bar("deploy", hints: ["^C cancel"]) do
assets = ui.spinner("Building assets") { build_assets }
ui.multi_progress("Uploading", concurrent: 3) do |m|
assets.each do |asset|
m.progress(asset.name, total: asset.bytesize) do |bar|
upload(asset) { |sent| bar.advance(sent) }
end
end
end
ui.multi_spinner("Warming caches") do |m|
regions.each do |region|
m.spinner(region) do |line|
warm(region) { |host| line.detail = host }
end
end
end
end
ui.success "Deployed #{assets.size} assets"
rescue => e
ui.error("Deploy failed", e.message)
end
endui.table([["Alan Turing", 41], ["Ada Lovelace", 36]], header: %w[Name Age])┌──────────────┬─────┐
│ Name │ Age │
├──────────────┼─────┤
│ Alan Turing │ 41 │
│ Ada Lovelace │ 36 │
└──────────────┴─────┘
Tables go to out and return nil. Cells are converted with to_s, the header is bold on a colour terminal, header: is optional, and an empty rows prints nothing. Tables are never truncated or rotated to fit the screen: a table wider than the terminal wraps like any long line.
ui.table(User.limit(10).pluck(:email, :created_at)) # no headername = ui.prompt("Name?", default: "Alan Turing")
env = ui.prompt("Environment?", choices: %w[staging production], default: "staging")
tier = ui.prompt("Tier?", choices: { "Free" => :free, "Pro" => :pro })
ui.confirm("Deploy to #{env}?", default: false)promptreturns the answer as a String, or the default for an empty answer.- With an Array of
choices:, it returns the chosen name; with a Hash, the value the chosen name maps to (:proabove).default:is the name of a choice. confirmreturnstrueorfalse, anddefault:isfalseunless given.
When both standard input and err are terminals, these use arrow-key menus and line editing (TTY::Prompt). Otherwise they print the question to err and read lines from standard input, so answers can be piped:
printf 'production\ny\n' | mycli deployIn that mode a list of choices is numbered, and an answer may be the number or the name:
Name? [Alan Turing]
Environment?
1) staging
2) production
Choose 1-2 [staging]: 2
Tier?
1) Free
2) Pro
Choose 1-2: Pro
Deploy to production? (y/N) maybe
Please answer y or n.
Deploy to production? (y/N) yes
An answer that matches no choice asks again, and confirm accepts y, yes, n and no in any case. When the input runs out, a prompt returns its default, or raises Dry::CLI::UI::NonInteractiveError if it has none:
token = begin
ui.prompt("API token?")
rescue Dry::CLI::UI::NonInteractiveError
ENV.fetch("API_TOKEN")
end| Error | Raised when |
|---|---|
Dry::CLI::UI::NonInteractiveError |
a prompt has no answer left to read and no default |
Dry::CLI::UI::Error |
never directly: the base class of the gem's own errors, for rescue Dry::CLI::UI::Error |
ArgumentError |
a widget has no block, total: is not a non-negative Integer, concurrent: is invalid, a level or configuration is unknown |
Errors raised inside a block are never swallowed: the widget marks itself failed and re-raises them.
To out (results) |
To err (everything else) |
|---|---|
info, success, box, table, status at those levels |
debug, warn, error, fatal, popup, spinners, progress bars, their multi forms, task trees, the status bar, prompts |
mycli export > rules.csv therefore writes only the command's results to the file, while its progress stays on the screen. ui writes to the streams dry-cli was called with, so Dry::CLI.new(registry).call(out: io, err: io) captures everything.
A stream that is not a terminal, or runs under TERM=dumb, gets no animation, no cursor movement and no escape codes. NO_COLOR turns colour off and leaves animation on.
Spinners and bars look the same everywhere, and are set once for the whole process:
Dry::CLI::UI.configure do
spinner_format :dots # any TTY::Spinner format name
bar_format(complete: "◼", incomplete: " ") # or any TTY::ProgressBar bar format name, such as :box
bar_color :green # the finished part: any Pastel style, or nil
bar_background nil # the whole bar: any Pastel style, or nil
endThose are the defaults: spinners turn through ⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏ ten times a second, and bars draw a green ◼ for each finished part, with nothing behind them, and brackets show where the bar begins and ends. Every spinner reads the same format, including multi_spinner, task trees and the status bar, and every bar reads the same characters and colours, except a bar given its own color:. The formats also take a definition of your own:
Dry::CLI::UI.configure do |config|
config.spinner_format = { interval: 8, frames: %w[◐ ◓ ◑ ◒] } # frames per second, and the frames
config.bar_format = { complete: "#", incomplete: "." }
endAn unknown name or a malformed definition raises ArgumentError when it is set, as does a colour Pastel does not know:
Dry::CLI::UI.configure { bar_color :nope }
# => ArgumentError: bar_color must be a Pastel style or nil, got :nopeCalled without a value, each setting reads it back, and Dry::CLI::UI.config returns the configuration itself. Dry::CLI::UI.reset! restores every default, which is useful between specs:
Dry::CLI::UI.config.bar_color # => :green
Dry::CLI::UI.config.spinner_frames # => ["⠋", "⠙", "⠹", ...]
RSpec.configure { |c| c.after { Dry::CLI::UI.reset! } }Some more looks, all from names the TTY gems already know:
Dry::CLI::UI.configure do
spinner_format :classic # | / - \
bar_format :block # █ and ░
bar_color :cyan
bar_background :on_blue # a blue track the whole bar's width
endOverride ui to configure the console:
class ApplicationCommand < Dry::CLI::Command
include Dry::CLI::UI
def ui
@ui ||= Dry::CLI::UI::Console.new(
out: out || $stdout,
err: err || $stderr,
box_width: 72, # boxes are 72 columns rather than the whole terminal
color: nil, # true or false to override detection
animate: nil # true or false to override detection
)
end
endEvery option Console.new takes:
| Option | Default | What it does |
|---|---|---|
out: |
$stdout |
where results go |
err: |
$stderr |
where diagnostics, progress and prompts go |
input: |
$stdin |
where prompt answers are read from |
env: |
ENV |
read for NO_COLOR and TERM |
color: |
nil |
true or false forces colour on both streams; nil detects it |
animate: |
nil |
true or false forces animation on both streams; nil detects it |
width: |
nil |
forces the terminal width in columns; nil asks the terminal, or 80 |
box_width: |
nil |
the width of every box; nil fills the terminal less two columns |
clock: |
monotonic clock | any object whose call returns seconds, for elapsed times |
config: |
Dry::CLI::UI.config |
a Dry::CLI::UI::Configuration for this console alone |
config: gives one console a look of its own without changing the process-wide one:
classic = Dry::CLI::UI::Configuration.new.tap { |c| c.spinner_format = :classic }
ui = Dry::CLI::UI::Console.new(config: classic)Pass StringIOs and assert on what was written. A StringIO is not a terminal, so the output is the plain form shown throughout this README, with no escape codes:
out = StringIO.new
err = StringIO.new
ui = Dry::CLI::UI::Console.new(out: out, err: err, input: StringIO.new("y\n"))
ui.spinner("Loading") { :ok }
ui.success "Imported"
ui.confirm("Continue?") # => true
err.string # => "Loading...\n✓ Loading (0.0s)\nContinue? (y/N) "
out.string # => the Success boxThrough dry-cli, Dry::CLI.new(registry).call(arguments: %w[import], out: out, err: err) gives every command's ui those streams.
dry-cli-help is static presentation: what does this command do? dry-cli-ui is runtime presentation: what is this command doing? Use either, or both.
gem "dry-cli"
gem "dry-cli-help"
gem "dry-cli-ui"examples/ holds a small dry-cli application that uses the gem:
cd examples
bundle install
bundle exec bin/mycli primes --max 200000
bundle exec bin/mycli urls_spinner https://www.ruby-lang.org https://dry-rb.org
bundle exec bin/mycli urls_progress https://www.ruby-lang.org https://dry-rb.org
bundle exec bin/mycli urls_progress https://www.ruby-lang.org | cat # the plain formbin/setup # bundle install
just test # the suite, with 100% line and branch coverage enforced
just lint # rubocop
just ci # both
just format # rubocop -a, then mdformat
just doc # YARD documentation
bin/console # IRB with the gem loadedSpecs render into a StringIO. The animated code paths run against FakeTTY, a StringIO that answers tty? with true, and elapsed times come from a fake clock. RBS signatures for the public API are in sig/dry/cli/ui.rbs.
Bug reports and pull requests are welcome at https://github.com/kigster/dry-cli-ui.
Warning
The dry- prefix and the Dry::CLI::UI namespace do not imply endorsement by dry-rb. This is an independent gem that extends theirs.
First — hats off to all of you who tirelessly built out one of the most valuable collections of libraries in the Ruby ecosystem.
While I admire and would be willing to contribute any or all of the extension gem's code to the original gem, I feel that creating plugins and extensions allows the author to fully express their needs and wants, and then, if the authors of dry-cli become interested in any of them, I would be honored to submit a PR to dry-cli itself.
This method offered a very open road to extensibility and experimentation. If the code quality or design is not up to the level required for direct contributions to dry-rb, then let it be known that:
- We would be very happy to receive any feedback and improve, refactor, and update the gem assuming it improves it
- Roll any part of the codebase as a PR to the
dry-clicore. - We hold the authors of
dry-rbin high regard, and generally would love to collaborate, as long as the feedback loop/cycle is not so long that the context of the changes gets lost in time, as with so many contributions made to other gems in the past.
MIT. See LICENSE.txt.