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,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c26-env: options from environment variables.
|
|
4
|
+
#
|
|
5
|
+
# R2UI.cli "deployer" do
|
|
6
|
+
# option :token, env: "DEPLOY_TOKEN", required: true, desc: "API token"
|
|
7
|
+
# option :port, :integer, env: "DEPLOY_PORT", default: 80
|
|
8
|
+
# option :env, env: %w[DEPLOY_ENV RACK_ENV] # the first one set wins
|
|
9
|
+
# option :tags, many: true, env: "DEPLOY_TAGS" # "web,api" => ["web", "api"]
|
|
10
|
+
# flag :force, env: "DEPLOY_FORCE" # 1/0, true/false, yes/no, on/off
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# $ DEPLOY_TOKEN=s3cret deployer # options[:token] == "s3cret"
|
|
14
|
+
# $ DEPLOY_TOKEN=s3cret deployer --token x # the command line wins: "x"
|
|
15
|
+
#
|
|
16
|
+
# When an option isn't on the command line, its variable fills it (in `after_parse`, so before
|
|
17
|
+
# defaults and the required check: a set variable satisfies `required: true`, and a default
|
|
18
|
+
# lambda isn't called). The value is converted and checked like typed input (type, `in:`), and a
|
|
19
|
+
# bad one is a usage error that names the variable:
|
|
20
|
+
#
|
|
21
|
+
# ✖ DEPLOY_PORT: --port expects an integer, got "eighty"
|
|
22
|
+
# Run 'deployer --help' for usage.
|
|
23
|
+
#
|
|
24
|
+
# An empty variable counts as unset. `given?(:token)` stays false: it means typed. Options from
|
|
25
|
+
# a group apply to its subcommands, variables included. Variables are read from the shell's env
|
|
26
|
+
# (`Shell#env`, ENV by default), so tests pass `env:` to `run_cli`.
|
|
27
|
+
#
|
|
28
|
+
# `--help` gains an "Environment" section (hidden options left out), names in the accent colour
|
|
29
|
+
# and headings bold on a terminal, plain text in a pipe:
|
|
30
|
+
#
|
|
31
|
+
# Environment
|
|
32
|
+
# DEPLOY_TOKEN --token
|
|
33
|
+
# DEPLOY_PORT --port
|
|
34
|
+
# DEPLOY_TAGS --tags (comma-separated)
|
|
35
|
+
module R2UI
|
|
36
|
+
module CLI
|
|
37
|
+
module Ext
|
|
38
|
+
module EnvOptions
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# The variable names for an option (none when it has no `env:`).
|
|
42
|
+
def names(option) = Array(option.meta[:env])
|
|
43
|
+
|
|
44
|
+
def check!(command)
|
|
45
|
+
command.options.each do |option|
|
|
46
|
+
env = option.meta[:env]
|
|
47
|
+
next if env.nil?
|
|
48
|
+
|
|
49
|
+
valid = Array(env).then { |list| list.any? && list.all? { |n| n.is_a?(String) && !n.empty? } }
|
|
50
|
+
raise ArgumentError, "#{command.full_name}: #{option.long} env: expects a variable name or a list of them, got #{env.inspect}" unless valid
|
|
51
|
+
end
|
|
52
|
+
command.commands.each { |sub| check!(sub) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The first set (non-empty) variable as [name, raw value], or nil.
|
|
56
|
+
def lookup(option, env)
|
|
57
|
+
names(option).each do |name|
|
|
58
|
+
value = env[name]
|
|
59
|
+
return [name, value] unless value.nil? || value.empty?
|
|
60
|
+
end
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def convert(option, name, raw)
|
|
65
|
+
if option.many
|
|
66
|
+
raw.split(",").map(&:strip).reject(&:empty?).map { |part| option.convert(part) }
|
|
67
|
+
else
|
|
68
|
+
option.convert(raw)
|
|
69
|
+
end
|
|
70
|
+
rescue UsageError => e
|
|
71
|
+
raise UsageError, "#{name}: #{e.message}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def help(command, shell)
|
|
75
|
+
rows = command.all_options.reject(&:hidden).flat_map do |option|
|
|
76
|
+
names(option).map { |name| [name, option] }
|
|
77
|
+
end
|
|
78
|
+
return nil if rows.empty?
|
|
79
|
+
|
|
80
|
+
width = rows.map { |name, _| name.size }.max
|
|
81
|
+
lines = rows.map do |name, option|
|
|
82
|
+
note = option.many ? " #{shell.paint("(comma-separated)", :muted)}" : ""
|
|
83
|
+
"#{shell.paint(name, :accent)}#{" " * (width - name.size)} #{option.long}#{note}"
|
|
84
|
+
end
|
|
85
|
+
["Environment", lines]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
extension :env do
|
|
91
|
+
setup { Ext::EnvOptions.check!(definition) }
|
|
92
|
+
|
|
93
|
+
after_parse do
|
|
94
|
+
command.all_options.each do |option|
|
|
95
|
+
next if given?(option.name)
|
|
96
|
+
|
|
97
|
+
name, raw = Ext::EnvOptions.lookup(option, shell.env)
|
|
98
|
+
next unless name
|
|
99
|
+
|
|
100
|
+
value = Ext::EnvOptions.convert(option, name, raw)
|
|
101
|
+
options[option.name] = value unless option.many && value.empty?
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
help_section { |command, shell| Ext::EnvOptions.help(command, shell) }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c29-examples: example invocations in a command's --help.
|
|
4
|
+
#
|
|
5
|
+
# R2UI.cli "deployer" do
|
|
6
|
+
# command :deploy do
|
|
7
|
+
# example "deployer deploy api -e production", "Deploy api to production"
|
|
8
|
+
# example "deployer deploy web" # the description is optional
|
|
9
|
+
# end
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# `example(command_line, description = nil)` is repeatable and works on the root and on any
|
|
13
|
+
# command; each command's help shows only its own examples, in the order given, as a last
|
|
14
|
+
# section after Options:
|
|
15
|
+
#
|
|
16
|
+
# Examples
|
|
17
|
+
# # Deploy api to production
|
|
18
|
+
# deployer deploy api -e production
|
|
19
|
+
# deployer deploy web
|
|
20
|
+
#
|
|
21
|
+
# On a terminal the "# ..." comment is muted and the command is in the accent colour (the same
|
|
22
|
+
# colour as the Usage line), so the runnable part stands out. Off a terminal (a pipe, CI,
|
|
23
|
+
# NO_COLOR) it is the same text without escape codes. A multi-line description makes several
|
|
24
|
+
# comment lines. The command line is printed as given (no "$ " prompt), so it copies cleanly.
|
|
25
|
+
module R2UI
|
|
26
|
+
module CLI
|
|
27
|
+
module Ext
|
|
28
|
+
module Examples
|
|
29
|
+
Example = Data.define(:command_line, :description)
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
def section(command, shell)
|
|
34
|
+
examples = command.declared(:example)
|
|
35
|
+
return nil if examples.empty?
|
|
36
|
+
|
|
37
|
+
["Examples", examples.flat_map { |e| lines(e, shell) }]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def lines(example, shell)
|
|
41
|
+
comments = example.description.to_s.split("\n").map { |line| shell.paint("# #{line}".rstrip, :muted) }
|
|
42
|
+
commands = example.command_line.split("\n").map { |line| shell.paint(line, :accent) }
|
|
43
|
+
comments + commands
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
extension :examples do
|
|
49
|
+
dsl(:command) do
|
|
50
|
+
def example(command_line, description = nil)
|
|
51
|
+
command_line = command_line.to_s.strip
|
|
52
|
+
raise ArgumentError, "example needs a command line" if command_line.empty?
|
|
53
|
+
|
|
54
|
+
description = description&.to_s&.strip
|
|
55
|
+
declare(:example, Ext::Examples::Example.new(command_line:, description:))
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
help_section { |command, shell| Ext::Examples.section(command, shell) }
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c21-filter: type to filter a list, like gum filter or fzf.
|
|
4
|
+
#
|
|
5
|
+
# branch = filter("Branch?", `git branch --format=%(refname:short)`.lines(chomp: true))
|
|
6
|
+
# region = filter("Region?", { "Europe (eu-west-1)" => :eu, "US East (us-east-1)" => :us })
|
|
7
|
+
# filter("Package?", packages, placeholder: "name or scope", limit: 8)
|
|
8
|
+
#
|
|
9
|
+
# On a terminal (Shell#interactive?) it asks inline: a bubbles TextInput on the question line,
|
|
10
|
+
# and under it the choices narrowed by a fuzzy match as you type (the characters may be spread
|
|
11
|
+
# out: "fbil" finds "feature/billing"; contiguous runs and word starts rank first, ties keep the
|
|
12
|
+
# list's order), the matched characters highlighted:
|
|
13
|
+
#
|
|
14
|
+
# ? Branch? feat▌ 2/6
|
|
15
|
+
# › feature/billing
|
|
16
|
+
# feature/login
|
|
17
|
+
#
|
|
18
|
+
# ↑/↓ (ctrl+p/ctrl+n, ctrl+k/ctrl+j) move, enter picks, the usual line-editing keys edit the
|
|
19
|
+
# query. At most `limit:` (10) rows show at once, with "↑ 3 more" / "↓ 5 more" hints. It leaves
|
|
20
|
+
# "✔ Branch? · feature/billing" in the scrollback; ctrl+c or esc raises Interrupt (a command exits
|
|
21
|
+
# 130) after the terminal is restored.
|
|
22
|
+
#
|
|
23
|
+
# Off a terminal it reads one line and returns the best match for it ("bill" → feature/billing).
|
|
24
|
+
# It asks "Branch? " on stderr when a person can type (stdin is a terminal, stdout piped); from a
|
|
25
|
+
# pipe (`echo bill | tool`, CI) it doesn't ask and records "✔ Branch? · feature/billing" on
|
|
26
|
+
# stderr. A line nothing matches, an empty line or end of input raise a CLI::Error naming the
|
|
27
|
+
# question (a command exits 1).
|
|
28
|
+
#
|
|
29
|
+
# Choices are an Array (the item is returned) or a Hash of label ⇒ value (the value is returned).
|
|
30
|
+
module R2UI
|
|
31
|
+
module CLI
|
|
32
|
+
module Ext
|
|
33
|
+
module Filter
|
|
34
|
+
# Fuzzy scoring, in the spirit of fzf: every query character must appear in order; a match
|
|
35
|
+
# earns MATCH per character, more at a word start or right after the previous match, less
|
|
36
|
+
# for each character skipped in between.
|
|
37
|
+
MATCH = 16
|
|
38
|
+
BOUNDARY = 8
|
|
39
|
+
CAMEL = 7
|
|
40
|
+
CONSECUTIVE = 6
|
|
41
|
+
GAP_START = 3
|
|
42
|
+
GAP = 1
|
|
43
|
+
EXACT = 1000
|
|
44
|
+
|
|
45
|
+
# A choice as the list shows it: its label, what picking it returns, its place in the list.
|
|
46
|
+
Choice = Data.define(:label, :value, :index)
|
|
47
|
+
|
|
48
|
+
# A choice that matched: its score and the label positions that matched.
|
|
49
|
+
Match = Data.define(:choice, :score, :positions)
|
|
50
|
+
|
|
51
|
+
# The inline prompt: a Bubbletea model on R2UI::CLI::Prompt hosting a bubbles TextInput.
|
|
52
|
+
class Model < Prompt::Model
|
|
53
|
+
attr_reader :question, :matches, :cursor
|
|
54
|
+
|
|
55
|
+
def initialize(shell, question, choices, limit: 10, placeholder: "Type to filter")
|
|
56
|
+
super(shell)
|
|
57
|
+
@question = question
|
|
58
|
+
@choices = Filter.choices(choices)
|
|
59
|
+
@limit = [[limit, shell.height - 3].min, 1].max
|
|
60
|
+
input = Bubbles::TextInput.new
|
|
61
|
+
input.prompt = ""
|
|
62
|
+
input.placeholder = placeholder
|
|
63
|
+
input.placeholder_style = shell.theme.style(:muted) if shell.color?
|
|
64
|
+
@focus_command = input.focus
|
|
65
|
+
self.component = input
|
|
66
|
+
@query = nil
|
|
67
|
+
refilter
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def init = [self, @focus_command]
|
|
71
|
+
|
|
72
|
+
def update(message)
|
|
73
|
+
result = super
|
|
74
|
+
refilter unless done? || component.value == @query
|
|
75
|
+
result
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def view
|
|
79
|
+
return "#{shell.symbol(:error)} #{question}" if interrupted?
|
|
80
|
+
return Filter.answered(shell, question, @picked.label) if done?
|
|
81
|
+
|
|
82
|
+
[header, *rows].join("\n")
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def key(name, _message)
|
|
88
|
+
case name
|
|
89
|
+
when "enter"
|
|
90
|
+
return [self, nil] if matches.empty?
|
|
91
|
+
|
|
92
|
+
@picked = matches[cursor].choice
|
|
93
|
+
submit(@picked.value)
|
|
94
|
+
when "up", "ctrl+p", "ctrl+k" then move(-1)
|
|
95
|
+
when "down", "ctrl+n", "ctrl+j" then move(1)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def move(step)
|
|
100
|
+
@cursor = (cursor + step).clamp(0, [matches.size - 1, 0].max)
|
|
101
|
+
@offset = cursor if cursor < @offset
|
|
102
|
+
@offset = cursor - @limit + 1 if cursor >= @offset + @limit
|
|
103
|
+
[self, nil]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def refilter
|
|
107
|
+
@query = component.value
|
|
108
|
+
@matches = Filter.rank(@query, @choices)
|
|
109
|
+
@cursor = 0
|
|
110
|
+
@offset = 0
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def header
|
|
114
|
+
count = shell.paint("#{matches.size}/#{@choices.size}", :muted)
|
|
115
|
+
"#{shell.paint("?", :accent)} #{question} #{component.view} #{count}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def rows
|
|
119
|
+
return [" #{shell.paint("No matches", :muted)}"] if matches.empty?
|
|
120
|
+
|
|
121
|
+
above = @offset
|
|
122
|
+
below = matches.size - @offset - @limit
|
|
123
|
+
lines = []
|
|
124
|
+
lines << " #{shell.paint("↑ #{above} more", :muted)}" if above.positive?
|
|
125
|
+
matches[@offset, @limit].each_with_index { |match, i| lines << row(match, @offset + i == cursor) }
|
|
126
|
+
lines << " #{shell.paint("↓ #{below} more", :muted)}" if below.positive?
|
|
127
|
+
lines
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The pointer marks the row without colour too (NO_COLOR on a terminal).
|
|
131
|
+
def row(match, selected)
|
|
132
|
+
room = [shell.width - 2, 1].max
|
|
133
|
+
label = match.choice.label
|
|
134
|
+
label = "#{label[0, room - 1]}…" if label.size > room
|
|
135
|
+
base = selected ? [:accent] : []
|
|
136
|
+
text = Filter.highlight(shell, label, match.positions, base)
|
|
137
|
+
selected ? "#{shell.paint(shell.theme.symbol(:pointer), :accent)} #{text}" : " #{text}"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
module_function
|
|
142
|
+
|
|
143
|
+
def choices(list)
|
|
144
|
+
return list if list.is_a?(Array) && list.all?(Choice)
|
|
145
|
+
|
|
146
|
+
pairs = list.is_a?(Hash) ? list.to_a : Array(list).map { |item| [item, item] }
|
|
147
|
+
pairs.each_with_index.map { |(label, value), index| Choice.new(label: label.to_s, value:, index:) }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The choices that match `query`, best first; an empty query keeps them all, in order.
|
|
151
|
+
def rank(query, choices)
|
|
152
|
+
matches = choices.filter_map do |choice|
|
|
153
|
+
score, positions = score(query, choice.label)
|
|
154
|
+
Match.new(choice:, score:, positions:) if score
|
|
155
|
+
end
|
|
156
|
+
matches.sort_by { |m| [-m.score, m.choice.index] }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# [score, matched positions] for `query` in `label`, or nil when it doesn't match. The
|
|
160
|
+
# best alignment by dynamic programming: score[i][j] is the best score with query
|
|
161
|
+
# character i at label position j.
|
|
162
|
+
def score(query, label)
|
|
163
|
+
q = query.downcase.chars
|
|
164
|
+
return [0, []] if q.empty?
|
|
165
|
+
|
|
166
|
+
chars = label.chars
|
|
167
|
+
lower = chars.map(&:downcase)
|
|
168
|
+
return nil if q.size > chars.size
|
|
169
|
+
|
|
170
|
+
scores = Array.new(q.size) { Array.new(chars.size) }
|
|
171
|
+
from = Array.new(q.size) { Array.new(chars.size) }
|
|
172
|
+
q.each_with_index do |qc, i|
|
|
173
|
+
gapped = nil # best score[i - 1][k] for k <= j - 2, less the gap penalty up to j
|
|
174
|
+
gapped_from = nil
|
|
175
|
+
chars.each_index do |j|
|
|
176
|
+
if i.positive? && j >= 2 && (prev = scores[i - 1][j - 2])
|
|
177
|
+
gapped -= GAP if gapped
|
|
178
|
+
if gapped.nil? || prev - GAP_START >= gapped
|
|
179
|
+
gapped = prev - GAP_START
|
|
180
|
+
gapped_from = j - 2
|
|
181
|
+
end
|
|
182
|
+
elsif gapped
|
|
183
|
+
gapped -= GAP
|
|
184
|
+
end
|
|
185
|
+
next unless lower[j] == qc
|
|
186
|
+
|
|
187
|
+
bonus = MATCH + bonus(chars, j)
|
|
188
|
+
if i.zero?
|
|
189
|
+
scores[i][j] = bonus
|
|
190
|
+
next
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
best = nil
|
|
194
|
+
if j >= 1 && (prev = scores[i - 1][j - 1])
|
|
195
|
+
best = prev + bonus + CONSECUTIVE
|
|
196
|
+
from[i][j] = j - 1
|
|
197
|
+
end
|
|
198
|
+
if gapped && (best.nil? || gapped + bonus > best)
|
|
199
|
+
best = gapped + bonus
|
|
200
|
+
from[i][j] = gapped_from
|
|
201
|
+
end
|
|
202
|
+
scores[i][j] = best
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
last = scores.last
|
|
207
|
+
j = last.each_index.select { |k| last[k] }.max_by { |k| [last[k], -k] }
|
|
208
|
+
return nil unless j
|
|
209
|
+
|
|
210
|
+
total = last[j]
|
|
211
|
+
positions = [j]
|
|
212
|
+
(q.size - 1).downto(1) { |i| positions.unshift(j = from[i][j]) }
|
|
213
|
+
total += EXACT if query.downcase == label.downcase
|
|
214
|
+
[total, positions]
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def bonus(chars, j)
|
|
218
|
+
return BOUNDARY if j.zero?
|
|
219
|
+
|
|
220
|
+
before = chars[j - 1]
|
|
221
|
+
return BOUNDARY unless before.match?(/[[:alnum:]]/)
|
|
222
|
+
return CAMEL if before.match?(/[[:lower:]]/) && chars[j].match?(/[[:upper:]]/)
|
|
223
|
+
|
|
224
|
+
0
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# The label with matched characters in the highlight style (runs painted together).
|
|
228
|
+
def highlight(shell, label, positions, base)
|
|
229
|
+
matched = positions.to_h { |p| [p, true] }
|
|
230
|
+
label.chars.each_with_index.chunk_while { |(_, a), (_, b)| matched[a] == matched[b] }.map do |run|
|
|
231
|
+
text = run.map(&:first).join
|
|
232
|
+
matched[run.first.last] ? shell.paint(text, :highlight) : shell.paint(text, *base)
|
|
233
|
+
end.join
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def answered(shell, question, label)
|
|
237
|
+
"#{shell.symbol(:success)} #{question} #{shell.paint("·", :muted)} #{shell.paint(label, :accent)}"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def ask_line(shell, question, choices)
|
|
241
|
+
line = Prompt.read_line(shell, "#{question} ")
|
|
242
|
+
query = line.to_s.strip
|
|
243
|
+
raise Error, "no answer for #{question.inspect} (end of input)" if line.nil?
|
|
244
|
+
raise Error, "no answer for #{question.inspect}" if query.empty?
|
|
245
|
+
|
|
246
|
+
best = rank(query, choices).first
|
|
247
|
+
raise Error, "nothing matches #{query.inspect} for #{question.inspect}" unless best
|
|
248
|
+
|
|
249
|
+
shell.err_puts("#{shell.symbol(:success)} #{question} · #{best.choice.label}") unless shell.input_tty?
|
|
250
|
+
best.choice.value
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
extension :filter do
|
|
256
|
+
helpers do
|
|
257
|
+
def filter(question, choices, limit: 10, placeholder: "Type to filter")
|
|
258
|
+
list = Ext::Filter.choices(choices)
|
|
259
|
+
raise Error, "nothing to choose from for #{question.inspect}" if list.empty?
|
|
260
|
+
return Ext::Filter.ask_line(shell, question, list) unless shell.interactive?
|
|
261
|
+
|
|
262
|
+
CLI.require_bubbles!
|
|
263
|
+
model = Ext::Filter::Model.new(shell, question, choices, limit:, placeholder:)
|
|
264
|
+
Prompt.run(shell, model).value
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c14-format: numbers and links the way people read them.
|
|
4
|
+
#
|
|
5
|
+
# say "added #{plural(1234, "package")} in #{duration(75.2)}" # added 1,234 packages in 1m 15s
|
|
6
|
+
# say "#{bytes(1_500_000)} written" # 1.5 MB written
|
|
7
|
+
# say "#{plural(1, "child", "children")}" # 1 child
|
|
8
|
+
# say "opened #{link(pr.url, "PR ##{pr.number}")}"
|
|
9
|
+
#
|
|
10
|
+
# duration(seconds) "120ms" under a second, "3.5s" under a minute, then "1m 15s", "2h 30m",
|
|
11
|
+
# "1d 2h". It rounds before picking the unit, so 59.97 is "1m 0s".
|
|
12
|
+
# bytes(count) decimal units like npm and the OS file managers: "999 B", "12 kB",
|
|
13
|
+
# "1.5 MB", "4.2 GB" (one decimal under 10, none above).
|
|
14
|
+
# plural(count, word, "3 packages", "1 package", "2 dependencies", "2 boxes"; counts get
|
|
15
|
+
# plural = nil) thousands separators. Pass the plural for irregular words.
|
|
16
|
+
# link(url, text = url) on a terminal with colour, an OSC 8 hyperlink: "text", clickable to url
|
|
17
|
+
# (terminals without OSC 8 support show just "text"). Off a terminal, or
|
|
18
|
+
# with NO_COLOR, plain "text (url)", or just "url" when there's no text.
|
|
19
|
+
#
|
|
20
|
+
# duration, bytes and plural are the same on and off a terminal; only link looks at the shell.
|
|
21
|
+
module R2UI
|
|
22
|
+
module CLI
|
|
23
|
+
module Ext
|
|
24
|
+
module HumanFormat
|
|
25
|
+
BYTE_UNITS = %w[B kB MB GB TB PB EB].freeze
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def duration(seconds)
|
|
30
|
+
seconds = Float(seconds)
|
|
31
|
+
return "-#{duration(-seconds)}" if seconds.negative?
|
|
32
|
+
|
|
33
|
+
ms = (seconds * 1000).round
|
|
34
|
+
return "#{ms}ms" if ms < 1000
|
|
35
|
+
|
|
36
|
+
tenths = (seconds * 10).round
|
|
37
|
+
return "#{format("%.1f", tenths / 10.0)}s" if tenths < 600
|
|
38
|
+
|
|
39
|
+
total = seconds.round
|
|
40
|
+
return "#{total / 60}m #{total % 60}s" if total < 3600
|
|
41
|
+
|
|
42
|
+
minutes = (total / 60.0).round
|
|
43
|
+
return "#{minutes / 60}h #{minutes % 60}m" if minutes < 24 * 60
|
|
44
|
+
|
|
45
|
+
hours = (total / 3600.0).round
|
|
46
|
+
"#{hours / 24}d #{hours % 24}h"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def bytes(count)
|
|
50
|
+
count = Float(count)
|
|
51
|
+
return "-#{bytes(-count)}" if count.negative?
|
|
52
|
+
|
|
53
|
+
# Rounds before picking the unit, so 999_999 is "1 MB", not "1000 kB".
|
|
54
|
+
unit = 0
|
|
55
|
+
unit += 1 while unit < BYTE_UNITS.size - 1 && scaled(count, unit) >= 1000
|
|
56
|
+
value = scaled(count, unit)
|
|
57
|
+
"#{value == value.to_i ? value.to_i : value} #{BYTE_UNITS[unit]}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def scaled(count, unit)
|
|
61
|
+
value = count / (1000**unit)
|
|
62
|
+
value < 10 && unit.positive? ? value.round(1) : value.round
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def plural(count, word, plural = nil)
|
|
66
|
+
form = count == 1 ? word : plural || pluralize(word)
|
|
67
|
+
"#{delimit(count)} #{form}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def link(shell, url, text)
|
|
71
|
+
return text.nil? || text == url ? url.to_s : "#{text} (#{url})" unless shell.color?
|
|
72
|
+
|
|
73
|
+
"\e]8;;#{url}\e\\#{text || url}\e]8;;\e\\"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def pluralize(word)
|
|
77
|
+
case word
|
|
78
|
+
when /[^aeiou]y\z/i then "#{word[0..-2]}ies"
|
|
79
|
+
when /(?:s|x|z|ch|sh)\z/i then "#{word}es"
|
|
80
|
+
else "#{word}s"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def delimit(count)
|
|
85
|
+
whole, fraction = count.to_s.split(".", 2)
|
|
86
|
+
whole = whole.reverse.scan(/\d{1,3}/).join(",").reverse.then { |w| whole.start_with?("-") ? "-#{w}" : w }
|
|
87
|
+
fraction ? "#{whole}.#{fraction}" : whole
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
extension :format do
|
|
93
|
+
helpers do
|
|
94
|
+
def duration(seconds) = Ext::HumanFormat.duration(seconds)
|
|
95
|
+
|
|
96
|
+
def bytes(count) = Ext::HumanFormat.bytes(count)
|
|
97
|
+
|
|
98
|
+
def plural(count, word, plural = nil) = Ext::HumanFormat.plural(count, word, plural)
|
|
99
|
+
|
|
100
|
+
def link(url, text = nil) = Ext::HumanFormat.link(shell, url, text)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c16-list: bullet and numbered lists.
|
|
4
|
+
#
|
|
5
|
+
# list(%w[rails pg puma])
|
|
6
|
+
# list(["Build", %w[compile link], "Ship"], numbered: true)
|
|
7
|
+
#
|
|
8
|
+
# prints
|
|
9
|
+
#
|
|
10
|
+
# • rails 1. Build
|
|
11
|
+
# • pg 1. compile
|
|
12
|
+
# • puma 2. link
|
|
13
|
+
# 2. Ship
|
|
14
|
+
#
|
|
15
|
+
# A nested Array is a list one level in, under the text of the item before it; numbered lists
|
|
16
|
+
# number each level from 1 and right-align the numbers (" 9." over "10."). An item too long for
|
|
17
|
+
# the shell's width wraps on spaces (a word longer than the line is broken), and every wrapped
|
|
18
|
+
# line, like every line after a "\n" in an item, starts under the item's text, not its marker.
|
|
19
|
+
# Widths are display widths, so wide characters (漢字, emoji) wrap correctly.
|
|
20
|
+
#
|
|
21
|
+
# On a terminal the markers are in the accent colour. Off a terminal (pipe, CI, NO_COLOR) the
|
|
22
|
+
# same lines and layout, without escape codes. Lines go to stdout through the Shell, so inside a
|
|
23
|
+
# live region (tasks, spinners) they print above it. Returns nil.
|
|
24
|
+
module R2UI
|
|
25
|
+
module CLI
|
|
26
|
+
module Ext
|
|
27
|
+
module BulletList
|
|
28
|
+
# The narrowest text column a wrapped item gets, however deep or narrow the shell.
|
|
29
|
+
MIN_TEXT_WIDTH = 4
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# The lines for `items`, each a String; `indent` is the column the level starts at.
|
|
34
|
+
def lines(shell, items, numbered:, indent: 0)
|
|
35
|
+
items = entries(items)
|
|
36
|
+
count = items.count { |item| !item.is_a?(Array) }
|
|
37
|
+
number_width = count.to_s.size
|
|
38
|
+
out = []
|
|
39
|
+
bullet = shell.theme.symbol(:bullet)
|
|
40
|
+
text_column = indent + (numbered ? number_width + 1 : Lipgloss.width(bullet)) + 1
|
|
41
|
+
text_width = [shell.width - text_column, MIN_TEXT_WIDTH].max
|
|
42
|
+
number = 0
|
|
43
|
+
items.each do |item|
|
|
44
|
+
if item.is_a?(Array)
|
|
45
|
+
out.concat(lines(shell, item, numbered:, indent: text_column))
|
|
46
|
+
next
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
number += 1
|
|
50
|
+
marker = numbered ? "#{number.to_s.rjust(number_width)}." : bullet
|
|
51
|
+
wrap(item.to_s, text_width).each_with_index do |line, i|
|
|
52
|
+
lead = i.zero? ? "#{" " * indent}#{shell.paint(marker, :accent)} " : " " * text_column
|
|
53
|
+
out << (line.empty? ? lead.rstrip : "#{lead}#{line}")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
out
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def entries(items)
|
|
60
|
+
return items if items.is_a?(Array)
|
|
61
|
+
raise ArgumentError, "list needs an Array of items, got #{items.class}" if items.is_a?(Hash) || !items.is_a?(Enumerable)
|
|
62
|
+
|
|
63
|
+
items.to_a
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Word-wraps text to `width` display cells; keeps the text's own line breaks.
|
|
67
|
+
def wrap(text, width)
|
|
68
|
+
text.split("\n", -1).flat_map { |paragraph| wrap_line(paragraph, width) }.then { |l| l.empty? ? [""] : l }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def wrap_line(text, width)
|
|
72
|
+
lines = []
|
|
73
|
+
current = +""
|
|
74
|
+
text.split(/ +/).each do |word|
|
|
75
|
+
next if word.empty?
|
|
76
|
+
|
|
77
|
+
if current.empty?
|
|
78
|
+
current = word
|
|
79
|
+
elsif Lipgloss.width(current) + 1 + Lipgloss.width(word) <= width
|
|
80
|
+
current << " " << word
|
|
81
|
+
else
|
|
82
|
+
lines << current
|
|
83
|
+
current = word
|
|
84
|
+
end
|
|
85
|
+
while Lipgloss.width(current) > width
|
|
86
|
+
head, current = split_at(current, width)
|
|
87
|
+
lines << head
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
lines << current
|
|
91
|
+
lines
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Splits a word into the longest head that fits in `width` cells and the rest.
|
|
95
|
+
def split_at(word, width)
|
|
96
|
+
head = +""
|
|
97
|
+
clusters = word.grapheme_clusters
|
|
98
|
+
while (cluster = clusters.first) && Lipgloss.width(head + cluster) <= width
|
|
99
|
+
head << clusters.shift
|
|
100
|
+
end
|
|
101
|
+
head << clusters.shift if head.empty? # a cluster wider than the line still has to go
|
|
102
|
+
[head, clusters.join]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
extension :list do
|
|
108
|
+
helpers do
|
|
109
|
+
def list(items, numbered: false)
|
|
110
|
+
lines = Ext::BulletList.lines(shell, items, numbered:)
|
|
111
|
+
shell.puts(lines.join("\n")) unless lines.empty?
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|