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.
Files changed (133) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -0
  3. data/README.md +79 -0
  4. data/lib/r2ui/app.rb +311 -34
  5. data/lib/r2ui/canvas.rb +135 -14
  6. data/lib/r2ui/cli/builder.rb +72 -0
  7. data/lib/r2ui/cli/context.rb +45 -0
  8. data/lib/r2ui/cli/definition.rb +174 -0
  9. data/lib/r2ui/cli/ext/ask.rb +133 -0
  10. data/lib/r2ui/cli/ext/box.rb +112 -0
  11. data/lib/r2ui/cli/ext/choose.rb +162 -0
  12. data/lib/r2ui/cli/ext/choose_many.rb +241 -0
  13. data/lib/r2ui/cli/ext/color_option.rb +64 -0
  14. data/lib/r2ui/cli/ext/completion.rb +520 -0
  15. data/lib/r2ui/cli/ext/config.rb +179 -0
  16. data/lib/r2ui/cli/ext/confirm.rb +110 -0
  17. data/lib/r2ui/cli/ext/dashboard.rb +105 -0
  18. data/lib/r2ui/cli/ext/diff.rb +195 -0
  19. data/lib/r2ui/cli/ext/editor.rb +94 -0
  20. data/lib/r2ui/cli/ext/env.rb +108 -0
  21. data/lib/r2ui/cli/ext/examples.rb +62 -0
  22. data/lib/r2ui/cli/ext/filter.rb +269 -0
  23. data/lib/r2ui/cli/ext/format.rb +104 -0
  24. data/lib/r2ui/cli/ext/list.rb +117 -0
  25. data/lib/r2ui/cli/ext/log.rb +89 -0
  26. data/lib/r2ui/cli/ext/pager.rb +97 -0
  27. data/lib/r2ui/cli/ext/pairs.rb +62 -0
  28. data/lib/r2ui/cli/ext/parallel.rb +274 -0
  29. data/lib/r2ui/cli/ext/password.rb +158 -0
  30. data/lib/r2ui/cli/ext/progress.rb +330 -0
  31. data/lib/r2ui/cli/ext/reference.rb +129 -0
  32. data/lib/r2ui/cli/ext/spinner.rb +165 -0
  33. data/lib/r2ui/cli/ext/summary.rb +63 -0
  34. data/lib/r2ui/cli/ext/table.rb +141 -0
  35. data/lib/r2ui/cli/ext/tasks.rb +216 -0
  36. data/lib/r2ui/cli/ext/trace.rb +64 -0
  37. data/lib/r2ui/cli/ext/tree.rb +114 -0
  38. data/lib/r2ui/cli/ext/version.rb +67 -0
  39. data/lib/r2ui/cli/extension.rb +129 -0
  40. data/lib/r2ui/cli/help.rb +115 -0
  41. data/lib/r2ui/cli/helpers.rb +20 -0
  42. data/lib/r2ui/cli/live.rb +150 -0
  43. data/lib/r2ui/cli/parser.rb +165 -0
  44. data/lib/r2ui/cli/prompt.rb +133 -0
  45. data/lib/r2ui/cli/runner.rb +86 -0
  46. data/lib/r2ui/cli/shell.rb +185 -0
  47. data/lib/r2ui/cli/testing.rb +49 -0
  48. data/lib/r2ui/cli.rb +129 -0
  49. data/lib/r2ui/compat/bubbletea/LICENSE-bubbletea.txt +21 -0
  50. data/lib/r2ui/compat/bubbletea/ansi.rb +424 -0
  51. data/lib/r2ui/compat/bubbletea/commands.rb +143 -0
  52. data/lib/r2ui/compat/bubbletea/input.rb +269 -0
  53. data/lib/r2ui/compat/bubbletea/input_reader.rb +66 -0
  54. data/lib/r2ui/compat/bubbletea/messages.rb +251 -0
  55. data/lib/r2ui/compat/bubbletea/model.rb +65 -0
  56. data/lib/r2ui/compat/bubbletea/native.rb +254 -0
  57. data/lib/r2ui/compat/bubbletea/renderer.rb +192 -0
  58. data/lib/r2ui/compat/bubbletea/runner.rb +425 -0
  59. data/lib/r2ui/compat/bubbletea/terminal.rb +230 -0
  60. data/lib/r2ui/compat/bubbletea/version.rb +8 -0
  61. data/lib/r2ui/compat/bubbletea/width_table.rb +228 -0
  62. data/lib/r2ui/compat/bubbletea.rb +16 -0
  63. data/lib/r2ui/compat/lipgloss/border.rb +17 -0
  64. data/lib/r2ui/compat/lipgloss/color.rb +78 -0
  65. data/lib/r2ui/compat/lipgloss/color_blend.rb +166 -0
  66. data/lib/r2ui/compat/lipgloss/colorful.rb +585 -0
  67. data/lib/r2ui/compat/lipgloss/layout.rb +461 -0
  68. data/lib/r2ui/compat/lipgloss/list.rb +85 -0
  69. data/lib/r2ui/compat/lipgloss/position.rb +30 -0
  70. data/lib/r2ui/compat/lipgloss/style/border.rb +134 -0
  71. data/lib/r2ui/compat/lipgloss/style.rb +1062 -0
  72. data/lib/r2ui/compat/lipgloss/table/render.rb +518 -0
  73. data/lib/r2ui/compat/lipgloss/table.rb +185 -0
  74. data/lib/r2ui/compat/lipgloss/termenv.rb +768 -0
  75. data/lib/r2ui/compat/lipgloss/text/unicode_tables.rb +428 -0
  76. data/lib/r2ui/compat/lipgloss/text.rb +1555 -0
  77. data/lib/r2ui/compat/lipgloss/tree/go_tree.rb +243 -0
  78. data/lib/r2ui/compat/lipgloss/tree.rb +105 -0
  79. data/lib/r2ui/compat/lipgloss/version.rb +15 -0
  80. data/lib/r2ui/compat/lipgloss.rb +64 -0
  81. data/lib/r2ui/compat/load_path/bubbletea.rb +4 -0
  82. data/lib/r2ui/compat/load_path/lipgloss.rb +4 -0
  83. data/lib/r2ui/component.rb +67 -0
  84. data/lib/r2ui/context.rb +87 -0
  85. data/lib/r2ui/drop_in.rb +16 -0
  86. data/lib/r2ui/dsl/column.rb +11 -3
  87. data/lib/r2ui/dsl/dashboard.rb +45 -9
  88. data/lib/r2ui/dsl/resource.rb +12 -0
  89. data/lib/r2ui/ext/after.rb +54 -0
  90. data/lib/r2ui/ext/async.rb +81 -0
  91. data/lib/r2ui/ext/batch_sequence.rb +54 -0
  92. data/lib/r2ui/ext/countdown.rb +52 -0
  93. data/lib/r2ui/ext/data_table.rb +71 -0
  94. data/lib/r2ui/ext/every.rb +66 -0
  95. data/lib/r2ui/ext/exec.rb +116 -0
  96. data/lib/r2ui/ext/file_picker.rb +78 -0
  97. data/lib/r2ui/ext/focus_report.rb +69 -0
  98. data/lib/r2ui/ext/help.rb +62 -0
  99. data/lib/r2ui/ext/keys.rb +64 -0
  100. data/lib/r2ui/ext/list.rb +145 -0
  101. data/lib/r2ui/ext/messages.rb +87 -0
  102. data/lib/r2ui/ext/mouse.rb +134 -0
  103. data/lib/r2ui/ext/paginator.rb +86 -0
  104. data/lib/r2ui/ext/paste.rb +86 -0
  105. data/lib/r2ui/ext/println.rb +21 -0
  106. data/lib/r2ui/ext/progress.rb +85 -0
  107. data/lib/r2ui/ext/refresh.rb +56 -0
  108. data/lib/r2ui/ext/resize.rb +62 -0
  109. data/lib/r2ui/ext/screen.rb +47 -0
  110. data/lib/r2ui/ext/screen_mode.rb +63 -0
  111. data/lib/r2ui/ext/spinner.rb +59 -0
  112. data/lib/r2ui/ext/state.rb +45 -0
  113. data/lib/r2ui/ext/stopwatch.rb +67 -0
  114. data/lib/r2ui/ext/suspend.rb +56 -0
  115. data/lib/r2ui/ext/text_area.rb +73 -0
  116. data/lib/r2ui/ext/text_input.rb +83 -0
  117. data/lib/r2ui/ext/theme.rb +112 -0
  118. data/lib/r2ui/ext/view.rb +37 -0
  119. data/lib/r2ui/ext/viewport.rb +92 -0
  120. data/lib/r2ui/ext/window_title.rb +51 -0
  121. data/lib/r2ui/extension.rb +199 -0
  122. data/lib/r2ui/history.rb +4 -1
  123. data/lib/r2ui/keys.rb +35 -8
  124. data/lib/r2ui/motion.rb +169 -0
  125. data/lib/r2ui/panel_state.rb +46 -2
  126. data/lib/r2ui/renderer.rb +87 -22
  127. data/lib/r2ui/version.rb +1 -1
  128. data/lib/r2ui/widgets/box.rb +6 -3
  129. data/lib/r2ui/widgets/glyphs.rb +149 -0
  130. data/lib/r2ui/widgets/table.rb +109 -25
  131. data/lib/r2ui.rb +12 -1
  132. metadata +119 -4
  133. 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