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,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