r2ui 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +64 -0
- data/README.md +79 -0
- data/lib/r2ui/app.rb +311 -34
- data/lib/r2ui/canvas.rb +135 -14
- data/lib/r2ui/cli/builder.rb +72 -0
- data/lib/r2ui/cli/context.rb +45 -0
- data/lib/r2ui/cli/definition.rb +174 -0
- data/lib/r2ui/cli/ext/ask.rb +133 -0
- data/lib/r2ui/cli/ext/box.rb +112 -0
- data/lib/r2ui/cli/ext/choose.rb +162 -0
- data/lib/r2ui/cli/ext/choose_many.rb +241 -0
- data/lib/r2ui/cli/ext/color_option.rb +64 -0
- data/lib/r2ui/cli/ext/completion.rb +520 -0
- data/lib/r2ui/cli/ext/config.rb +179 -0
- data/lib/r2ui/cli/ext/confirm.rb +110 -0
- data/lib/r2ui/cli/ext/dashboard.rb +105 -0
- data/lib/r2ui/cli/ext/diff.rb +195 -0
- data/lib/r2ui/cli/ext/editor.rb +94 -0
- data/lib/r2ui/cli/ext/env.rb +108 -0
- data/lib/r2ui/cli/ext/examples.rb +62 -0
- data/lib/r2ui/cli/ext/filter.rb +269 -0
- data/lib/r2ui/cli/ext/format.rb +104 -0
- data/lib/r2ui/cli/ext/list.rb +117 -0
- data/lib/r2ui/cli/ext/log.rb +89 -0
- data/lib/r2ui/cli/ext/pager.rb +97 -0
- data/lib/r2ui/cli/ext/pairs.rb +62 -0
- data/lib/r2ui/cli/ext/parallel.rb +274 -0
- data/lib/r2ui/cli/ext/password.rb +158 -0
- data/lib/r2ui/cli/ext/progress.rb +330 -0
- data/lib/r2ui/cli/ext/reference.rb +129 -0
- data/lib/r2ui/cli/ext/spinner.rb +165 -0
- data/lib/r2ui/cli/ext/summary.rb +63 -0
- data/lib/r2ui/cli/ext/table.rb +141 -0
- data/lib/r2ui/cli/ext/tasks.rb +216 -0
- data/lib/r2ui/cli/ext/trace.rb +64 -0
- data/lib/r2ui/cli/ext/tree.rb +114 -0
- data/lib/r2ui/cli/ext/version.rb +67 -0
- data/lib/r2ui/cli/extension.rb +129 -0
- data/lib/r2ui/cli/help.rb +115 -0
- data/lib/r2ui/cli/helpers.rb +20 -0
- data/lib/r2ui/cli/live.rb +150 -0
- data/lib/r2ui/cli/parser.rb +165 -0
- data/lib/r2ui/cli/prompt.rb +133 -0
- data/lib/r2ui/cli/runner.rb +86 -0
- data/lib/r2ui/cli/shell.rb +185 -0
- data/lib/r2ui/cli/testing.rb +49 -0
- data/lib/r2ui/cli.rb +129 -0
- data/lib/r2ui/compat/bubbletea/LICENSE-bubbletea.txt +21 -0
- data/lib/r2ui/compat/bubbletea/ansi.rb +424 -0
- data/lib/r2ui/compat/bubbletea/commands.rb +143 -0
- data/lib/r2ui/compat/bubbletea/input.rb +269 -0
- data/lib/r2ui/compat/bubbletea/input_reader.rb +66 -0
- data/lib/r2ui/compat/bubbletea/messages.rb +251 -0
- data/lib/r2ui/compat/bubbletea/model.rb +65 -0
- data/lib/r2ui/compat/bubbletea/native.rb +254 -0
- data/lib/r2ui/compat/bubbletea/renderer.rb +192 -0
- data/lib/r2ui/compat/bubbletea/runner.rb +425 -0
- data/lib/r2ui/compat/bubbletea/terminal.rb +230 -0
- data/lib/r2ui/compat/bubbletea/version.rb +8 -0
- data/lib/r2ui/compat/bubbletea/width_table.rb +228 -0
- data/lib/r2ui/compat/bubbletea.rb +16 -0
- data/lib/r2ui/compat/lipgloss/border.rb +17 -0
- data/lib/r2ui/compat/lipgloss/color.rb +78 -0
- data/lib/r2ui/compat/lipgloss/color_blend.rb +166 -0
- data/lib/r2ui/compat/lipgloss/colorful.rb +585 -0
- data/lib/r2ui/compat/lipgloss/layout.rb +461 -0
- data/lib/r2ui/compat/lipgloss/list.rb +85 -0
- data/lib/r2ui/compat/lipgloss/position.rb +30 -0
- data/lib/r2ui/compat/lipgloss/style/border.rb +134 -0
- data/lib/r2ui/compat/lipgloss/style.rb +1062 -0
- data/lib/r2ui/compat/lipgloss/table/render.rb +518 -0
- data/lib/r2ui/compat/lipgloss/table.rb +185 -0
- data/lib/r2ui/compat/lipgloss/termenv.rb +768 -0
- data/lib/r2ui/compat/lipgloss/text/unicode_tables.rb +428 -0
- data/lib/r2ui/compat/lipgloss/text.rb +1555 -0
- data/lib/r2ui/compat/lipgloss/tree/go_tree.rb +243 -0
- data/lib/r2ui/compat/lipgloss/tree.rb +105 -0
- data/lib/r2ui/compat/lipgloss/version.rb +15 -0
- data/lib/r2ui/compat/lipgloss.rb +64 -0
- data/lib/r2ui/compat/load_path/bubbletea.rb +4 -0
- data/lib/r2ui/compat/load_path/lipgloss.rb +4 -0
- data/lib/r2ui/component.rb +67 -0
- data/lib/r2ui/context.rb +87 -0
- data/lib/r2ui/drop_in.rb +16 -0
- data/lib/r2ui/dsl/column.rb +11 -3
- data/lib/r2ui/dsl/dashboard.rb +45 -9
- data/lib/r2ui/dsl/resource.rb +12 -0
- data/lib/r2ui/ext/after.rb +54 -0
- data/lib/r2ui/ext/async.rb +81 -0
- data/lib/r2ui/ext/batch_sequence.rb +54 -0
- data/lib/r2ui/ext/countdown.rb +52 -0
- data/lib/r2ui/ext/data_table.rb +71 -0
- data/lib/r2ui/ext/every.rb +66 -0
- data/lib/r2ui/ext/exec.rb +116 -0
- data/lib/r2ui/ext/file_picker.rb +78 -0
- data/lib/r2ui/ext/focus_report.rb +69 -0
- data/lib/r2ui/ext/help.rb +62 -0
- data/lib/r2ui/ext/keys.rb +64 -0
- data/lib/r2ui/ext/list.rb +145 -0
- data/lib/r2ui/ext/messages.rb +87 -0
- data/lib/r2ui/ext/mouse.rb +134 -0
- data/lib/r2ui/ext/paginator.rb +86 -0
- data/lib/r2ui/ext/paste.rb +86 -0
- data/lib/r2ui/ext/println.rb +21 -0
- data/lib/r2ui/ext/progress.rb +85 -0
- data/lib/r2ui/ext/refresh.rb +56 -0
- data/lib/r2ui/ext/resize.rb +62 -0
- data/lib/r2ui/ext/screen.rb +47 -0
- data/lib/r2ui/ext/screen_mode.rb +63 -0
- data/lib/r2ui/ext/spinner.rb +59 -0
- data/lib/r2ui/ext/state.rb +45 -0
- data/lib/r2ui/ext/stopwatch.rb +67 -0
- data/lib/r2ui/ext/suspend.rb +56 -0
- data/lib/r2ui/ext/text_area.rb +73 -0
- data/lib/r2ui/ext/text_input.rb +83 -0
- data/lib/r2ui/ext/theme.rb +112 -0
- data/lib/r2ui/ext/view.rb +37 -0
- data/lib/r2ui/ext/viewport.rb +92 -0
- data/lib/r2ui/ext/window_title.rb +51 -0
- data/lib/r2ui/extension.rb +199 -0
- data/lib/r2ui/history.rb +4 -1
- data/lib/r2ui/keys.rb +35 -8
- data/lib/r2ui/motion.rb +169 -0
- data/lib/r2ui/panel_state.rb +46 -2
- data/lib/r2ui/renderer.rb +87 -22
- data/lib/r2ui/version.rb +1 -1
- data/lib/r2ui/widgets/box.rb +6 -3
- data/lib/r2ui/widgets/glyphs.rb +149 -0
- data/lib/r2ui/widgets/table.rb +109 -25
- data/lib/r2ui.rb +12 -1
- metadata +119 -4
- data/lib/r2ui/terminal.rb +0 -53
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
module CLI
|
|
5
|
+
# One CLI capability in one file, the same shape as the dashboard DSL's R2UI.extension
|
|
6
|
+
# (docs/dsl.md). Files in lib/r2ui/cli/ext/ load automatically, in name order. See docs/cli.md
|
|
7
|
+
# and the reference ones, lib/r2ui/cli/ext/tasks.rb and confirm.rb.
|
|
8
|
+
#
|
|
9
|
+
# R2UI::CLI.extension :version do
|
|
10
|
+
# dsl(:command) { def version(v) = declare(:version, v) }
|
|
11
|
+
# setup { flag :version, short: "V", desc: "Print the version" if declared(:version).any? }
|
|
12
|
+
# before_run { (say(program.declared(:version).last) || halt) if options[:version] }
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# dsl(:command) { def kw ... end } keywords on Builder (`definition`, `declare`), at definition
|
|
16
|
+
# helpers { def h ... end } methods on Helpers (so on every Context too)
|
|
17
|
+
# setup { } on the root's Builder, after the R2UI.cli block
|
|
18
|
+
# after_parse { } on the Context, argv parsed, before defaults, required
|
|
19
|
+
# checks and positional arguments (`args` is empty here)
|
|
20
|
+
# before_run { } on the Context, before the command's run block
|
|
21
|
+
# after_run { } on the Context, after it returned normally
|
|
22
|
+
# help_header { |command, shell| } returns a String (or nil) shown first in --help
|
|
23
|
+
# help_section { |command, shell| } returns [heading, lines] (or nil) to append to --help
|
|
24
|
+
# on_error(Klass) { |error| } on the Context when an error escapes; returning an Integer
|
|
25
|
+
# makes it the exit code and skips the core's report
|
|
26
|
+
class Extension
|
|
27
|
+
TARGETS = { command: -> { Builder } }.freeze
|
|
28
|
+
|
|
29
|
+
Hook = Data.define(:extension, :matcher, :order, :block) do
|
|
30
|
+
def match?(value) = matcher.nil? || matcher === value # rubocop:disable Style/CaseEquality
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
attr_reader :name, :hooks
|
|
34
|
+
|
|
35
|
+
def initialize(name)
|
|
36
|
+
@name = name.to_sym
|
|
37
|
+
@modules = []
|
|
38
|
+
@hooks = Hash.new { |h, k| h[k] = [] }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def dsl(target = :command, &body)
|
|
42
|
+
builder = TARGETS.fetch(target) { raise Error, "unknown DSL target #{target.inspect}" }.call
|
|
43
|
+
add_module(builder, Module.new(&body))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def helpers(&body) = add_module(Helpers, Module.new(&body), also: Context)
|
|
47
|
+
|
|
48
|
+
def setup(&block) = hook(:setup, nil, block)
|
|
49
|
+
|
|
50
|
+
def after_parse(&block) = hook(:after_parse, nil, block)
|
|
51
|
+
|
|
52
|
+
def before_run(&block) = hook(:before_run, nil, block)
|
|
53
|
+
|
|
54
|
+
def after_run(&block) = hook(:after_run, nil, block)
|
|
55
|
+
|
|
56
|
+
def help_section(&block) = hook(:help_section, nil, block)
|
|
57
|
+
|
|
58
|
+
def help_header(&block) = hook(:help_header, nil, block)
|
|
59
|
+
|
|
60
|
+
def on_error(matcher = StandardError, &block) = hook(:on_error, matcher, block)
|
|
61
|
+
|
|
62
|
+
def remove!
|
|
63
|
+
@modules.each do |mod|
|
|
64
|
+
(mod.instance_methods(false) + mod.private_instance_methods(false)).each { |m| mod.send(:remove_method, m) }
|
|
65
|
+
end
|
|
66
|
+
@hooks.clear
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def hook(kind, matcher, block)
|
|
72
|
+
raise ArgumentError, "#{kind} needs a block" unless block
|
|
73
|
+
|
|
74
|
+
@hooks[kind] << Hook.new(extension: name, matcher:, order: Extensions.next_order, block:)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def add_module(target, mod, also: nil)
|
|
78
|
+
defined = mod.instance_methods(false) + mod.private_instance_methods(false)
|
|
79
|
+
taken = defined.select do |m|
|
|
80
|
+
[target, also].compact.any? { |t| t.method_defined?(m) || t.private_method_defined?(m) && t.instance_method(m).owner != Kernel }
|
|
81
|
+
end
|
|
82
|
+
raise Error, "extension #{name}: #{target} already has #{taken.join(", ")}" if taken.any?
|
|
83
|
+
|
|
84
|
+
target.include(mod)
|
|
85
|
+
@modules << mod
|
|
86
|
+
mod
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The loaded CLI extensions, in load order.
|
|
91
|
+
module Extensions
|
|
92
|
+
@all = {}
|
|
93
|
+
@order = 0
|
|
94
|
+
@lock = Mutex.new
|
|
95
|
+
|
|
96
|
+
class << self
|
|
97
|
+
def register(name, &block)
|
|
98
|
+
name = name.to_sym
|
|
99
|
+
raise Error, "CLI extension #{name} is already registered" if @all.key?(name)
|
|
100
|
+
|
|
101
|
+
extension = Extension.new(name)
|
|
102
|
+
@all[name] = extension
|
|
103
|
+
begin
|
|
104
|
+
extension.instance_eval(&block) if block
|
|
105
|
+
rescue StandardError
|
|
106
|
+
remove(name)
|
|
107
|
+
raise
|
|
108
|
+
end
|
|
109
|
+
extension
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def [](name) = @all[name.to_sym]
|
|
113
|
+
|
|
114
|
+
def names = @all.keys
|
|
115
|
+
|
|
116
|
+
# Unregisters an extension and empties its modules (for tests).
|
|
117
|
+
def remove(name) = @all.delete(name.to_sym)&.remove!
|
|
118
|
+
|
|
119
|
+
def hooks(kind) = @all.values.flat_map { |e| e.hooks.fetch(kind, []) }.sort_by(&:order)
|
|
120
|
+
|
|
121
|
+
def next_order = @lock.synchronize { @order += 1 }
|
|
122
|
+
|
|
123
|
+
def load_all(dir = File.expand_path("ext", __dir__))
|
|
124
|
+
Dir[File.join(dir, "*.rb")].sort.each { |file| require file }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
module CLI
|
|
5
|
+
# The generated `--help` for a command:
|
|
6
|
+
#
|
|
7
|
+
# Deploy an app to the fleet
|
|
8
|
+
#
|
|
9
|
+
# Usage
|
|
10
|
+
# deployer deploy <app> [sha] [options]
|
|
11
|
+
#
|
|
12
|
+
# Arguments
|
|
13
|
+
# app App to deploy
|
|
14
|
+
#
|
|
15
|
+
# Options
|
|
16
|
+
# -e, --env <env> Target environment (staging, production) [default: staging]
|
|
17
|
+
# --[no-]force Skip the checks
|
|
18
|
+
# -h, --help Show help
|
|
19
|
+
#
|
|
20
|
+
# Headings bold, names in the accent colour, defaults muted, when the shell has colour; the
|
|
21
|
+
# same text without escapes otherwise. Extensions put lines on top with `help_header` (e.g.
|
|
22
|
+
# "deployer 1.4.0") and append sections with `help_section`.
|
|
23
|
+
class Help
|
|
24
|
+
INDENT = " "
|
|
25
|
+
GAP = " "
|
|
26
|
+
|
|
27
|
+
def initialize(command, shell)
|
|
28
|
+
@command = command
|
|
29
|
+
@shell = shell
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_s
|
|
33
|
+
sections = Extensions.hooks(:help_header).filter_map do |hook|
|
|
34
|
+
text = hook.block.call(@command, @shell)
|
|
35
|
+
text.nil? || text.to_s.empty? ? nil : text.to_s.chomp
|
|
36
|
+
end
|
|
37
|
+
about = @command.description || @command.summary
|
|
38
|
+
sections << about if about
|
|
39
|
+
sections << section("Usage", [INDENT + usage])
|
|
40
|
+
sections << table("Arguments", @command.arguments.map { |a| [a.name.to_s.tr("_", "-"), describe_argument(a)] })
|
|
41
|
+
sections << table("Commands", visible_commands.map { |c| [[c.name, *c.aliases].join(", "), c.summary.to_s] })
|
|
42
|
+
sections << table("Options", option_rows)
|
|
43
|
+
Extensions.hooks(:help_section).each do |hook|
|
|
44
|
+
heading, body = hook.block.call(@command, @shell)
|
|
45
|
+
sections << section(heading, Array(body).flat_map { |b| b.to_s.split("\n") }.map { |l| INDENT + l }) if heading
|
|
46
|
+
end
|
|
47
|
+
sections << footer if @command.group?
|
|
48
|
+
"#{sections.compact.join("\n\n")}\n"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def usage
|
|
52
|
+
parts = [@command.full_name]
|
|
53
|
+
parts << "<command>" if @command.group? && !@command.action
|
|
54
|
+
parts << "[command]" if @command.group? && @command.action
|
|
55
|
+
parts.concat(@command.arguments.map(&:usage))
|
|
56
|
+
parts << "[options]"
|
|
57
|
+
@shell.paint(parts.join(" "), :accent)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def visible_commands = @command.commands.reject(&:hidden)
|
|
63
|
+
|
|
64
|
+
def option_rows
|
|
65
|
+
options = @command.all_options.reject(&:hidden)
|
|
66
|
+
rows = options.map { |o| [option_label(o), describe_option(o)] }
|
|
67
|
+
rows << ["-h, --help", "Show help"]
|
|
68
|
+
rows
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def option_label(option)
|
|
72
|
+
short = option.short ? "-#{option.short}, " : " "
|
|
73
|
+
long = option.boolean? && option.default == true ? "--[no-]#{option.long.delete_prefix("--")}" : option.long
|
|
74
|
+
long += " <#{option.value_name}>" unless option.boolean?
|
|
75
|
+
long += "..." if option.many
|
|
76
|
+
short + long
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def describe_option(option)
|
|
80
|
+
notes = []
|
|
81
|
+
notes << option.choices.join(", ") if option.choices
|
|
82
|
+
notes << "required" if option.required
|
|
83
|
+
default = option.default
|
|
84
|
+
notes << "default: #{default}" if !default.nil? && !default.respond_to?(:call) && default != false && default != []
|
|
85
|
+
[option.desc, notes.empty? ? nil : @shell.paint("(#{notes.join("; ")})", :muted)].compact.join(" ")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def describe_argument(argument)
|
|
89
|
+
notes = argument.default.nil? ? nil : @shell.paint("(default: #{argument.default})", :muted)
|
|
90
|
+
[argument.desc, notes].compact.join(" ")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def section(heading, lines)
|
|
94
|
+
return nil if lines.empty?
|
|
95
|
+
|
|
96
|
+
[@shell.paint(heading, :heading), *lines].join("\n")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def table(heading, rows)
|
|
100
|
+
return nil if rows.empty?
|
|
101
|
+
|
|
102
|
+
width = rows.map { |name, _| name.size }.max
|
|
103
|
+
lines = rows.map do |name, text|
|
|
104
|
+
line = INDENT + @shell.paint(name, :accent)
|
|
105
|
+
text.to_s.empty? ? line : line + (" " * (width - name.size)) + GAP + text
|
|
106
|
+
end
|
|
107
|
+
section(heading, lines)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def footer
|
|
111
|
+
@shell.paint("Run '#{@command.full_name} <command> --help' for more about a command.", :muted)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
module CLI
|
|
5
|
+
# The inline helpers (tasks, confirm, ...). Every extension's `helpers` block adds a module
|
|
6
|
+
# here, so they are available
|
|
7
|
+
# - in a command's `run` block (a Context, whose `shell` is the runner's),
|
|
8
|
+
# - in any script that does `include R2UI::CLI::Helpers` (they use R2UI::CLI.shell),
|
|
9
|
+
# - as `R2UI::CLI.confirm(...)` and friends.
|
|
10
|
+
#
|
|
11
|
+
# A helper writes only through `shell` (Shell#puts, #err_puts, #paint, #symbol), so it works on
|
|
12
|
+
# a terminal, in a pipe and in tests alike.
|
|
13
|
+
module Helpers
|
|
14
|
+
def shell = CLI.shell
|
|
15
|
+
|
|
16
|
+
# Prints a line, styled with theme styles when the shell has colour: `say "Done", :success`.
|
|
17
|
+
def say(text = "", *styles) = shell.puts(shell.paint(text, *styles))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
|
|
5
|
+
module R2UI
|
|
6
|
+
module CLI
|
|
7
|
+
# An inline region redrawn in place: spinners, task lists, progress bars. It draws with the
|
|
8
|
+
# compat engine's inline renderer (R2UI::Compat::Tea::Renderer, Bubbletea's standard
|
|
9
|
+
# renderer), so lines are redrawn and erased exactly the way an inline Bubbletea program's are,
|
|
10
|
+
# but it takes no raw mode and reads no keys: the work runs on the caller's thread, ctrl+c is an
|
|
11
|
+
# ordinary Interrupt, and typed input stays in the terminal's buffer.
|
|
12
|
+
#
|
|
13
|
+
# live = Live.new(shell) { |frame| "#{Live.spinner(frame)} Building" }
|
|
14
|
+
# live.run { build } # draws at FPS until the block returns, then the last frame stays
|
|
15
|
+
#
|
|
16
|
+
# Off a live terminal (a pipe, TERM=dumb) it draws nothing: `run` just yields and the caller
|
|
17
|
+
# prints its own plain lines (check `live?`). The cursor is hidden while drawing and shown
|
|
18
|
+
# again on every exit path (the block's return, an exception, ctrl+c).
|
|
19
|
+
class Live
|
|
20
|
+
# bubbles' Spinner::MINI_DOT frames, at its 12 fps.
|
|
21
|
+
SPINNER = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
22
|
+
FPS = 12
|
|
23
|
+
HIDE_CURSOR = "\e[?25l"
|
|
24
|
+
SHOW_CURSOR = "\e[?25h"
|
|
25
|
+
ERASE_BELOW = "\e[J"
|
|
26
|
+
|
|
27
|
+
def self.spinner(frame) = SPINNER[frame % SPINNER.size]
|
|
28
|
+
|
|
29
|
+
attr_reader :shell, :frame
|
|
30
|
+
|
|
31
|
+
# When true, `stop` erases the region instead of leaving its last frame (a spinner that
|
|
32
|
+
# disappears on success). Set it at any time before the region stops.
|
|
33
|
+
attr_accessor :clear
|
|
34
|
+
|
|
35
|
+
# `view` gets the frame number (it ticks at `fps`) and returns the region's lines.
|
|
36
|
+
def initialize(shell, fps: FPS, clear: false, &view)
|
|
37
|
+
raise ArgumentError, "Live needs a view block" unless view
|
|
38
|
+
|
|
39
|
+
@shell = shell
|
|
40
|
+
@fps = fps
|
|
41
|
+
@clear = clear
|
|
42
|
+
@view = view
|
|
43
|
+
@lock = Monitor.new
|
|
44
|
+
@frame = 0
|
|
45
|
+
@running = false
|
|
46
|
+
@lines = 0
|
|
47
|
+
@error = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def live? = shell.live?
|
|
51
|
+
|
|
52
|
+
def running? = @running
|
|
53
|
+
|
|
54
|
+
# Draws while the block runs; returns the block's value. If the view raised while the
|
|
55
|
+
# ticker was drawing, that error is raised here once the terminal is restored.
|
|
56
|
+
def run
|
|
57
|
+
start
|
|
58
|
+
value = yield self
|
|
59
|
+
stop
|
|
60
|
+
raise @error if @error
|
|
61
|
+
|
|
62
|
+
value
|
|
63
|
+
ensure
|
|
64
|
+
stop
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def start
|
|
68
|
+
return self if @running || !live?
|
|
69
|
+
|
|
70
|
+
@running = true
|
|
71
|
+
@renderer = new_renderer
|
|
72
|
+
shell.print(HIDE_CURSOR)
|
|
73
|
+
shell.live = self
|
|
74
|
+
refresh
|
|
75
|
+
@ticker = Thread.new do
|
|
76
|
+
Thread.current.report_on_exception = false
|
|
77
|
+
loop do
|
|
78
|
+
sleep(1.0 / @fps)
|
|
79
|
+
@lock.synchronize do
|
|
80
|
+
@frame += 1
|
|
81
|
+
draw
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
@error = e # the ticker stops; `run` (or the next refresh) surfaces it
|
|
86
|
+
end
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Redraws now (call after changing what the view shows, so it doesn't wait for a tick).
|
|
91
|
+
def refresh
|
|
92
|
+
@lock.synchronize { draw if @running }
|
|
93
|
+
self
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Prints a line above the region; it stays in the scrollback. Shell#puts calls this while
|
|
97
|
+
# the region is drawing.
|
|
98
|
+
def println(text)
|
|
99
|
+
@lock.synchronize do
|
|
100
|
+
erase if @running
|
|
101
|
+
shell.output.write("#{text}\n")
|
|
102
|
+
next unless @running
|
|
103
|
+
|
|
104
|
+
@renderer = new_renderer
|
|
105
|
+
draw
|
|
106
|
+
end
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Draws the last frame and leaves it in the scrollback (or erases the region when `clear`),
|
|
111
|
+
# then shows the cursor again. The terminal is restored even if the last draw raises or a
|
|
112
|
+
# second ctrl+c arrives while the ticker stops; that error then propagates.
|
|
113
|
+
def stop
|
|
114
|
+
return unless @running
|
|
115
|
+
|
|
116
|
+
begin
|
|
117
|
+
@ticker&.kill
|
|
118
|
+
@ticker&.join
|
|
119
|
+
@lock.synchronize { @clear ? erase : (draw unless @error) }
|
|
120
|
+
ensure
|
|
121
|
+
@running = false
|
|
122
|
+
shell.live = nil
|
|
123
|
+
shell.output.write(@clear ? SHOW_CURSOR : "\n#{SHOW_CURSOR}")
|
|
124
|
+
shell.output.flush if shell.output.respond_to?(:flush)
|
|
125
|
+
end
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def draw
|
|
132
|
+
raise @error if @error
|
|
133
|
+
|
|
134
|
+
view = @view.call(@frame).to_s
|
|
135
|
+
@renderer.render(view)
|
|
136
|
+
@lines = [view.empty? ? 1 : view.split("\n", -1).size, shell.height].min
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Moves to the region's first line and erases it and everything below.
|
|
140
|
+
def erase
|
|
141
|
+
up = @lines - 1
|
|
142
|
+
shell.output.write("#{up.positive? ? "\e[#{up}A" : ""}\r#{ERASE_BELOW}")
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def new_renderer
|
|
146
|
+
Compat::Tea::Renderer.new(shell.output).tap { |r| r.set_size(shell.width, shell.height) }
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module R2UI
|
|
4
|
+
module CLI
|
|
5
|
+
# argv → which command, its options and arguments. Errors are UsageErrors with a "did you
|
|
6
|
+
# mean" when a near name exists. Options may come anywhere after the command that owns them
|
|
7
|
+
# (or an ancestor that declared them); `--` ends options.
|
|
8
|
+
#
|
|
9
|
+
# Parsing happens in two steps so extensions can act in between (the runner's `after_parse`
|
|
10
|
+
# hooks): `parse` reads argv (commands and options); `finish` applies defaults, checks
|
|
11
|
+
# required options and reads the positional arguments.
|
|
12
|
+
class Parser
|
|
13
|
+
# `options` holds the given options (converted); `given` their names.
|
|
14
|
+
Invocation = Struct.new(:command, :options, :given, :args, :positionals, :help, :argv, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
NEGATIVE_NUMBER = /\A-\d+(\.\d+)?\z/
|
|
17
|
+
|
|
18
|
+
def initialize(program)
|
|
19
|
+
@program = program
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def parse(argv)
|
|
23
|
+
argv = argv.map(&:to_s)
|
|
24
|
+
command = @program
|
|
25
|
+
options = {}
|
|
26
|
+
positionals = []
|
|
27
|
+
help = false
|
|
28
|
+
only_positionals = false
|
|
29
|
+
i = 0
|
|
30
|
+
|
|
31
|
+
while i < argv.size
|
|
32
|
+
token = argv[i]
|
|
33
|
+
i += 1
|
|
34
|
+
if only_positionals
|
|
35
|
+
positionals << token
|
|
36
|
+
elsif token == "--"
|
|
37
|
+
only_positionals = true
|
|
38
|
+
elsif %w[--help -h].include?(token)
|
|
39
|
+
help = true
|
|
40
|
+
elsif token.start_with?("--")
|
|
41
|
+
i = long(command, token, argv, i, options)
|
|
42
|
+
elsif token.start_with?("-") && token.size > 1 && !(token.match?(NEGATIVE_NUMBER) && command.arguments.any?)
|
|
43
|
+
i = short(command, token, argv, i, options)
|
|
44
|
+
elsif positionals.empty? && command.group? && (sub = command.find(token))
|
|
45
|
+
command = sub
|
|
46
|
+
elsif positionals.empty? && command.group? && token == "help" && !help
|
|
47
|
+
help = true
|
|
48
|
+
elsif positionals.empty? && command.group? && !(command.action && command.arguments.any?)
|
|
49
|
+
raise UsageError, unknown("command", token, command.commands.reject(&:hidden).flat_map { |c| [c.name, *c.aliases] },
|
|
50
|
+
"for '#{command.full_name}'")
|
|
51
|
+
else
|
|
52
|
+
positionals << token
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Invocation.new(command:, options:, given: options.keys, args: {}, positionals:, help:, argv:)
|
|
57
|
+
rescue UsageError => e
|
|
58
|
+
e.command ||= command
|
|
59
|
+
raise
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# After the `after_parse` hooks: defaults for options still missing, required checks, then
|
|
63
|
+
# the positional arguments (filled into `invocation.args` in place, so a Context holding it
|
|
64
|
+
# sees them). A hook that halts (`--version`) never meets these checks.
|
|
65
|
+
def finish(invocation)
|
|
66
|
+
options = invocation.options
|
|
67
|
+
invocation.command.all_options.each do |option|
|
|
68
|
+
next if options.key?(option.name)
|
|
69
|
+
|
|
70
|
+
value = option.default_value
|
|
71
|
+
if option.required && (value.nil? || value == [])
|
|
72
|
+
raise UsageError, "missing required option #{option.long}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
options[option.name] = value
|
|
76
|
+
end
|
|
77
|
+
invocation.args.replace(arguments(invocation.command, invocation.positionals))
|
|
78
|
+
invocation
|
|
79
|
+
rescue UsageError => e
|
|
80
|
+
e.command ||= invocation.command
|
|
81
|
+
raise
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def long(command, token, argv, i, options)
|
|
87
|
+
name, value = token.delete_prefix("--").split("=", 2)
|
|
88
|
+
option = find_long(command, name)
|
|
89
|
+
if option.nil? && name.start_with?("no-") && value.nil?
|
|
90
|
+
negated = find_long(command, name.delete_prefix("no-"))
|
|
91
|
+
return store(options, negated, false, i) if negated&.boolean?
|
|
92
|
+
end
|
|
93
|
+
raise UsageError, unknown("option", "--#{name}", command.all_options.reject(&:hidden).map(&:long)) unless option
|
|
94
|
+
|
|
95
|
+
if option.boolean?
|
|
96
|
+
store(options, option, value.nil? ? true : option.convert(value), i)
|
|
97
|
+
elsif value
|
|
98
|
+
store(options, option, option.convert(value), i)
|
|
99
|
+
else
|
|
100
|
+
raise UsageError, "option #{option.long} needs a value" if i >= argv.size
|
|
101
|
+
|
|
102
|
+
store(options, option, option.convert(argv[i]), i + 1)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def short(command, token, argv, i, options)
|
|
107
|
+
chars = token.delete_prefix("-").chars
|
|
108
|
+
chars.each_with_index do |char, j|
|
|
109
|
+
option = command.all_options.find { |o| o.short == char }
|
|
110
|
+
raise UsageError, unknown("option", "-#{char}", command.all_options.filter_map { |o| "-#{o.short}" if o.short }) unless option
|
|
111
|
+
next store(options, option, true, i) if option.boolean?
|
|
112
|
+
|
|
113
|
+
attached = chars[(j + 1)..].join
|
|
114
|
+
return store(options, option, option.convert(attached), i) unless attached.empty?
|
|
115
|
+
raise UsageError, "option -#{char} (#{option.long}) needs a value" if i >= argv.size
|
|
116
|
+
|
|
117
|
+
return store(options, option, option.convert(argv[i]), i + 1)
|
|
118
|
+
end
|
|
119
|
+
i
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def store(options, option, value, i)
|
|
123
|
+
if option.many
|
|
124
|
+
(options[option.name] ||= []) << value
|
|
125
|
+
else
|
|
126
|
+
options[option.name] = value
|
|
127
|
+
end
|
|
128
|
+
i
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def find_long(command, name)
|
|
132
|
+
name = name.tr("-", "_")
|
|
133
|
+
command.all_options.find { |o| o.name.to_s == name }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def arguments(command, positionals)
|
|
137
|
+
args = {}
|
|
138
|
+
command.arguments.each_with_index do |argument, index|
|
|
139
|
+
if argument.many
|
|
140
|
+
values = positionals[index..] || []
|
|
141
|
+
raise UsageError, "missing argument #{argument.usage}" if argument.required && values.empty?
|
|
142
|
+
|
|
143
|
+
args[argument.name] = values.empty? && argument.default ? Array(argument.default) : values.map { |v| argument.convert(v) }
|
|
144
|
+
elsif index < positionals.size
|
|
145
|
+
args[argument.name] = argument.convert(positionals[index])
|
|
146
|
+
elsif argument.required
|
|
147
|
+
raise UsageError, "missing argument #{argument.usage}"
|
|
148
|
+
else
|
|
149
|
+
args[argument.name] = argument.default
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
extra = command.arguments.last&.many ? [] : positionals.drop(command.arguments.size)
|
|
153
|
+
raise UsageError, "unexpected argument #{extra.first.inspect}" if extra.any?
|
|
154
|
+
|
|
155
|
+
args
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def unknown(kind, word, candidates, suffix = nil)
|
|
159
|
+
message = "unknown #{kind} '#{word}'#{" #{suffix}" if suffix}"
|
|
160
|
+
guess = DidYouMean::SpellChecker.new(dictionary: candidates).correct(word).first if defined?(DidYouMean)
|
|
161
|
+
guess ? "#{message}. Did you mean '#{guess}'?" : message
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|