asktty 2.0.0 → 3.0.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.
@@ -1,106 +1,52 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AskTTY
4
- class ConfirmPrompt
5
- def self.ask(title:, details: nil, value: false, &validator)
6
- new(title: title, details: details, value: value, validator: validator).ask
7
- end
8
-
9
- private_class_method :new
10
-
11
- def initialize(title:, details: nil, value: false, validator: nil)
12
- @title = title.to_s
13
- @details = details&.to_s
4
+ class ConfirmPrompt < Internal::Prompt
5
+ def initialize(title:, details: nil, value: false, &validator)
6
+ super(title: title, details: details, &validator)
14
7
 
15
8
  raise AskTTY::Error, "value must be true or false" unless [true, false].include?(value)
16
9
 
17
10
  @value = value
18
- @validator = validator
19
11
  end
20
12
 
21
- def ask
22
- error_message = nil
23
- validation_checked = false
24
-
25
- Internal::Terminal.open do |session|
26
- loop do
27
- session.render(render(width: session.width, error_message: error_message))
28
-
29
- case session.read_key
30
- when :enter
31
- unless validation_checked
32
- error_message = validation_message
33
- validation_checked = true
34
- end
35
-
36
- next if error_message
37
-
38
- session.render(submitted_render(width: session.width))
39
- return @value
40
- when :left, "h"
41
- previous_value = @value
42
- @value = true
13
+ private
43
14
 
44
- if @value != previous_value
45
- error_message = validation_message
46
- validation_checked = true
47
- end
48
- when :right, "l"
49
- previous_value = @value
50
- @value = false
15
+ def body_lines(width:, height:)
16
+ raise AskTTY::Error, "terminal is too short for prompt" if height < 2
51
17
 
52
- if @value != previous_value
53
- error_message = validation_message
54
- validation_checked = true
55
- end
56
- end
57
- end
58
- end
59
- end
18
+ buttons = [
19
+ Internal::ANSIStyle.apply(" Yes ", @value ? :focused_button : :blurred_button),
20
+ Internal::ANSIStyle.apply(" No ", @value ? :blurred_button : :focused_button)
21
+ ]
60
22
 
61
- private
23
+ row = buttons.join(" ")
24
+ return ["", row] if Internal::Rendering.display_width(row) <= width
62
25
 
63
- def render(width:, error_message: nil)
64
- Internal::Rendering.prompt_frame(
65
- title: @title,
66
- details: @details,
67
- help_items: help_items,
68
- error_message: error_message,
69
- width: width,
70
- gap_before_body: true
71
- ) do |content_width|
72
- button_lines(content_width)
26
+ if buttons.any? { |button| Internal::Rendering.display_width(button) > width }
27
+ raise AskTTY::Error, "terminal is too narrow for confirmation prompt"
73
28
  end
74
- end
75
29
 
76
- def submitted_render(width:)
77
- Internal::Rendering.submitted_frame(@title, @value ? "Yes" : "No", width: width)
30
+ [""] + buttons
78
31
  end
79
32
 
80
- def button_lines(content_width)
81
- yes_button = @value ? Internal::ANSIStyle.focused_button("Yes") : Internal::ANSIStyle.blurred_button("Yes")
82
- no_button = @value ? Internal::ANSIStyle.blurred_button("No") : Internal::ANSIStyle.focused_button("No")
33
+ def handle_key(key)
34
+ previous_value = @value
83
35
 
84
- row = "#{yes_button} #{no_button}"
85
-
86
- return [row] if Internal::Rendering.display_width(row) <= content_width
87
-
88
- button_width = [
89
- Internal::Rendering.display_width(yes_button),
90
- Internal::Rendering.display_width(no_button)
91
- ].max
92
-
93
- raise AskTTY::Error, "terminal is too narrow for confirmation prompt" if button_width > content_width
36
+ case key
37
+ when :left, "h" then @value = true
38
+ when :right, "l" then @value = false
39
+ end
94
40
 
95
- [yes_button, no_button]
41
+ validate if @value != previous_value
96
42
  end
97
43
 
98
44
  def help_items
99
- ["enter (submit)", "left/right (select option)"]
45
+ super + [["left/right", "select option"]]
100
46
  end
101
47
 
102
- def validation_message
103
- Internal::Validation.message_for(@value, @validator)
48
+ def summary
49
+ @value ? "Yes" : "No"
104
50
  end
