curses_menu 0.0.1 → 0.0.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,150 +1,151 @@
1
- class CursesMenu
2
-
3
- # Definition of a row that stores for each cell the string and color information to be displayed
4
- class CursesRow
5
-
6
- # Constructor
7
- #
8
- # Parameters::
9
- # * *cells* (Hash< Symbol, Hash<Symbol,Object> >): For each cell id (ordered), the cell info:
10
- # * *text* (String): Text associated to this cell
11
- # * *color_pair* (Integer): Associated color pair [optional]
12
- # * *begin_with* (String): String to prepend to the text [default: '']
13
- # * *end_with* (String): String to append to the text [default: '']
14
- # * *fixed_size* (Integer): Number of characters this cell will take, or nil if no limit. [default: nil]
15
- # * *justify* (Symbol): Text justification (only used when fixed_size is not nil). Values can be: [default: :left]
16
- # * *left*: Left justified
17
- # * *right*: Right justified
18
- # * *pad* (String): Text to be used to pad the cell content (only used when fixed_size is not nil) [default: ' ']
19
- # * *separator* (String): Separator used between cells [default: ' ']
20
- def initialize(cells, separator: ' ')
21
- @cells = cells
22
- @separator = separator
23
- end
24
-
25
- # Change the cells order
26
- #
27
- # Parameters::
28
- # * *cells* (Array<Symbol>): The ordered list of cells to filter
29
- # * *unknown_cells* (String or Hash<Symbol,Object>): Content to put in unknown cells (as a String or properties like in #initialize), or nil to not add them. [default: nil]
30
- def cells_order(cells, unknown_cells: nil)
31
- new_cells = {}
32
- cells.each do |cell_id|
33
- if @cells.key?(cell_id)
34
- new_cells[cell_id] = @cells[cell_id]
35
- elsif !unknown_cells.nil?
36
- new_cells[cell_id] = unknown_cells.is_a?(String) ? { text: unknown_cells } : unknown_cells
37
- end
38
- end
39
- @cells = new_cells
40
- end
41
-
42
- # Change properties of a set of cells
43
- #
44
- # Parameters::
45
- # * *cells* (Hash<Symbol, Hash<Symbol,Object> >): The cells properties to change, per cell id. Possible properties are the ones given in the #initialize method.
46
- def change_cells(cells)
47
- cells.each do |cell_id, cell_info|
48
- raise "Unknown cell #{cell_id}" unless @cells.key?(cell_id)
49
- @cells[cell_id].merge!(cell_info)
50
- @cells[cell_id].delete(:cache_rendered_text)
51
- end
52
- end
53
-
54
- # Get the size of the total string of such row.
55
- #
56
- # Parameters::
57
- # * *cells* (Array<Symbol>): The list of cells to consider for the size [default: @cells.keys]
58
- # Result::
59
- # * Integer: Row size
60
- def size(cells: @cells.keys)
61
- result = @separator.size * (cells.size - 1)
62
- cells.each do |cell_id|
63
- result += cell_text(cell_id).size
64
- end
65
- result
66
- end
67
-
68
- # Print this row into a window
69
- #
70
- # Parameters::
71
- # * *window* (Window): Curses window to print on
72
- # * *from* (Integer): From index to be displayed [default: 0]
73
- # * *to* (Integer): To index to be displayed [default: total size]
74
- # * *default_color_pair* (Integer): Default color pair to use if no color information is provided [default: COLORS_LINE]
75
- # * *force_color_pair* (Integer): Force color pair to use, or nil to not force [default: nil]
76
- # * *pad* (String or nil): Pad the line to the row extent with the given string, or nil for no padding. [default: nil]
77
- # * *add_nl* (Boolean): If true, then add a new line at the end [default: true]
78
- # * *single_line* (Boolean): If true, then make sure the print does not exceed the line [default: false]
79
- def print_on(window, from: 0, to: nil, default_color_pair: COLORS_LINE, force_color_pair: nil, pad: nil, add_nl: true, single_line: false)
80
- text_size = size
81
- from = text_size if from > text_size
82
- to = text_size - 1 if to.nil?
83
- to = window.maxx - window.curx + from - 2 if single_line && window.curx + to - from >= window.maxx - 1
84
- current_idx = 0
85
- @cells.each.with_index do |(cell_id, cell_info), cell_idx|
86
- text = cell_text(cell_id)
87
- full_substring_size = text.size + @separator.size
88
- if from < current_idx + full_substring_size
89
- # We have something to display from this substring
90
- window.color_set(
91
- if force_color_pair.nil?
92
- cell_info[:color_pair] ? cell_info[:color_pair] : default_color_pair
93
- else
94
- force_color_pair
95
- end
96
- )
97
- window << "#{text}#{cell_idx == @cells.size - 1 ? '' : @separator}"[(from < current_idx ? 0 : from - current_idx)..to - current_idx]
98
- end
99
- current_idx += full_substring_size
100
- break if current_idx > to
101
- end
102
- window.color_set(force_color_pair.nil? ? default_color_pair : force_color_pair)
103
- if pad && window.curx < window.maxx
104
- nbr_chars = window.maxx - window.curx - 1
105
- window << (pad * nbr_chars)[0..nbr_chars - 1]
106
- end
107
- window << "\n" if add_nl
108
- end
109
-
110
- private
111
-
112
- # Get a cell's text.
113
- # Cache it to not compute it several times.
114
- #
115
- # Parameters::
116
- # * *cell_id* (Symbol): Cell id to get text for
117
- # Result::
118
- # * String: The cell's text
119
- def cell_text(cell_id, cell_decorator: nil)
120
- unless @cells[cell_id].key?(:cache_rendered_text)
121
- begin_str = "#{@cells[cell_id][:begin_with] || ''}#{@cells[cell_id][:text]}"
122
- end_str = @cells[cell_id][:end_with] || ''
123
- @cells[cell_id][:cache_rendered_text] =
124
- if @cells[cell_id][:fixed_size]
125
- text = "#{begin_str[0..@cells[cell_id][:fixed_size] - end_str.size - 1]}#{end_str}"
126
- remaining_size = @cells[cell_id][:fixed_size] - text.size
127
- if remaining_size > 0
128
- padding = ((@cells[cell_id][:pad] || ' ') * remaining_size)[0..remaining_size - 1]
129
- justify = @cells[cell_id][:justify] || :left
130
- case justify
131
- when :left
132
- "#{text}#{padding}"
133
- when :right
134
- "#{padding}#{text}"
135
- else
136
- raise "Unknown justify decorator: #{justify}"
137
- end
138
- else
139
- text[0..@cells[cell_id][:fixed_size] - 1]
140
- end
141
- else
142
- "#{begin_str}#{end_str}"
143
- end
144
- end
145
- @cells[cell_id][:cache_rendered_text]
146
- end
147
-
148
- end
149
-
150
- end
1
+ class CursesMenu
2
+
3
+ # Definition of a row that stores for each cell the string and color information to be displayed
4
+ class CursesRow
5
+
6
+ # Constructor
7
+ #
8
+ # Parameters::
9
+ # * *cells* (Hash< Symbol, Hash<Symbol,Object> >): For each cell id (ordered), the cell info:
10
+ # * *text* (String): Text associated to this cell
11
+ # * *color_pair* (Integer): Associated color pair [optional]
12
+ # * *begin_with* (String): String to prepend to the text [default: '']
13
+ # * *end_with* (String): String to append to the text [default: '']
14
+ # * *fixed_size* (Integer): Number of characters this cell will take, or nil if no limit. [default: nil]
15
+ # * *justify* (Symbol): Text justification (only used when fixed_size is not nil). Values can be: [default: :left]
16
+ # * *left*: Left justified
17
+ # * *right*: Right justified
18
+ # * *pad* (String): Text to be used to pad the cell content (only used when fixed_size is not nil) [default: ' ']
19
+ # * *separator* (String): Separator used between cells [default: ' ']
20
+ def initialize(cells, separator: ' ')
21
+ @cells = cells
22
+ @separator = separator
23
+ end
24
+
25
+ # Change the cells order
26
+ #
27
+ # Parameters::
28
+ # * *cells* (Array<Symbol>): The ordered list of cells to filter
29
+ # * *unknown_cells* (String or Hash<Symbol,Object>): Content to put in unknown cells (as a String or properties like in #initialize), or nil to not add them. [default: nil]
30
+ def cells_order(cells, unknown_cells: nil)
31
+ new_cells = {}
32
+ cells.each do |cell_id|
33
+ if @cells.key?(cell_id)
34
+ new_cells[cell_id] = @cells[cell_id]
35
+ elsif !unknown_cells.nil?
36
+ new_cells[cell_id] = unknown_cells.is_a?(String) ? { text: unknown_cells } : unknown_cells
37
+ end
38
+ end
39
+ @cells = new_cells
40
+ end
41
+
42
+ # Change properties of a set of cells
43
+ #
44
+ # Parameters::
45
+ # * *cells* (Hash<Symbol, Hash<Symbol,Object> >): The cells properties to change, per cell id. Possible properties are the ones given in the #initialize method.
46
+ def change_cells(cells)
47
+ cells.each do |cell_id, cell_info|
48
+ raise "Unknown cell #{cell_id}" unless @cells.key?(cell_id)
49
+
50
+ @cells[cell_id].merge!(cell_info)
51
+ @cells[cell_id].delete(:cache_rendered_text)
52
+ end
53
+ end
54
+
55
+ # Get the size of the total string of such row.
56
+ #
57
+ # Parameters::
58
+ # * *cells* (Array<Symbol>): The list of cells to consider for the size [default: @cells.keys]
59
+ # Result::
60
+ # * Integer: Row size
61
+ def size(cells: @cells.keys)
62
+ result = @separator.size * (cells.size - 1)
63
+ cells.each do |cell_id|
64
+ result += cell_text(cell_id).size
65
+ end
66
+ result
67
+ end
68
+
69
+ # Print this row into a window
70
+ #
71
+ # Parameters::
72
+ # * *window* (Window): Curses window to print on
73
+ # * *from* (Integer): From index to be displayed [default: 0]
74
+ # * *to* (Integer): To index to be displayed [default: total size]
75
+ # * *default_color_pair* (Integer): Default color pair to use if no color information is provided [default: COLORS_LINE]
76
+ # * *force_color_pair* (Integer): Force color pair to use, or nil to not force [default: nil]
77
+ # * *pad* (String or nil): Pad the line to the row extent with the given string, or nil for no padding. [default: nil]
78
+ # * *add_nl* (Boolean): If true, then add a new line at the end [default: true]
79
+ # * *single_line* (Boolean): If true, then make sure the print does not exceed the line [default: false]
80
+ def print_on(window, from: 0, to: nil, default_color_pair: COLORS_LINE, force_color_pair: nil, pad: nil, add_nl: true, single_line: false)
81
+ text_size = size
82
+ from = text_size if from > text_size
83
+ to = text_size - 1 if to.nil?
84
+ to = window.maxx - window.curx + from - 2 if single_line && window.curx + to - from >= window.maxx - 1
85
+ current_idx = 0
86
+ @cells.each.with_index do |(cell_id, cell_info), cell_idx|
87
+ text = cell_text(cell_id)
88
+ full_substring_size = text.size + @separator.size
89
+ if from < current_idx + full_substring_size
90
+ # We have something to display from this substring
91
+ window.color_set(
92
+ if force_color_pair.nil?
93
+ cell_info[:color_pair] || default_color_pair
94
+ else
95
+ force_color_pair
96
+ end
97
+ )
98
+ window << "#{text}#{cell_idx == @cells.size - 1 ? '' : @separator}"[(from < current_idx ? 0 : from - current_idx)..to - current_idx]
99
+ end
100
+ current_idx += full_substring_size
101
+ break if current_idx > to
102
+ end
103
+ window.color_set(force_color_pair.nil? ? default_color_pair : force_color_pair)
104
+ if pad && window.curx < window.maxx
105
+ nbr_chars = window.maxx - window.curx - 1
106
+ window << (pad * nbr_chars)[0..nbr_chars - 1]
107
+ end
108
+ window << "\n" if add_nl
109
+ end
110
+
111
+ private
112
+
113
+ # Get a cell's text.
114
+ # Cache it to not compute it several times.
115
+ #
116
+ # Parameters::
117
+ # * *cell_id* (Symbol): Cell id to get text for
118
+ # Result::
119
+ # * String: The cell's text
120
+ def cell_text(cell_id)
121
+ unless @cells[cell_id].key?(:cache_rendered_text)
122
+ begin_str = "#{@cells[cell_id][:begin_with] || ''}#{@cells[cell_id][:text]}"
123
+ end_str = @cells[cell_id][:end_with] || ''
124
+ @cells[cell_id][:cache_rendered_text] =
125
+ if @cells[cell_id][:fixed_size]
126
+ text = "#{begin_str[0..@cells[cell_id][:fixed_size] - end_str.size - 1]}#{end_str}"
127
+ remaining_size = @cells[cell_id][:fixed_size] - text.size
128
+ if remaining_size.positive?
129
+ padding = ((@cells[cell_id][:pad] || ' ') * remaining_size)[0..remaining_size - 1]
130
+ justify = @cells[cell_id][:justify] || :left
131
+ case justify
132
+ when :left
133
+ "#{text}#{padding}"
134
+ when :right
135
+ "#{padding}#{text}"
136
+ else
137
+ raise "Unknown justify decorator: #{justify}"
138
+ end
139
+ else
140
+ text[0..@cells[cell_id][:fixed_size] - 1]
141
+ end
142
+ else
143
+ "#{begin_str}#{end_str}"
144
+ end
145
+ end
146
+ @cells[cell_id][:cache_rendered_text]
147
+ end
148
+
149
+ end
150
+
151
+ end
@@ -0,0 +1,5 @@
1
+ class CursesMenu
2
+
3
+ VERSION = '0.0.5'
4
+
5
+ end
@@ -0,0 +1,131 @@
1
+ require 'curses_menu'
2
+
3
+ module CursesMenuTest
4
+
5
+ # Monkey-patch the curses_menu_finalize method so that it captures the menu screen before finalizing
6
+ module CursesMenuPatch
7
+
8
+ # Last screenshot taken
9
+ # Array<String>: List of lines
10
+ attr_reader :screenshot
11
+
12
+ # Finalize the curses menu window
13
+ def curses_menu_finalize
14
+ @screenshot = capture_screenshot
15
+ super
16
+ end
17
+
18
+ private
19
+
20
+ # Get a screenshot of the menu
21
+ #
22
+ # Result::
23
+ # * Array<String>: List of lines
24
+ def capture_screenshot
25
+ # Curses is initialized
26
+ window = Curses.stdscr
27
+ old_x = window.curx
28
+ old_y = window.cury
29
+ chars = []
30
+ window.maxy.times do |idx_y|
31
+ window.maxx.times do |idx_x|
32
+ window.setpos idx_y, idx_x
33
+ chars << window.inch
34
+ end
35
+ end
36
+ window.setpos old_y, old_x
37
+ # Build the map of colors per color pair acutally registered
38
+ colors_left_shift = Curses::A_COLOR.to_s(2).match(/^1+(0+)$/)[1].size
39
+ color_pairs = CursesMenu.constants.select { |const| const.to_s.start_with?('COLORS_') }.map do |const|
40
+ color_pair = CursesMenu.const_get(const)
41
+ [
42
+ # On Windows using Curses.color_pair can result in bugs [BUG] Unnormalized Fixnum value when using/displaying the value.
43
+ # So for now we depend on the internal algorithm used by color_pair (which is a left shift of the 0 bits of A_COLOR mask)
44
+ # TODO: Uncomment the following when curses will be fixed on Windows
45
+ # Curses.color_pair(color_pair),
46
+ color_pair << colors_left_shift,
47
+ const
48
+ ]
49
+ end.to_h
50
+ chars.
51
+ map do |chr|
52
+ {
53
+ char: begin
54
+ (chr & Curses::A_CHARTEXT).chr(Encoding::UTF_8)
55
+ rescue RangeError
56
+ # On Windows curses returns wrong encoded characters.
57
+ # In this case we just force an ASCII version of it.
58
+ (chr & 255).chr(Encoding::UTF_8)
59
+ end,
60
+ color: color_pairs[chr & Curses::A_COLOR] || chr & Curses::A_COLOR,
61
+ attributes: chr & Curses::A_ATTRIBUTES
62
+ }
63
+ end.
64
+ each_slice(window.maxx).
65
+ to_a
66
+ end
67
+
68
+ end
69
+
70
+ # Helpers for the tests
71
+ module Helpers
72
+
73
+ # Test a given menu, and prepare a screenshot to be analyzed
74
+ #
75
+ # Parameters::
76
+ # * *title* (String): The title [default: 'Menu title']
77
+ # * *keys* (Array<Object> or nil): Keys to automatically press [default: []]
78
+ # * *auto_exit* (Boolean): Do we automatically add the escape key to the key presses? [default: true]
79
+ # * Proc: The code called with the test menu to be populated
80
+ # * Parameters::
81
+ # * *menu* (CursesMenu): Curses menu to populate
82
+ # * *key_presses* (Array<Object>): Keys to possibly give to sub-menus
83
+ def test_menu(title: 'Menu title', keys: [], auto_exit: true)
84
+ # TODO: Find a way to not depend on the current terminal screen, and run the tests silently.
85
+ key_presses = auto_exit ? keys + [CursesMenu::KEY_ESCAPE] : keys
86
+ menu = CursesMenu.new(title, key_presses: key_presses) do |m|
87
+ yield m, key_presses
88
+ end
89
+ @screenshot = menu.screenshot
90
+ end
91
+
92
+ # Assert that a line of the screenshot starts with a given content
93
+ #
94
+ # Parameters::
95
+ # * *line_idx* (Integer): The line index of the screenshot
96
+ # * *expectation* (String or Regexp): The expected line
97
+ def assert_line(line_idx, expectation)
98
+ line = @screenshot[line_idx][0..(expectation.is_a?(Regexp) ? -1 : expectation.size)].map { |char_info| char_info[:char] }.join
99
+ if expectation.is_a?(Regexp)
100
+ expect(line).to match(expectation), "Screenshot line #{line_idx} differs:\n \"#{line}\" should be\n \"#{expectation} \""
101
+ else
102
+ # Add an ending space to make sure the line does not continue after what we test
103
+ expect(line).to eq("#{expectation} "), "Screenshot line #{line_idx} differs:\n \"#{line}\" should be\n \"#{expectation} \""
104
+ end
105
+ end
106
+
107
+ # Assert that a line of the screenshot starts with a given content, using colors information
108
+ #
109
+ # Parameters::
110
+ # * *line_idx* (Integer): The line index of the screenshot
111
+ # * *expectation* (String): The expected line
112
+ # * *color* (Symbol): The expected color pair name
113
+ def assert_colored_line(line_idx, expectation, color)
114
+ colored_line = @screenshot[line_idx][0..expectation.size - 1].map do |char_info|
115
+ [char_info[:char], char_info[:color]]
116
+ end
117
+ expected_colored_line = expectation.each_char.map do |chr|
118
+ [chr, color]
119
+ end
120
+ expect(colored_line).to eq(expected_colored_line), "Screenshot line #{line_idx} differs:\n \"#{colored_line}\" should be\n \"#{expected_colored_line}\""
121
+ end
122
+
123
+ end
124
+
125
+ end
126
+
127
+ class CursesMenu
128
+
129
+ prepend CursesMenuTest::CursesMenuPatch
130
+
131
+ end
@@ -0,0 +1,221 @@
1
+ describe CursesMenu do
2
+
3
+ it 'actions the default selection when pressed enter' do
4
+ actioned = false
5
+ test_menu(keys: [CursesMenu::KEY_ENTER]) do |menu|
6
+ menu.item 'Menu item' do
7
+ actioned = true
8
+ end
9
+ end
10
+ expect(actioned).to eq(true)
11
+ end
12
+
13
+ it 'actions the default selection when pressed enter on the correct item' do
14
+ action = nil
15
+ test_menu(keys: [Curses::KEY_DOWN, Curses::KEY_DOWN, CursesMenu::KEY_ENTER]) do |menu|
16
+ menu.item 'Menu item 1' do
17
+ action = 1
18
+ end
19
+ menu.item 'Menu item 2' do
20
+ action = 2
21
+ end
22
+ menu.item 'Menu item 3' do
23
+ action = 3
24
+ end
25
+ menu.item 'Menu item 4' do
26
+ action = 4
27
+ end
28
+ end
29
+ expect(action).to eq(3)
30
+ end
31
+
32
+ it 'actions other actions' do
33
+ action = nil
34
+ test_menu(keys: ['a']) do |menu|
35
+ menu.item 'Menu item', actions: {
36
+ 'a' => {
37
+ name: 'Action A',
38
+ execute: proc { action = 'a' }
39
+ },
40
+ 'b' => {
41
+ name: 'Action B',
42
+ execute: proc { action = 'b' }
43
+ }
44
+ }
45
+ end
46
+ expect(action).to eq('a')
47
+ end
48
+
49
+ it 'actions several actions' do
50
+ actions = []
51
+ test_menu(keys: %w[a b a]) do |menu|
52
+ menu.item 'Menu item', actions: {
53
+ 'a' => {
54
+ name: 'Action A',
55
+ execute: proc { actions << 'a' }
56
+ },
57
+ 'b' => {
58
+ name: 'Action B',
59
+ execute: proc { actions << 'b' }
60
+ }
61
+ }
62
+ end
63
+ expect(actions).to eq(%w[a b a])
64
+ end
65
+
66
+ it 'actions several actions including the default one' do
67
+ actions = []
68
+ test_menu(keys: ['a', 'b', CursesMenu::KEY_ENTER, 'a']) do |menu|
69
+ menu.item(
70
+ 'Menu item',
71
+ actions: {
72
+ 'a' => {
73
+ name: 'Action A',
74
+ execute: proc { actions << 'a' }
75
+ },
76
+ 'b' => {
77
+ name: 'Action B',
78
+ execute: proc { actions << 'b' }
79
+ }
80
+ }
81
+ ) do
82
+ actions << 'ENTER'
83
+ end
84
+ end
85
+ expect(actions).to eq(%w[a b ENTER a])
86
+ end
87
+
88
+ it 'actions nothing if action does not exist' do
89
+ actions = []
90
+ test_menu(keys: %w[a b c a]) do |menu|
91
+ menu.item(
92
+ 'Menu item',
93
+ actions: {
94
+ 'a' => {
95
+ name: 'Action A',
96
+ execute: proc { actions << 'a' }
97
+ },
98
+ 'b' => {
99
+ name: 'Action B',
100
+ execute: proc { actions << 'b' }
101
+ }
102
+ }
103
+ ) do
104
+ actions << 'ENTER'
105
+ end
106
+ end
107
+ expect(actions).to eq(%w[a b a])
108
+ end
109
+
110
+ it 'exits when action returns :menu_exit' do
111
+ quit = false
112
+ test_menu(keys: [CursesMenu::KEY_ENTER], auto_exit: false) do |menu|
113
+ menu.item 'Menu item quit' do
114
+ quit = true
115
+ :menu_exit
116
+ end
117
+ end
118
+ expect(quit).to eq(true)
119
+ end
120
+
121
+ it 'navigates in sub-menus' do
122
+ actions = []
123
+ test_menu(
124
+ keys: [
125
+ # Enter sub-menu 1
126
+ CursesMenu::KEY_ENTER,
127
+ # Action sub-menu second item
128
+ Curses::KEY_DOWN,
129
+ CursesMenu::KEY_ENTER,
130
+ # Back to first menu
131
+ CursesMenu::KEY_ESCAPE,
132
+ # Enter sub-menu 2
133
+ Curses::KEY_DOWN,
134
+ CursesMenu::KEY_ENTER,
135
+ # Action sub-menu item
136
+ CursesMenu::KEY_ENTER,
137
+ # Exit sub-menu
138
+ Curses::KEY_DOWN,
139
+ CursesMenu::KEY_ENTER
140
+ ]
141
+ ) do |menu, key_presses|
142
+ menu.item 'Sub-menu 1' do
143
+ described_class.new('Sub-menu 1 title', key_presses: key_presses) do |sub_menu|
144
+ sub_menu.item 'Sub-menu item 1'
145
+ sub_menu.item 'Sub-menu item 2' do
146
+ actions << 'a'
147
+ end
148
+ end
149
+ end
150
+ menu.item 'Sub-menu 2' do
151
+ described_class.new('Sub-menu 2 title', key_presses: key_presses) do |sub_menu|
152
+ sub_menu.item 'Sub-menu item 1' do
153
+ actions << 'b'
154
+ end
155
+ sub_menu.item 'Sub-menu item 2' do
156
+ :menu_exit
157
+ end
158
+ end
159
+ end
160
+ end
161
+ expect(actions).to eq(%w[a b])
162
+ end
163
+
164
+ it 'exits only the sub-menu when action returns :menu_exit in a sub-menu' do
165
+ actions = []
166
+ test_menu(keys: [CursesMenu::KEY_ENTER, CursesMenu::KEY_ENTER, Curses::KEY_DOWN, CursesMenu::KEY_ENTER]) do |menu, key_presses|
167
+ menu.item 'Sub-menu' do
168
+ described_class.new('Sub-menu title', key_presses: key_presses) do |sub_menu|
169
+ sub_menu.item 'Sub-menu item quit' do
170
+ actions << 'a'
171
+ :menu_exit
172
+ end
173
+ end
174
+ end
175
+ menu.item 'Menu item 2' do
176
+ actions << 'b'
177
+ end
178
+ end
179
+ expect(actions).to eq(%w[a b])
180
+ end
181
+
182
+ it 'exits only the sub-menu when Escape key is used' do
183
+ actions = []
184
+ test_menu(keys: [CursesMenu::KEY_ENTER, CursesMenu::KEY_ESCAPE, Curses::KEY_DOWN, CursesMenu::KEY_ENTER]) do |menu, key_presses|
185
+ menu.item 'Sub-menu' do
186
+ described_class.new('Sub-menu title', key_presses: key_presses) do |sub_menu|
187
+ sub_menu.item 'Sub-menu item quit' do
188
+ actions << 'a'
189
+ :menu_exit
190
+ end
191
+ end
192
+ end
193
+ menu.item 'Menu item 2' do
194
+ actions << 'b'
195
+ end
196
+ end
197
+ expect(actions).to eq(%w[b])
198
+ end
199
+
200
+ it 'does not refresh menu items normally' do
201
+ idx = 0
202
+ test_menu(keys: [CursesMenu::KEY_ENTER, CursesMenu::KEY_ENTER]) do |menu|
203
+ menu.item "Menu item #{idx}" do
204
+ idx += 1
205
+ end
206
+ end
207
+ assert_line 3, 'Menu item 0'
208
+ end
209
+
210
+ it 'refreshes menu items when action returns :menu_refresh' do
211
+ idx = 0
212
+ test_menu(keys: [CursesMenu::KEY_ENTER, CursesMenu::KEY_ENTER]) do |menu|
213
+ menu.item "Menu item #{idx}" do
214
+ idx += 1
215
+ :menu_refresh
216
+ end
217
+ end
218
+ assert_line 3, 'Menu item 2'
219
+ end
220
+
221
+ end