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,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c07-box: a boxed notice, like npm's "Update available" or a "Next steps" box after scaffolding.
|
|
4
|
+
#
|
|
5
|
+
# box "Created app\n\n cd app\n bin/dev", title: "Next steps"
|
|
6
|
+
# box "Update available 1.3.0 → 1.4.0\nRun gem update deployer", title: "deployer", style: :warn
|
|
7
|
+
#
|
|
8
|
+
# prints a rounded lipgloss border with the title in its top border:
|
|
9
|
+
#
|
|
10
|
+
# ╭─ Next steps ─────╮
|
|
11
|
+
# │ │
|
|
12
|
+
# │ Created app │
|
|
13
|
+
# │ │
|
|
14
|
+
# │ cd app │
|
|
15
|
+
# │ bin/dev │
|
|
16
|
+
# │ │
|
|
17
|
+
# ╰──────────────────╯
|
|
18
|
+
#
|
|
19
|
+
# On a terminal the border and the title are drawn in the style's colour (a Theme name: :info,
|
|
20
|
+
# the default, :success, :warn, :error, :accent, :muted, ...; the title also bold); the text keeps
|
|
21
|
+
# its own styling. Off a terminal (a pipe, CI, NO_COLOR) it prints the same box, character for
|
|
22
|
+
# character, without escape codes, so logs and `| cat` read the same.
|
|
23
|
+
#
|
|
24
|
+
# The box fits its text, never wider than the shell: longer lines wrap inside it (words first,
|
|
25
|
+
# then hard breaks). `width:` fixes the outer width (border included), still capped at the shell.
|
|
26
|
+
# `padding:` is the space inside the border: an Integer n is n blank lines above and below and 2n
|
|
27
|
+
# columns either side (cells are about twice as tall as wide); `[vertical, horizontal]` sets both.
|
|
28
|
+
# Multi-line text, tabs and wide characters (CJK, emoji) keep the right edge aligned. A title too
|
|
29
|
+
# wide for the top border becomes the box's first line instead. Returns nil.
|
|
30
|
+
module R2UI
|
|
31
|
+
module CLI
|
|
32
|
+
module Ext
|
|
33
|
+
module Box
|
|
34
|
+
BORDER = Lipgloss::Style.new.border(:rounded)
|
|
35
|
+
TOP_LEFT = "╭"
|
|
36
|
+
TOP_RIGHT = "╮"
|
|
37
|
+
TOP = "─"
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
def render(shell, text, title:, style:, padding:, width:)
|
|
42
|
+
shell.theme.options(style) # unknown names raise, coloured or not
|
|
43
|
+
vertical, horizontal = spacing(padding)
|
|
44
|
+
text = text.to_s
|
|
45
|
+
title = title&.to_s&.tr("\n", " ")
|
|
46
|
+
title = nil if title&.empty?
|
|
47
|
+
max = [width ? width.to_i : shell.width, shell.width].min
|
|
48
|
+
horizontal = [horizontal, [(max - 3) / 2, 0].max].min
|
|
49
|
+
inner = width ? max - 2 : natural(text, title, horizontal, max)
|
|
50
|
+
inner = [inner, 1].max
|
|
51
|
+
|
|
52
|
+
in_border = title && fits?(title, inner)
|
|
53
|
+
body = in_border || title.nil? ? text : "#{shell.paint(title, :heading, style)}\n#{text}"
|
|
54
|
+
plain = BORDER.padding(vertical, horizontal).width(inner).render(body)
|
|
55
|
+
lines = plain.split("\n")
|
|
56
|
+
lines[0] = in_border ? top(shell, title, inner, style) : shell.paint(lines[0], style)
|
|
57
|
+
lines[-1] = shell.paint(lines[-1], style)
|
|
58
|
+
lines[1..-2].each_with_index do |line, i|
|
|
59
|
+
lines[i + 1] = side(shell, line, style)
|
|
60
|
+
end
|
|
61
|
+
lines.join("\n")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def spacing(padding)
|
|
65
|
+
case padding
|
|
66
|
+
when Integer then [padding, padding * 2]
|
|
67
|
+
when Array
|
|
68
|
+
raise ArgumentError, "padding: needs [vertical, horizontal]" unless padding.size == 2
|
|
69
|
+
|
|
70
|
+
padding.map(&:to_i)
|
|
71
|
+
else raise ArgumentError, "padding: must be an Integer or [vertical, horizontal]"
|
|
72
|
+
end.map { |n| [n, 0].max }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The inner width (padding included) that fits the text and the title, capped by `max`.
|
|
76
|
+
def natural(text, title, horizontal, max)
|
|
77
|
+
widest = text.split("\n").map { |line| Lipgloss.width(line.gsub("\t", " ")) }.max || 0
|
|
78
|
+
inner = widest + (2 * horizontal)
|
|
79
|
+
inner = [inner, title_width(title)].max if title
|
|
80
|
+
[inner, max - 2].min
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def title_width(title) = Lipgloss.width(title) + 4
|
|
84
|
+
|
|
85
|
+
def fits?(title, inner) = title_width(title) <= inner
|
|
86
|
+
|
|
87
|
+
# "╭─ Title ───╮": the title in the top border, the rest of it in the style's colour.
|
|
88
|
+
def top(shell, title, inner, style)
|
|
89
|
+
rest = inner - Lipgloss.width(title) - 3
|
|
90
|
+
"#{shell.paint("#{TOP_LEFT}#{TOP} ", style)}#{shell.paint(title, :heading, style)}" \
|
|
91
|
+
"#{shell.paint(" #{TOP * rest}#{TOP_RIGHT}", style)}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A middle line: its first and last characters are the border; the text between is left as is.
|
|
95
|
+
def side(shell, line, style)
|
|
96
|
+
left = line[0]
|
|
97
|
+
right = line[-1]
|
|
98
|
+
"#{shell.paint(left, style)}#{line[1..-2]}#{shell.paint(right, style)}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
extension :box do
|
|
104
|
+
helpers do
|
|
105
|
+
def box(text, title: nil, style: :info, padding: 1, width: nil)
|
|
106
|
+
shell.puts(Ext::Box.render(shell, text, title:, style:, padding:, width:))
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c19-choose: pick one of a list.
|
|
4
|
+
#
|
|
5
|
+
# region = choose("Region?", %w[eu us ap], default: "eu") # => "eu"
|
|
6
|
+
# plan = choose("Plan?", { "Free" => :free, "Pro ($9/mo)" => :pro }) # labels ⇒ values
|
|
7
|
+
#
|
|
8
|
+
# Choices are an Array (each item is its own label, shown with to_s) or a Hash of label ⇒ value.
|
|
9
|
+
# `choose` returns the picked value. `default:` (a value or a label) is where the pointer starts
|
|
10
|
+
# and the answer for an empty line or end of input off a terminal.
|
|
11
|
+
#
|
|
12
|
+
# On a terminal (Shell#interactive?) it asks inline, like gum choose:
|
|
13
|
+
#
|
|
14
|
+
# ? Region? ↑/↓ move · enter pick
|
|
15
|
+
# › eu
|
|
16
|
+
# us
|
|
17
|
+
# ap
|
|
18
|
+
#
|
|
19
|
+
# ↑/↓ (or k/j) move and wrap around, home/end (g/G) jump to the ends, enter picks. More than 10
|
|
20
|
+
# choices scroll, with "↑ 3 more" / "↓ 4 more" lines above and below. It leaves
|
|
21
|
+
# "✔ Region? · eu" in the scrollback. ctrl+c or esc raises Interrupt (a command exits 130) after
|
|
22
|
+
# the terminal is restored.
|
|
23
|
+
#
|
|
24
|
+
# Off a terminal it reads one line: a label (any case) or its 1-based number. An empty line or
|
|
25
|
+
# end of input takes `default:`; without one, end of input is an error naming the question and
|
|
26
|
+
# its choices. When a person can still type (stdin is a terminal, stdout piped) it lists the
|
|
27
|
+
# choices as " 1) eu" lines on stderr, asks "Region? [eu] " and asks again after an answer it
|
|
28
|
+
# can't read; from a pipe (`echo us | tool`, CI) it doesn't ask, records "✔ Region? · us" on
|
|
29
|
+
# stderr, and an unknown answer is an error.
|
|
30
|
+
module R2UI
|
|
31
|
+
module CLI
|
|
32
|
+
module Ext
|
|
33
|
+
module Choose
|
|
34
|
+
VISIBLE = 10
|
|
35
|
+
|
|
36
|
+
Option = Data.define(:label, :value)
|
|
37
|
+
|
|
38
|
+
# The inline prompt: a pointer list on R2UI::CLI::Prompt.
|
|
39
|
+
class Model < Prompt::Model
|
|
40
|
+
attr_reader :question, :options, :cursor
|
|
41
|
+
|
|
42
|
+
def initialize(shell, question, options, cursor)
|
|
43
|
+
super(shell)
|
|
44
|
+
@question = question
|
|
45
|
+
@options = options
|
|
46
|
+
@cursor = cursor || 0
|
|
47
|
+
@offset = 0
|
|
48
|
+
scroll
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def view
|
|
52
|
+
return "#{shell.symbol(:error)} #{question}" if interrupted?
|
|
53
|
+
return Choose.answered(shell, question, options[@cursor].label) if done?
|
|
54
|
+
|
|
55
|
+
lines = ["#{shell.paint("?", :accent)} #{question} #{shell.paint("↑/↓ move · enter pick", :muted)}"]
|
|
56
|
+
lines << more("↑", @offset) if @offset.positive?
|
|
57
|
+
window.each_with_index { |option, i| lines << row(option, @offset + i == @cursor) }
|
|
58
|
+
below = options.size - @offset - window.size
|
|
59
|
+
lines << more("↓", below) if below.positive?
|
|
60
|
+
lines.join("\n")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def key(name, _message)
|
|
66
|
+
case name
|
|
67
|
+
when "enter" then return submit(options[@cursor].value)
|
|
68
|
+
when "up", "k", "shift+tab" then @cursor = (@cursor - 1) % options.size
|
|
69
|
+
when "down", "j", "tab" then @cursor = (@cursor + 1) % options.size
|
|
70
|
+
when "home", "g", "pgup" then @cursor = name == "pgup" ? [@cursor - VISIBLE, 0].max : 0
|
|
71
|
+
when "end", "G", "pgdown" then @cursor = name == "pgdown" ? [@cursor + VISIBLE, options.size - 1].min : options.size - 1
|
|
72
|
+
else return nil
|
|
73
|
+
end
|
|
74
|
+
scroll
|
|
75
|
+
[self, nil]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def window = options[@offset, VISIBLE]
|
|
79
|
+
|
|
80
|
+
# Keeps the cursor inside the visible window, moving it as little as possible.
|
|
81
|
+
def scroll
|
|
82
|
+
@offset = @cursor if @cursor < @offset
|
|
83
|
+
@offset = @cursor - VISIBLE + 1 if @cursor >= @offset + VISIBLE
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The pointer marks the choice without colour too (NO_COLOR on a terminal).
|
|
87
|
+
def row(option, selected)
|
|
88
|
+
label = option.label
|
|
89
|
+
selected ? " #{shell.paint("#{shell.theme.symbol(:pointer)} #{label}", :highlight)}" : " #{label}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def more(arrow, count) = " #{shell.paint("#{arrow} #{count} more", :muted)}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
module_function
|
|
96
|
+
|
|
97
|
+
# Choices as Options: an Array's items are their own labels, a Hash maps label ⇒ value.
|
|
98
|
+
def options(choices)
|
|
99
|
+
list = choices.is_a?(Hash) ? choices.map { |label, value| Option.new(label.to_s, value) } : choices.map { |c| Option.new(c.to_s, c) }
|
|
100
|
+
raise ArgumentError, "choose needs at least one choice" if list.empty?
|
|
101
|
+
|
|
102
|
+
list
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The index of `default` (a value or a label) in options; nil for no default.
|
|
106
|
+
def default_index(options, default)
|
|
107
|
+
return nil if default.nil?
|
|
108
|
+
|
|
109
|
+
options.index { |o| o.value == default } || options.index { |o| o.label == default.to_s } ||
|
|
110
|
+
raise(ArgumentError, "default #{default.inspect} is not one of the choices")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def answered(shell, question, label)
|
|
114
|
+
"#{shell.symbol(:success)} #{question} #{shell.paint("·", :muted)} #{shell.paint(label, :accent)}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# The Option a typed line names (a label, any case, or a 1-based number); nil if none.
|
|
118
|
+
def parse(line, options)
|
|
119
|
+
answer = line.to_s.strip
|
|
120
|
+
return options[answer.to_i - 1] if answer.match?(/\A\d+\z/) && answer.to_i.between?(1, options.size)
|
|
121
|
+
|
|
122
|
+
options.find { |o| o.label == answer } || options.find { |o| o.label.casecmp?(answer) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def ask_line(shell, question, options, default)
|
|
126
|
+
labels = options.map(&:label).join(", ")
|
|
127
|
+
options.each_with_index { |o, i| shell.err_puts(" #{i + 1}) #{o.label}") } if shell.input_tty?
|
|
128
|
+
hint = default ? "[#{options[default].label}] " : ""
|
|
129
|
+
loop do
|
|
130
|
+
line = Prompt.read_line(shell, "#{question} #{hint}")
|
|
131
|
+
if line.nil? || line.strip.empty?
|
|
132
|
+
option = default && options[default]
|
|
133
|
+
raise Error, "no answer for #{question.inspect} (choose one of: #{labels}); pass it as an option or give a default" if line.nil? && !option
|
|
134
|
+
else
|
|
135
|
+
option = parse(line, options)
|
|
136
|
+
raise Error, "#{line.strip.inspect} is not a choice for #{question.inspect} (choose one of: #{labels})" if !option && !shell.input_tty?
|
|
137
|
+
end
|
|
138
|
+
if option
|
|
139
|
+
shell.err_puts(answered(shell, question, option.label)) unless shell.input_tty?
|
|
140
|
+
return option.value
|
|
141
|
+
end
|
|
142
|
+
raise Error, "no answer for #{question.inspect} (choose one of: #{labels})" unless shell.input_tty?
|
|
143
|
+
|
|
144
|
+
shell.err_puts("Please pick one of the choices or its number (1-#{options.size}).")
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
extension :choose do
|
|
151
|
+
helpers do
|
|
152
|
+
def choose(question, choices, default: nil)
|
|
153
|
+
options = Ext::Choose.options(choices)
|
|
154
|
+
index = Ext::Choose.default_index(options, default)
|
|
155
|
+
return Ext::Choose.ask_line(shell, question, options, index) unless shell.interactive?
|
|
156
|
+
|
|
157
|
+
Prompt.run(shell, Ext::Choose::Model.new(shell, question, options, index)).value
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c20-choose-many: pick several from a list.
|
|
4
|
+
#
|
|
5
|
+
# choose_many("Features?", %w[api web worker]) # => ["api", "worker"]
|
|
6
|
+
# choose_many("Features?", %w[api web worker], selected: %w[web], min: 1, max: 2)
|
|
7
|
+
# choose_many("Install?", { "API server" => :api, "Web app" => :web }) # => [:api]
|
|
8
|
+
#
|
|
9
|
+
# Choices are an Array (each item is its own label, shown with to_s) or a Hash of label => value.
|
|
10
|
+
# `selected:` lists the values checked at the start (labels work too). The chosen values come back
|
|
11
|
+
# in list order, whatever order they were picked in.
|
|
12
|
+
#
|
|
13
|
+
# On a terminal (Shell#interactive?) it shows a checkbox list inline, like gum choose --no-limit:
|
|
14
|
+
#
|
|
15
|
+
# ? Features?
|
|
16
|
+
# › ◯ api
|
|
17
|
+
# ◉ web
|
|
18
|
+
# ◯ worker
|
|
19
|
+
# space toggle · a all · enter submit
|
|
20
|
+
#
|
|
21
|
+
# ↑/↓ (k/j) move (wrapping), space toggles, a checks all (or clears all when all are checked), enter
|
|
22
|
+
# submits once the count is within `min:`..`max:` (otherwise it says "Pick at least 1" and waits).
|
|
23
|
+
# Past 10 items the list scrolls, with "↑ 3 more" / "↓ 4 more" hints. It leaves
|
|
24
|
+
# "✔ Features? · api, web" ("· none" for an empty answer) in the scrollback. ctrl+c or esc raises
|
|
25
|
+
# Interrupt (a command exits 130) after the terminal is restored.
|
|
26
|
+
#
|
|
27
|
+
# Off a terminal it reads one comma-separated line of labels (any case) or 1-based numbers:
|
|
28
|
+
# "web, 1". An empty line or end of input means `selected:`. When a person can still type (stdin
|
|
29
|
+
# is a terminal, stdout piped) it lists the numbered choices and asks
|
|
30
|
+
# "Features? (comma-separated) [web] " on stderr, asking again after an answer it can't use; from
|
|
31
|
+
# a pipe (`echo api,web | tool`, CI) it doesn't ask, records "✔ Features? · api, web" on stderr,
|
|
32
|
+
# and an unknown choice or a count outside min..max is an error naming the question.
|
|
33
|
+
module R2UI
|
|
34
|
+
module CLI
|
|
35
|
+
module Ext
|
|
36
|
+
module ChooseMany
|
|
37
|
+
PAGE = 10
|
|
38
|
+
HINT = "space toggle · a all · enter submit"
|
|
39
|
+
|
|
40
|
+
# Labels and values, in list order.
|
|
41
|
+
Choices = Struct.new(:labels, :values) do
|
|
42
|
+
def size = labels.size
|
|
43
|
+
|
|
44
|
+
# Indexes for `selected:` values (or labels); raises on one that isn't a choice.
|
|
45
|
+
def indexes_of(items)
|
|
46
|
+
Array(items).map do |item|
|
|
47
|
+
values.index(item) || labels.index(item.to_s) or
|
|
48
|
+
raise ArgumentError, "selected: #{item.inspect} is not one of the choices"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The index for a typed label or 1-based number; nil when it matches nothing.
|
|
53
|
+
def lookup(token)
|
|
54
|
+
if token.match?(/\A\d+\z/)
|
|
55
|
+
n = token.to_i
|
|
56
|
+
return n - 1 if n.between?(1, size)
|
|
57
|
+
|
|
58
|
+
return nil
|
|
59
|
+
end
|
|
60
|
+
labels.index(token) || labels.index { |label| label.casecmp?(token) }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The inline prompt: a Bubbletea model on R2UI::CLI::Prompt.
|
|
65
|
+
class Model < Prompt::Model
|
|
66
|
+
attr_reader :question
|
|
67
|
+
|
|
68
|
+
def initialize(shell, question, choices, checked, min, max)
|
|
69
|
+
super(shell)
|
|
70
|
+
@question = question
|
|
71
|
+
@choices = choices
|
|
72
|
+
@checked = checked
|
|
73
|
+
@min = min
|
|
74
|
+
@max = max
|
|
75
|
+
@cursor = 0
|
|
76
|
+
@offset = 0
|
|
77
|
+
@problem = nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def view
|
|
81
|
+
return "#{shell.symbol(:error)} #{question}" if interrupted?
|
|
82
|
+
return ChooseMany.answered(shell, question, @choices, value_indexes) if done?
|
|
83
|
+
|
|
84
|
+
lines = ["#{shell.paint("?", :accent)} #{question}"]
|
|
85
|
+
lines << shell.paint(" ↑ #{@offset} more", :muted) if @offset.positive?
|
|
86
|
+
visible.each { |i| lines << row(i) }
|
|
87
|
+
below = @choices.size - (@offset + visible.size)
|
|
88
|
+
lines << shell.paint(" ↓ #{below} more", :muted) if below.positive?
|
|
89
|
+
lines << (@problem ? shell.paint(@problem, :warn) : shell.paint(HINT, :muted))
|
|
90
|
+
lines.join("\n")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def key(name, _message)
|
|
96
|
+
case name
|
|
97
|
+
when "up", "k" then move(-1)
|
|
98
|
+
when "down", "j" then move(1)
|
|
99
|
+
when "space", " " then toggle(@cursor)
|
|
100
|
+
when "a" then toggle_all
|
|
101
|
+
when "enter" then return finish
|
|
102
|
+
else return nil
|
|
103
|
+
end
|
|
104
|
+
[self, nil]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def move(step)
|
|
108
|
+
@cursor = (@cursor + step) % @choices.size
|
|
109
|
+
@offset = @cursor if @cursor < @offset
|
|
110
|
+
@offset = @cursor - PAGE + 1 if @cursor >= @offset + PAGE
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def toggle(index)
|
|
114
|
+
@problem = nil
|
|
115
|
+
@checked.include?(index) ? @checked.delete(index) : @checked << index
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def toggle_all
|
|
119
|
+
@problem = nil
|
|
120
|
+
@checked = @checked.size == @choices.size ? [] : (0...@choices.size).to_a
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def finish
|
|
124
|
+
@problem = ChooseMany.count_problem(@checked.size, @min, @max)
|
|
125
|
+
return [self, nil] if @problem
|
|
126
|
+
|
|
127
|
+
@indexes = @checked.sort
|
|
128
|
+
submit(@indexes.map { |i| @choices.values[i] })
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def value_indexes = @indexes || []
|
|
132
|
+
|
|
133
|
+
def visible = (@offset...[@offset + PAGE, @choices.size].min).to_a
|
|
134
|
+
|
|
135
|
+
def row(index)
|
|
136
|
+
pointer = index == @cursor ? shell.paint(shell.theme.symbol(:pointer), :accent) : " "
|
|
137
|
+
box = @checked.include?(index) ? shell.paint("◉", :success) : shell.paint("◯", :muted)
|
|
138
|
+
label = @choices.labels[index]
|
|
139
|
+
label = shell.paint(label, :highlight) if index == @cursor
|
|
140
|
+
"#{pointer} #{box} #{label}"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
module_function
|
|
145
|
+
|
|
146
|
+
def choices(list)
|
|
147
|
+
raise ArgumentError, "choose_many needs at least one choice" if list.nil? || list.empty?
|
|
148
|
+
|
|
149
|
+
if list.is_a?(Hash)
|
|
150
|
+
Choices.new(list.keys.map(&:to_s), list.values)
|
|
151
|
+
else
|
|
152
|
+
Choices.new(list.map(&:to_s), list.to_a)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def check_bounds(min, max)
|
|
157
|
+
raise ArgumentError, "min: must be 0 or more" if min.negative?
|
|
158
|
+
raise ArgumentError, "max: (#{max}) is less than min: (#{min})" if max && max < min
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def build_model(shell, question, list, selected, min, max)
|
|
162
|
+
check_bounds(min, max)
|
|
163
|
+
choices = choices(list)
|
|
164
|
+
Model.new(shell, question, choices, choices.indexes_of(selected).uniq, min, max)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# "Pick at least 1" / "Pick at most 2" when `count` is out of bounds, else nil.
|
|
168
|
+
def count_problem(count, min, max)
|
|
169
|
+
return "Pick at least #{min}" if count < min
|
|
170
|
+
|
|
171
|
+
"Pick at most #{max}" if max && count > max
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def answered(shell, question, choices, indexes)
|
|
175
|
+
answer = indexes.empty? ? shell.paint("none", :muted) : shell.paint(indexes.map { |i| choices.labels[i] }.join(", "), :accent)
|
|
176
|
+
"#{shell.symbol(:success)} #{question} #{shell.paint("·", :muted)} #{answer}"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Sorted indexes for a typed line, `default` for an empty one; raises ArgumentError naming
|
|
180
|
+
# the first token it can't read.
|
|
181
|
+
def parse(line, choices, default)
|
|
182
|
+
tokens = line.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
183
|
+
return default if tokens.empty?
|
|
184
|
+
|
|
185
|
+
tokens.map { |token| choices.lookup(token) || raise(ArgumentError, token) }.uniq.sort
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def ask_line(shell, question, list, selected, min, max)
|
|
189
|
+
check_bounds(min, max)
|
|
190
|
+
choices = choices(list)
|
|
191
|
+
default = choices.indexes_of(selected).uniq.sort
|
|
192
|
+
person = shell.input_tty?
|
|
193
|
+
prompt = "#{question} (comma-separated) "
|
|
194
|
+
prompt += "[#{default.map { |i| choices.labels[i] }.join(", ")}] " unless default.empty?
|
|
195
|
+
list_choices(shell, choices) if person
|
|
196
|
+
loop do
|
|
197
|
+
line = Prompt.read_line(shell, prompt)
|
|
198
|
+
problem =
|
|
199
|
+
begin
|
|
200
|
+
indexes = parse(line, choices, default)
|
|
201
|
+
count_problem(indexes.size, min, max)
|
|
202
|
+
rescue ArgumentError => e
|
|
203
|
+
bad = e.message
|
|
204
|
+
person ? "Unknown choice #{bad.inspect}" : "unknown choice #{bad.inspect} for #{question.inspect} (choices: #{choices.labels.join(", ")})"
|
|
205
|
+
end
|
|
206
|
+
unless problem
|
|
207
|
+
shell.err_puts(plain_answer(shell, question, choices, indexes)) unless person
|
|
208
|
+
return indexes.map { |i| choices.values[i] }
|
|
209
|
+
end
|
|
210
|
+
raise Error, (bad ? problem : "#{problem.sub("Pick", "pick")} for #{question.inspect}") unless person
|
|
211
|
+
raise Error, "no answer for #{question.inspect}: #{problem.downcase}" if line.nil?
|
|
212
|
+
|
|
213
|
+
shell.err_puts("#{problem}.")
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def list_choices(shell, choices)
|
|
218
|
+
digits = choices.size.to_s.size
|
|
219
|
+
choices.labels.each_with_index { |label, i| shell.err_puts(" #{(i + 1).to_s.rjust(digits)}. #{label}") }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def plain_answer(shell, question, choices, indexes)
|
|
223
|
+
answer = indexes.empty? ? "none" : indexes.map { |i| choices.labels[i] }.join(", ")
|
|
224
|
+
"#{shell.symbol(:success)} #{question} · #{answer}"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
extension :choose_many do
|
|
230
|
+
helpers do
|
|
231
|
+
def choose_many(question, choices, selected: [], min: 0, max: nil)
|
|
232
|
+
unless shell.interactive?
|
|
233
|
+
return Ext::ChooseMany.ask_line(shell, question, choices, selected, min, max)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
Prompt.run(shell, Ext::ChooseMany.build_model(shell, question, choices, selected, min, max)).value
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# c31-color-option: let the person running a tool turn colour on or off.
|
|
4
|
+
#
|
|
5
|
+
# R2UI.cli "deployer" do
|
|
6
|
+
# color_option # on the root: every command takes --color / --no-color
|
|
7
|
+
# command(:deploy) { run { say "Deployed", :success } }
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# deployer deploy --no-color # on a terminal: no colour codes, live redraw still happens
|
|
11
|
+
# deployer deploy --color | less -R # in a pipe: styled as on a terminal, one line per event
|
|
12
|
+
#
|
|
13
|
+
# Without either flag the shell decides as usual (NO_COLOR, FORCE_COLOR, a terminal or not). Given,
|
|
14
|
+
# the flag wins over those variables, and the last one given wins (`--color --no-color` is off).
|
|
15
|
+
# It only changes colour: a pipe still gets plain stable lines (no spinners or cursor movement),
|
|
16
|
+
# and a terminal still redraws live output in place.
|
|
17
|
+
#
|
|
18
|
+
# The flag is applied right after argv is parsed, so usage errors about arguments, the command's
|
|
19
|
+
# output and its error report all follow it. `tool --no-color --help` is the exception: the core
|
|
20
|
+
# prints help before any extension hook runs.
|
|
21
|
+
#
|
|
22
|
+
# Lipgloss picks its colour profile once from the process's stdout, which is "no colour" when stdout
|
|
23
|
+
# is piped; `--color` raises that to the 16-colour ANSI profile (the theme uses palette numbers), so
|
|
24
|
+
# the styles really come out in a pipe.
|
|
25
|
+
module R2UI
|
|
26
|
+
module CLI
|
|
27
|
+
module Ext
|
|
28
|
+
module ColorOption
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
def apply(shell, on)
|
|
32
|
+
shell.color = on
|
|
33
|
+
force_lipgloss_colour if on
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def force_lipgloss_colour
|
|
37
|
+
renderer = R2UI::Compat::Gloss::Renderer
|
|
38
|
+
renderer.color_profile = :ansi if renderer.color_profile.to_sym == :ascii
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
extension :color_option do
|
|
44
|
+
dsl(:command) do
|
|
45
|
+
# Adds --color / --no-color to every command (on the root only).
|
|
46
|
+
def color_option
|
|
47
|
+
raise ArgumentError, "color_option belongs on the root command" unless definition.root?
|
|
48
|
+
|
|
49
|
+
declare(:color_option, true)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
setup do
|
|
54
|
+
if declared(:color_option).any?
|
|
55
|
+
flag :color, default: nil, desc: "Colour the output (--no-color turns it off; default: on a terminal)"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
after_parse do
|
|
60
|
+
Ext::ColorOption.apply(shell, options[:color]) if program.declared(:color_option).any? && given?(:color)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|