105
51
  end
106
52
  end
@@ -1,18 +1,91 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AskTTY
4
- class InputPrompt
5
- def self.ask(title:, details: nil, placeholder: nil, value: nil, &validator)
6
- Internal::TextInputPrompt.new(
7
- title: title,
8
- details: details,
9
- placeholder: placeholder,
10
- value: value,
11
- validator: validator,
12
- multiline: false
13
- ).ask
14
- end
15
-
16
- private_class_method :new
4
+ class InputPrompt < Internal::Prompt
5
+ def initialize(title:, details: nil, placeholder: nil, value: nil, &validator)
6
+ super(title: title, details: details, &validator)
7
+
8
+ @placeholder = normalize_text(placeholder) unless placeholder.nil?
9
+ @value = normalize_text(value)
10
+ end
11
+
12
+ private
13
+
14
+ def body_lines(width:, height:)
15
+ placeholder = @placeholder && @value.empty?
16
+ wrap_width = width - 2
17
+
18
+ lines = Internal::Rendering.wrap(placeholder ? @placeholder : @value, wrap_width, words: false)
19
+ lines << "" if !placeholder && Internal::Rendering.display_width(lines.last) >= wrap_width
20
+
21
+ first_line = lines.first
22
+ lines.map! { |line| Internal::ANSIStyle.apply(line, placeholder ? :muted : :text) }
23
+
24
+ if placeholder
25
+ lines[0] = placeholder_with_cursor(first_line)
26
+ else
27
+ lines[-1] << Internal::ANSIStyle.apply(" ", :cursor)
28
+ end
29
+
30
+ lines = lines.each_with_index.map do |line, index|
31
+ prefix = index.zero? ? Internal::ANSIStyle.apply("> ", :prompt) : " "
32
+ prefix + line
33
+ end
34
+
35
+ focus = @value.empty? ? 0 : lines.length - 1
36
+
37
+ Internal::Rendering.window(lines, height: height, focus: focus)
38
+ end
39
+
40
+ def handle_key(key)
41
+ updated_value =
42
+ case key
43
+ when :shift_enter, :ctrl_j
44
+ return unless multiline?
45
+
46
+ "#{@value}\n"
47
+ when :backspace
48
+ @value.grapheme_clusters[0...-1].join
49
+ when Internal::Terminal::Paste
50
+ @value + normalize_text(key.text)
51
+ when String
52
+ return unless key == "\t" || (key.length == 1 && key >= " ")
53
+
54
+ @value + key
55
+ else
56
+ return
57
+ end
58
+
59
+ return if updated_value == @value
60
+
61
+ @value = updated_value
62
+
63
+ validate
64
+ end
65
+
66
+ def help_items
67
+ multiline? ? super + [["ctrl+j", "new line"]] : super
68
+ end
69
+
70
+ def multiline?
71
+ false
72
+ end
73
+
74
+ def normalize_text(text)
75
+ text = text.to_s.gsub(/\r\n?/, "\n")
76
+
77
+ multiline? ? text : text.tr("\n", " ")
78
+ end
79
+
80
+ def placeholder_with_cursor(text)
81
+ first, *rest = text.grapheme_clusters
82
+ return Internal::ANSIStyle.apply(" ", :cursor) unless first
83
+
84
+ Internal::ANSIStyle.apply(first, :placeholder_cursor) + Internal::ANSIStyle.apply(rest.join, :muted)
85
+ end
86
+
87
+ def summary
88
+ multiline? ? @value.tr("\n", " ") : @value
89
+ end
17
90
  end
18
91
  end
@@ -1,123 +1,53 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AskTTY
4
- class MultiSelectPrompt
5
- def self.ask(title:, options:, details: nil, values: nil, &validator)
6
- new(title: title, details: details, options: options, values: values, validator: validator).ask
7
- end
8
-
9
- private_class_method :new
10
-
11
- def initialize(title:, options:, details: nil, values: nil, validator: nil)
12
- @title = title.to_s
13
- @details = details&.to_s
14
- @options = Internal::Options.normalize(options)
15
- @validator = validator
16
-
17
- @values = Array(values).map do |value|
18
- option = @options.find { |candidate| candidate.value == value }
4
+ class MultiSelectPrompt < SelectPrompt
5
+ def initialize(title:, options:, details: nil, values: nil, &validator)
6
+ super(title: title, options: options, details: details, &validator)
19
7
 
