clack 0.1.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 +7 -0
- data/CHANGELOG.md +17 -0
- data/LICENSE +24 -0
- data/README.md +424 -0
- data/exe/clack-demo +9 -0
- data/lib/clack/box.rb +120 -0
- data/lib/clack/colors.rb +55 -0
- data/lib/clack/core/cursor.rb +61 -0
- data/lib/clack/core/key_reader.rb +45 -0
- data/lib/clack/core/options_helper.rb +96 -0
- data/lib/clack/core/prompt.rb +215 -0
- data/lib/clack/core/settings.rb +97 -0
- data/lib/clack/core/text_input_helper.rb +83 -0
- data/lib/clack/environment.rb +137 -0
- data/lib/clack/group.rb +100 -0
- data/lib/clack/log.rb +42 -0
- data/lib/clack/note.rb +49 -0
- data/lib/clack/prompts/autocomplete.rb +162 -0
- data/lib/clack/prompts/autocomplete_multiselect.rb +280 -0
- data/lib/clack/prompts/confirm.rb +100 -0
- data/lib/clack/prompts/group_multiselect.rb +250 -0
- data/lib/clack/prompts/multiselect.rb +185 -0
- data/lib/clack/prompts/password.rb +77 -0
- data/lib/clack/prompts/path.rb +226 -0
- data/lib/clack/prompts/progress.rb +145 -0
- data/lib/clack/prompts/select.rb +134 -0
- data/lib/clack/prompts/select_key.rb +100 -0
- data/lib/clack/prompts/spinner.rb +206 -0
- data/lib/clack/prompts/tasks.rb +131 -0
- data/lib/clack/prompts/text.rb +93 -0
- data/lib/clack/stream.rb +82 -0
- data/lib/clack/symbols.rb +84 -0
- data/lib/clack/task_log.rb +174 -0
- data/lib/clack/utils.rb +135 -0
- data/lib/clack/validators.rb +145 -0
- data/lib/clack/version.rb +5 -0
- data/lib/clack.rb +576 -0
- metadata +83 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
|
|
5
|
+
module Clack
|
|
6
|
+
# Environment detection utilities for cross-platform compatibility
|
|
7
|
+
# and CI/terminal environment awareness.
|
|
8
|
+
module Environment
|
|
9
|
+
class << self
|
|
10
|
+
# Check if running on Windows
|
|
11
|
+
# @return [Boolean]
|
|
12
|
+
def windows?
|
|
13
|
+
return @windows if defined?(@windows)
|
|
14
|
+
|
|
15
|
+
@windows = !!(RUBY_PLATFORM =~ /mswin|mingw|cygwin|bccwin/i)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Check if running in a CI environment
|
|
19
|
+
# Common CI env vars: CI, CONTINUOUS_INTEGRATION, BUILD_NUMBER, GITHUB_ACTIONS, etc.
|
|
20
|
+
# @return [Boolean]
|
|
21
|
+
def ci?
|
|
22
|
+
return @ci if defined?(@ci)
|
|
23
|
+
|
|
24
|
+
@ci = ENV["CI"] == "true" ||
|
|
25
|
+
ENV["CONTINUOUS_INTEGRATION"] == "true" ||
|
|
26
|
+
ENV.key?("BUILD_NUMBER") ||
|
|
27
|
+
ENV.key?("GITHUB_ACTIONS") ||
|
|
28
|
+
ENV.key?("GITLAB_CI") ||
|
|
29
|
+
ENV.key?("CIRCLECI") ||
|
|
30
|
+
ENV.key?("TRAVIS") ||
|
|
31
|
+
ENV.key?("JENKINS_URL") ||
|
|
32
|
+
ENV.key?("TEAMCITY_VERSION") ||
|
|
33
|
+
ENV.key?("BUILDKITE")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Check if stdout is a TTY (interactive terminal)
|
|
37
|
+
# @param output [IO] Output stream to check (default: $stdout)
|
|
38
|
+
# @return [Boolean]
|
|
39
|
+
def tty?(output = $stdout)
|
|
40
|
+
output.respond_to?(:tty?) && output.tty?
|
|
41
|
+
rescue IOError
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Check if running in Windows Terminal (modern)
|
|
46
|
+
# @return [Boolean]
|
|
47
|
+
def windows_terminal?
|
|
48
|
+
windows? && ENV.key?("WT_SESSION")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Check if running in a dumb terminal (no ANSI support)
|
|
52
|
+
# @return [Boolean]
|
|
53
|
+
def dumb_terminal?
|
|
54
|
+
ENV["TERM"] == "dumb"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Check if ANSI colors are supported
|
|
58
|
+
# @param output [IO] Output stream to check
|
|
59
|
+
# @return [Boolean]
|
|
60
|
+
def colors_supported?(output = $stdout)
|
|
61
|
+
return false if ENV["NO_COLOR"]
|
|
62
|
+
return true if ENV["FORCE_COLOR"]
|
|
63
|
+
return false unless tty?(output)
|
|
64
|
+
return false if dumb_terminal?
|
|
65
|
+
|
|
66
|
+
# Windows: Modern Windows Terminal, ConEmu, ANSICON, or Windows 10 1511+
|
|
67
|
+
# all support ANSI. We optimistically assume modern systems support it.
|
|
68
|
+
true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Get terminal columns (width)
|
|
72
|
+
# @param output [IO] Output stream (default: $stdout)
|
|
73
|
+
# @param default [Integer] Default if detection fails
|
|
74
|
+
# @return [Integer]
|
|
75
|
+
def columns(output = $stdout, default: 80)
|
|
76
|
+
return default unless tty?(output)
|
|
77
|
+
|
|
78
|
+
if output.respond_to?(:winsize)
|
|
79
|
+
_, cols = output.winsize
|
|
80
|
+
(cols > 0) ? cols : default
|
|
81
|
+
else
|
|
82
|
+
default
|
|
83
|
+
end
|
|
84
|
+
rescue IOError, SystemCallError
|
|
85
|
+
default
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Get terminal rows (height)
|
|
89
|
+
# @param output [IO] Output stream (default: $stdout)
|
|
90
|
+
# @param default [Integer] Default if detection fails
|
|
91
|
+
# @return [Integer]
|
|
92
|
+
def rows(output = $stdout, default: 24)
|
|
93
|
+
return default unless tty?(output)
|
|
94
|
+
|
|
95
|
+
if output.respond_to?(:winsize)
|
|
96
|
+
rows, = output.winsize
|
|
97
|
+
(rows > 0) ? rows : default
|
|
98
|
+
else
|
|
99
|
+
default
|
|
100
|
+
end
|
|
101
|
+
rescue IOError, SystemCallError
|
|
102
|
+
default
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Get terminal dimensions as [rows, columns]
|
|
106
|
+
# @param output [IO] Output stream
|
|
107
|
+
# @return [Array<Integer>] [rows, columns]
|
|
108
|
+
def dimensions(output = $stdout)
|
|
109
|
+
[rows(output), columns(output)]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Check if raw mode is supported for input
|
|
113
|
+
# @param input [IO] Input stream (default: $stdin)
|
|
114
|
+
# @return [Boolean]
|
|
115
|
+
def raw_mode_supported?(input = $stdin)
|
|
116
|
+
return false unless input.respond_to?(:raw)
|
|
117
|
+
|
|
118
|
+
# On Windows without proper console, raw mode may fail
|
|
119
|
+
if windows? && !windows_terminal?
|
|
120
|
+
begin
|
|
121
|
+
IO.console&.respond_to?(:raw)
|
|
122
|
+
rescue
|
|
123
|
+
false
|
|
124
|
+
end
|
|
125
|
+
else
|
|
126
|
+
true
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Reset cached environment checks (useful for testing)
|
|
131
|
+
def reset!
|
|
132
|
+
remove_instance_variable(:@windows) if defined?(@windows)
|
|
133
|
+
remove_instance_variable(:@ci) if defined?(@ci)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
data/lib/clack/group.rb
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clack
|
|
4
|
+
# Collects results from multiple prompts and handles cancellation gracefully.
|
|
5
|
+
#
|
|
6
|
+
# @example Basic usage
|
|
7
|
+
# result = Clack.group do |g|
|
|
8
|
+
# g.prompt(:name) { Clack.text(message: "Your name?") }
|
|
9
|
+
# g.prompt(:age) { Clack.text(message: "Your age?") }
|
|
10
|
+
# end
|
|
11
|
+
# # => { name: "Alice", age: "30" } or Clack::CANCEL
|
|
12
|
+
#
|
|
13
|
+
# @example With previous results
|
|
14
|
+
# result = Clack.group do |g|
|
|
15
|
+
# g.prompt(:name) { Clack.text(message: "Your name?") }
|
|
16
|
+
# g.prompt(:greeting) { |r| Clack.text(message: "Hello #{r[:name]}!") }
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
class Group
|
|
20
|
+
# @return [Hash] The collected results
|
|
21
|
+
attr_reader :results
|
|
22
|
+
|
|
23
|
+
def initialize(on_cancel: nil)
|
|
24
|
+
@results = {}
|
|
25
|
+
@prompts = []
|
|
26
|
+
@on_cancel = on_cancel
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Define a prompt in the group.
|
|
30
|
+
#
|
|
31
|
+
# @param name [Symbol, String] The key for this result
|
|
32
|
+
# @yield [results] Block that returns a prompt result
|
|
33
|
+
# @yieldparam results [Hash] Previous results collected so far
|
|
34
|
+
# @return [void]
|
|
35
|
+
def prompt(name, &block)
|
|
36
|
+
raise ArgumentError, "Block required for prompt :#{name}" unless block_given?
|
|
37
|
+
|
|
38
|
+
@prompts << {name: name.to_sym, block: block}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Run all prompts and collect results.
|
|
42
|
+
#
|
|
43
|
+
# @return [Hash, Clack::CANCEL] Results hash or CANCEL if user cancelled
|
|
44
|
+
def run
|
|
45
|
+
@prompts.each do |prompt_def|
|
|
46
|
+
name = prompt_def[:name]
|
|
47
|
+
block = prompt_def[:block]
|
|
48
|
+
|
|
49
|
+
# Pass previous results to the block if it accepts an argument
|
|
50
|
+
result = if block.arity.zero?
|
|
51
|
+
block.call
|
|
52
|
+
else
|
|
53
|
+
block.call(@results.dup.freeze)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if Clack.cancel?(result)
|
|
57
|
+
@results[name] = :cancelled
|
|
58
|
+
@on_cancel&.call(@results.dup.freeze)
|
|
59
|
+
return CANCEL
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
@results[name] = result
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
@results
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class << self
|
|
70
|
+
# Run a group of prompts and collect their results.
|
|
71
|
+
#
|
|
72
|
+
# If any prompt is cancelled, the entire group returns Clack::CANCEL.
|
|
73
|
+
# The on_cancel callback receives partial results collected so far.
|
|
74
|
+
#
|
|
75
|
+
# @param on_cancel [Proc, nil] Callback when a prompt is cancelled
|
|
76
|
+
# @yield [group] Block to define prompts
|
|
77
|
+
# @yieldparam group [Clack::Group] The group builder
|
|
78
|
+
# @return [Hash, Clack::CANCEL] Results hash or CANCEL
|
|
79
|
+
#
|
|
80
|
+
# @example
|
|
81
|
+
# result = Clack.group do |g|
|
|
82
|
+
# g.prompt(:name) { Clack.text(message: "Name?") }
|
|
83
|
+
# g.prompt(:confirm) { Clack.confirm(message: "Continue?") }
|
|
84
|
+
# end
|
|
85
|
+
#
|
|
86
|
+
# if Clack.cancel?(result)
|
|
87
|
+
# Clack.cancel("Cancelled")
|
|
88
|
+
# else
|
|
89
|
+
# puts "Name: #{result[:name]}"
|
|
90
|
+
# end
|
|
91
|
+
#
|
|
92
|
+
def group(on_cancel: nil, &block)
|
|
93
|
+
raise ArgumentError, "Block required for Clack.group" unless block_given?
|
|
94
|
+
|
|
95
|
+
group = Group.new(on_cancel: on_cancel)
|
|
96
|
+
block.call(group)
|
|
97
|
+
group.run
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
data/lib/clack/log.rb
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clack
|
|
4
|
+
module Log
|
|
5
|
+
class << self
|
|
6
|
+
def message(msg = "", symbol: nil, output: $stdout)
|
|
7
|
+
symbol ||= Colors.gray(Symbols::S_BAR)
|
|
8
|
+
lines = msg.to_s.lines
|
|
9
|
+
|
|
10
|
+
if lines.empty?
|
|
11
|
+
output.puts symbol
|
|
12
|
+
else
|
|
13
|
+
lines.each_with_index do |line, idx|
|
|
14
|
+
prefix = idx.zero? ? symbol : Colors.gray(Symbols::S_BAR)
|
|
15
|
+
output.puts "#{prefix} #{line.chomp}"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def info(msg, output: $stdout)
|
|
21
|
+
message(msg, symbol: Colors.blue(Symbols::S_INFO), output:)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def success(msg, output: $stdout)
|
|
25
|
+
message(msg, symbol: Colors.green(Symbols::S_SUCCESS), output:)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def step(msg, output: $stdout)
|
|
29
|
+
message(msg, symbol: Colors.green(Symbols::S_STEP_SUBMIT), output:)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def warn(msg, output: $stdout)
|
|
33
|
+
message(msg, symbol: Colors.yellow(Symbols::S_WARN), output:)
|
|
34
|
+
end
|
|
35
|
+
alias_method :warning, :warn
|
|
36
|
+
|
|
37
|
+
def error(msg, output: $stdout)
|
|
38
|
+
message(msg, symbol: Colors.red(Symbols::S_ERROR), output:)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
data/lib/clack/note.rb
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clack
|
|
4
|
+
module Note
|
|
5
|
+
class << self
|
|
6
|
+
def render(message = "", title: nil, output: $stdout)
|
|
7
|
+
lines = message.to_s.lines.map(&:chomp)
|
|
8
|
+
# Add empty lines at start and end like original
|
|
9
|
+
lines = ["", *lines, ""]
|
|
10
|
+
title_len = title&.length || 0
|
|
11
|
+
width = calculate_width(lines, title_len)
|
|
12
|
+
|
|
13
|
+
output.puts Colors.gray(Symbols::S_BAR)
|
|
14
|
+
output.puts build_top_border(title, title_len, width)
|
|
15
|
+
|
|
16
|
+
lines.each do |line|
|
|
17
|
+
padded = line.ljust(width)
|
|
18
|
+
output.puts "#{Colors.gray(Symbols::S_BAR)} #{Colors.dim(padded)}#{Colors.gray(Symbols::S_BAR)}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
output.puts build_bottom_border(width)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def calculate_width(lines, title_len)
|
|
27
|
+
max_line = lines.map(&:length).max || 0
|
|
28
|
+
[max_line, title_len].max + 2
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def build_top_border(title, title_len, width)
|
|
32
|
+
if title
|
|
33
|
+
# Format: ◇ title ───────╮
|
|
34
|
+
right_len = [width - title_len - 1, 1].max
|
|
35
|
+
right = "#{Symbols::S_BAR_H * right_len}#{Symbols::S_CORNER_TOP_RIGHT}"
|
|
36
|
+
"#{Colors.green(Symbols::S_STEP_SUBMIT)} #{title} #{Colors.gray(right)}"
|
|
37
|
+
else
|
|
38
|
+
border = Symbols::S_BAR_H * (width + 2)
|
|
39
|
+
"#{Colors.gray(Symbols::S_CORNER_TOP_LEFT)}#{Colors.gray(border)}#{Colors.gray(Symbols::S_CORNER_TOP_RIGHT)}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def build_bottom_border(width)
|
|
44
|
+
border = Symbols::S_BAR_H * (width + 2)
|
|
45
|
+
"#{Colors.gray(Symbols::S_CONNECT_LEFT)}#{Colors.gray(border)}#{Colors.gray(Symbols::S_CORNER_BOTTOM_RIGHT)}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clack
|
|
4
|
+
module Prompts
|
|
5
|
+
# Type-to-filter autocomplete prompt.
|
|
6
|
+
#
|
|
7
|
+
# Combines text input with a filtered option list. Type to filter,
|
|
8
|
+
# use arrow keys to navigate matches, Enter to select.
|
|
9
|
+
#
|
|
10
|
+
# Filtering searches across value, label, and hint fields.
|
|
11
|
+
#
|
|
12
|
+
# @example Basic usage
|
|
13
|
+
# color = Clack.autocomplete(
|
|
14
|
+
# message: "Pick a color",
|
|
15
|
+
# options: %w[red orange yellow green blue indigo violet]
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
# @example With placeholder
|
|
19
|
+
# city = Clack.autocomplete(
|
|
20
|
+
# message: "Select city",
|
|
21
|
+
# options: cities,
|
|
22
|
+
# placeholder: "Type to search...",
|
|
23
|
+
# max_items: 10
|
|
24
|
+
# )
|
|
25
|
+
#
|
|
26
|
+
class Autocomplete < Core::Prompt
|
|
27
|
+
include Core::OptionsHelper
|
|
28
|
+
include Core::TextInputHelper
|
|
29
|
+
|
|
30
|
+
# @param message [String] the prompt message
|
|
31
|
+
# @param options [Array<Hash, String>] list of options to filter
|
|
32
|
+
# @param max_items [Integer] max visible options (default: 5)
|
|
33
|
+
# @param placeholder [String, nil] placeholder text when empty
|
|
34
|
+
# @param opts [Hash] additional options passed to {Core::Prompt}
|
|
35
|
+
def initialize(message:, options:, max_items: 5, placeholder: nil, **opts)
|
|
36
|
+
super(message:, **opts)
|
|
37
|
+
@all_options = normalize_options(options)
|
|
38
|
+
@max_items = max_items
|
|
39
|
+
@placeholder = placeholder
|
|
40
|
+
@value = ""
|
|
41
|
+
@cursor = 0
|
|
42
|
+
@selected_index = 0
|
|
43
|
+
@scroll_offset = 0
|
|
44
|
+
update_filtered
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
protected
|
|
48
|
+
|
|
49
|
+
def handle_key(key)
|
|
50
|
+
return if terminal_state?
|
|
51
|
+
|
|
52
|
+
@state = :active if @state == :error
|
|
53
|
+
action = Core::Settings.action?(key)
|
|
54
|
+
|
|
55
|
+
case action
|
|
56
|
+
when :cancel
|
|
57
|
+
@state = :cancel
|
|
58
|
+
when :enter
|
|
59
|
+
submit_selection
|
|
60
|
+
when :up
|
|
61
|
+
move_selection(-1)
|
|
62
|
+
when :down
|
|
63
|
+
move_selection(1)
|
|
64
|
+
else
|
|
65
|
+
handle_text_input(key)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def handle_text_input(key)
|
|
70
|
+
return unless super
|
|
71
|
+
|
|
72
|
+
@selected_index = 0
|
|
73
|
+
@scroll_offset = 0
|
|
74
|
+
update_filtered
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def submit_selection
|
|
78
|
+
if @filtered.empty?
|
|
79
|
+
@error_message = "No matching option"
|
|
80
|
+
@state = :error
|
|
81
|
+
return
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
@value = @filtered[@selected_index][:value]
|
|
85
|
+
submit
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def build_frame
|
|
89
|
+
lines = []
|
|
90
|
+
lines << "#{bar}\n"
|
|
91
|
+
lines << "#{symbol_for_state} #{@message}\n"
|
|
92
|
+
lines << "#{active_bar} #{input_display}\n"
|
|
93
|
+
|
|
94
|
+
visible_options.each_with_index do |opt, idx|
|
|
95
|
+
actual_idx = @scroll_offset + idx
|
|
96
|
+
lines << "#{bar} #{option_display(opt, actual_idx == @selected_index)}\n"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
lines << "#{bar_end}\n"
|
|
100
|
+
|
|
101
|
+
lines[-1] = "#{Colors.yellow(Symbols::S_BAR_END)} #{Colors.yellow(@error_message)}\n" if @state == :error
|
|
102
|
+
|
|
103
|
+
lines.join
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def build_final_frame
|
|
107
|
+
lines = []
|
|
108
|
+
lines << "#{bar}\n"
|
|
109
|
+
lines << "#{symbol_for_state} #{@message}\n"
|
|
110
|
+
|
|
111
|
+
display_value = @filtered[@selected_index]&.[](:label) || @value
|
|
112
|
+
display = (@state == :cancel) ? Colors.strikethrough(Colors.dim(display_value)) : Colors.dim(display_value)
|
|
113
|
+
lines << "#{bar} #{display}\n"
|
|
114
|
+
|
|
115
|
+
lines.join
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def update_filtered
|
|
121
|
+
query = @value.downcase
|
|
122
|
+
@filtered = @all_options.select do |opt|
|
|
123
|
+
opt[:label].downcase.include?(query) ||
|
|
124
|
+
opt[:value].to_s.downcase.include?(query) ||
|
|
125
|
+
opt[:hint]&.downcase&.include?(query)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def visible_options
|
|
130
|
+
return @filtered if @filtered.length <= @max_items
|
|
131
|
+
|
|
132
|
+
@filtered[@scroll_offset, @max_items]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def move_selection(delta)
|
|
136
|
+
return if @filtered.empty?
|
|
137
|
+
|
|
138
|
+
@selected_index = (@selected_index + delta) % @filtered.length
|
|
139
|
+
update_scroll
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def update_scroll
|
|
143
|
+
return unless @filtered.length > @max_items
|
|
144
|
+
|
|
145
|
+
if @selected_index < @scroll_offset
|
|
146
|
+
@scroll_offset = @selected_index
|
|
147
|
+
elsif @selected_index >= @scroll_offset + @max_items
|
|
148
|
+
@scroll_offset = @selected_index - @max_items + 1
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def option_display(opt, active)
|
|
153
|
+
hint = (opt[:hint] && active) ? Colors.dim(" (#{opt[:hint]})") : ""
|
|
154
|
+
if active
|
|
155
|
+
"#{Colors.green(Symbols::S_RADIO_ACTIVE)} #{opt[:label]}#{hint}"
|
|
156
|
+
else
|
|
157
|
+
"#{Colors.dim(Symbols::S_RADIO_INACTIVE)} #{Colors.dim(opt[:label])}"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|