20
- raise AskTTY::Error, "values contain unknown option values" unless option
8
+ @selected = Array.new(@options.length, false)
21
9
 
22
- option.value
10
+ Array(values).each do |value|
11
+ index = option_index(value) || raise(AskTTY::Error, "values contain unknown option values")
12
+ @selected[index] = true
23
13
  end
24
14
 
25
- @index = first_selected_index || 0
26
- end
27
-
28
- def ask
29
- error_message = nil
30
- validation_checked = false
31
-
32
- Internal::Terminal.open do |session|
33
- loop do
34
- session.render(render(width: session.width, error_message: error_message))
35
-
36
- case session.read_key
37
- when :enter
38
- unless validation_checked
39
- error_message = validation_message
40
- validation_checked = true
41
- end
42
-
43
- next if error_message
44
-
45
- session.render(submitted_render(width: session.width))
46
- return selected_results
47
- when :up, "k"
48
- move(-1)
49
- when :down, "j"
50
- move(1)
51
- when " "
52
- toggle_current
53
- error_message = validation_message
54
- validation_checked = true
55
- end
56
- end
57
- end
15
+ @index = @selected.index(true) || 0
58
16
  end
59
17
 
60
18
  private
61
19
 
62
- def render(width:, error_message: nil)
63
- Internal::Rendering.prompt_frame(
64
- title: @title, details: @details, help_items: help_items, error_message: error_message, width: width
65
- ) do |content_width|
66
- option_lines(content_width)
67
- end
68
- end
69
-
70
- def submitted_render(width:)
71
- labels = selected_options.map(&:label).join(", ")
72
-
73
- Internal::Rendering.submitted_frame(@title, labels, width: width)
74
- end
75
-
76
- def option_lines(content_width)
77
- @options.each_with_index.flat_map do |option, index|
78
- cursor = index == @index ? Internal::ANSIStyle.prompt("> ") : " "
79
- selected = @values.include?(option.value)
80
- marker = selected ? Internal::ANSIStyle.selected("[•] ") : Internal::ANSIStyle.text("[ ] ")
81
- style = selected ? Internal::ANSIStyle.method(:selected) : Internal::ANSIStyle.method(:text)
82
-
83
- Internal::Rendering.prefixed_lines(
84
- option.label, prefix: cursor + marker, width: content_width, style: style
85
- )
20
+ def handle_key(key)
21
+ if key == " "
22
+ @selected[@index] = !@selected[@index]
23
+ validate
24
+ else
25
+ move_cursor(key)
86
26
  end
87
27
  end
88
28
 
89
- def first_selected_index
90
- @options.index { |option| @values.include?(option.value) }
91
- end
92
-
93
29
  def help_items
94
- ["enter (submit)", "up/down (select item)", "space (toggle item)"]
30
+ super + [["space", "toggle item"]]
95
31
  end
96
32
 
97
- def move(offset)
98
- @index = (@index + offset) % @options.length
33
+ def marker(index)
34
+ selected?(index) ? Internal::ANSIStyle.apply("[•] ", :selected) : Internal::ANSIStyle.apply("[ ] ", :text)
99
35
  end
100
36
 
101
- def selected_options
102
- @options.select { |option| @values.include?(option.value) }
37
+ def selected?(index)
38
+ @selected[index]
103
39
  end
104
40
 
105
- def selected_results
106
- selected_options.map(&:value)
41
+ def selected_options
42
+ @options.select.with_index { |_option, index| selected?(index) }
107
43
  end
108
44
 
109
- def toggle_current
110
- value = @options[@index].value
111
-
112
- if @values.include?(value)
113
- @values.delete(value)
114
- else
115
- @values << value
116
- end
45
+ def summary
46
+ selected_options.map(&:label).join(", ")
117
47
  end
118
48
 
119
- def validation_message
120
- Internal::Validation.message_for(selected_results, @validator)
49
+ def value
50
+ selected_options.map(&:value)
121
51
  end
122
52
  end
123
53
  end
@@ -1,111 +1,100 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AskTTY
4
- class SelectPrompt
4
+ class SelectPrompt < Internal::Prompt
5
+ Option = Data.define(:label, :value)
5
6
  UNSET = Object.new.freeze
6
7
 
7
- private_constant :UNSET
8
+ private_constant :Option, :UNSET
8
9
 
9
- def self.ask(title:, options:, details: nil, value: UNSET, &validator)
10
- new(title: title, details: details, options: options, value: value, validator: validator).ask
11
- end
10
+ def initialize(title:, options:, details: nil, value: UNSET, &validator)
11
+ super(title: title, details: details, &validator)
12
12
 
13
- private_class_method :new
13
+ @index = 0
14
+ @options = normalize_options(options)
14
15
 
15
- def initialize(title:, options:, details: nil, value: UNSET, validator: nil)
16
- @title = title.to_s
17
- @details = details&.to_s
18
- @options = Internal::Options.normalize(options)
19
- @index = index_for(value)
20
- @validator = validator
21
- end
16
+ return if value.equal?(UNSET)
22
17
 
23
- def ask
24
- error_message = nil
25
- validation_checked = false
26
-
27
- Internal::Terminal.open do |session|
28
- loop do
29
- session.render(render(width: session.width, error_message: error_message))
30
-
31
- case session.read_key
32
- when :enter
33
- unless validation_checked
34
- error_message = validation_message
35
- validation_checked = true
36
- end
37
-
38
- next if error_message
39
-
40
- session.render(submitted_render(width: session.width))
41
- return selected_option.value
42
- when :up, "k"
43
- previous_value = selected_option.value
44
- move(-1)
45
-
46
- if selected_option.value != previous_value
47
- error_message = validation_message
48
- validation_checked = true
49
- end
50
- when :down, "j"
51
- previous_value = selected_option.value
52
- move(1)
53
-
54
- if selected_option.value != previous_value
55
- error_message = validation_message
56
- validation_checked = true
57
- end
58
- end
59
- end
60
- end
18
+ @index = option_index(value) || raise(AskTTY::Error, "value is not a valid option value")
61
19
  end
62
20
 
63
21
  private
64
22
 
65
- def render(width:, error_message: nil)
66
- Internal::Rendering.prompt_frame(
67
- title: @title, details: @details, help_items: help_items, error_message: error_message, width: width
68
- ) do |content_width|
69
- option_lines(content_width)
23
+ def body_lines(width:, height:)
24
+ groups = @options.each_with_index.map do |option, index|
25
+ cursor = index == @index ? Internal::ANSIStyle.apply("> ", :prompt) : " "
26
+
27
+ Internal::Rendering.prefixed_lines(
28
+ option.label, prefix: cursor + marker(index), width: width, style: selected?(index) ? :selected : :text
29
+ )
70
30
  end
71
- end
72
31
 
73
- def submitted_render(width:)
74
- Internal::Rendering.submitted_frame(@title, selected_option.label, width: width)
32
+ focus = groups.take(@index).sum(&:length)
33
+
34
+ Internal::Rendering.window(groups.flatten, height: height, focus: focus)
75
35
  end
76
36
 
77
- def option_lines(content_width)
78
- @options.each_with_index.flat_map do |option, index|
79
- prefix = index == @index ? Internal::ANSIStyle.prompt("> ") : " "
80
- style = index == @index ? Internal::ANSIStyle.method(:selected) : Internal::ANSIStyle.method(:text)
37
+ def handle_key(key)
38
+ previous_index = @index
39
+ move_cursor(key)
81
40
 
82
- Internal::Rendering.prefixed_lines(
83
- option.label, prefix: prefix, width: content_width, style: style
84
- )
85
- end
41
+ validate if @index != previous_index
86
42
  end
87
43
 
88
44
  def help_items
89
- ["enter (submit)", "up/down (select item)"]
45
+ super + [["up/down", "select item"]]
90
46
  end
91
47
 
92
- def index_for(value)
93
- return 0 if value.equal?(UNSET)
94
-
95
- @options.index { |option| option.value == value } ||
96
- raise(AskTTY::Error, "value is not a valid option value")
48
+ def marker(_index)
49
+ ""
97
50
  end
98
51
 
99
- def move(offset)
52
+ def move_cursor(key)
53
+ offset =
54
+ case key
55
+ when :up, "k" then -1
56
+ when :down, "j" then 1
57
+ else return
58
+ end
59
+
100
60
  @index = (@index + offset) % @options.length
101
61
  end
102
62
 
103
- def selected_option
104
- @options[@index]
63
+ def normalize_options(options)
64
+ raise AskTTY::Error, "options must be a non-empty array" unless options.is_a?(Array) && !options.empty?
65
+
66
+ values = []
67
+
68
+ options.map do |option|
69
+ raise AskTTY::Error, "options must be hashes with label and value" unless option.is_a?(Hash)
70
+
71
+ unless option.key?(:label) && option.key?(:value) && !option[:label].nil?
72
+ raise AskTTY::Error, "options must include label and value"
73
+ end
74
+
75
+ value = option[:value]
76
+ raise AskTTY::Error, "option values must be unique" if values.include?(value)
77
+
78
+ values << value
79
+
80
+ Option.new(option[:label].to_s, value)
81
+ end
82
+ end
83
+
84
+ def option_index(value)
85
+ @options.index { |option| option.value == value }
86
+ end
87
+
88
+ def selected?(index)
89
+ index == @index
90
+ end
91
+
92
+ def summary
93
+ @options[@index].label
105
94
  end
106
95
 
107
- def validation_message
108
- Internal::Validation.message_for(selected_option.value, @validator)
96
+ def value
97
+ @options[@index].value
109
98
  end
110
99
  end
111
100
  end
@@ -1,18 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AskTTY
4
- class TextPrompt
5
- def self.ask(title:, details: nil, placeholder: nil, value: nil, &validator)
6
- Internal::TextInputPrompt.new(
7
- title: title,
8
- details: details,
9
- placeholder: placeholder,
10
- value: value,
11
- validator: validator,
12
- multiline: true
13
- ).ask
14
- end
4
+ class TextPrompt < InputPrompt
5
+ private
15
6
 
16
- private_class_method :new
7
+ def multiline?
8
+ true
9
+ end
17
10
  end
18
11
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AskTTY
4
- VERSION = "2.0.0"
4
+ VERSION = "3.0.0"
5
5
  end
data/lib/asktty.rb CHANGED
@@ -7,11 +7,10 @@ module AskTTY
7
7
  end
8
8
 
9
9
  require_relative "asktty/internal/ansi_style"
10
- require_relative "asktty/internal/options"
10
+ require_relative "asktty/internal/prompt"
11
11
  require_relative "asktty/internal/rendering"
12
12
  require_relative "asktty/internal/terminal"
13
- require_relative "asktty/internal/validation"
14
- require_relative "asktty/internal/text_input_prompt"
13
+
15
14
  require_relative "asktty/prompts/input_prompt"
16
15
  require_relative "asktty/prompts/text_prompt"
17
16
  require_relative "asktty/prompts/select_prompt"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: asktty
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vlad Suchy
@@ -35,11 +35,9 @@ files:
35
35
  - README.md
36
36
  - lib/asktty.rb
37
37
  - lib/asktty/internal/ansi_style.rb
38
- - lib/asktty/internal/options.rb
38
+ - lib/asktty/internal/prompt.rb
39
39
  - lib/asktty/internal/rendering.rb
40
40
  - lib/asktty/internal/terminal.rb
41
- - lib/asktty/internal/text_input_prompt.rb
42
- - lib/asktty/internal/validation.rb
43
41
  - lib/asktty/prompts/confirm_prompt.rb
44
42
  - lib/asktty/prompts/input_prompt.rb
45
43
  - lib/asktty/prompts/multi_select_prompt.rb
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module AskTTY
4
- module Internal
5
- module Options
6
- Option = Data.define(:label, :value)
7
-
8
- private_constant :Option
9
-
10
- module_function
11
-
12
- def normalize(options)
13
- raise AskTTY::Error, "options must be a non-empty array" unless options.is_a?(Array) && !options.empty?
14
-
15
- values = []
16
-
17
- options.map do |option|
18
- raise AskTTY::Error, "options must be hashes with label and value" unless option.is_a?(Hash)
19
-
20
- unless option.key?(:label) && option.key?(:value) && !option[:label].nil?
21
- raise AskTTY::Error, "options must include label and value"
22
- end
23
-
24
- value = option[:value]
25
- raise AskTTY::Error, "option values must be unique" if values.include?(value)
26
-
27
- values << value
28
- Option.new(option[:label].to_s, value)
29
- end
30
- end
31
- end
32
- end
33
- end