-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathcurses_menu.rb
268 lines (255 loc) · 10.6 KB
/
curses_menu.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
require 'curses'
require 'curses_menu/curses_row'
# Provide a menu using curses with keys navigation and selection
class CursesMenu
# Define some color pairs names.
# The integer value is meaningless in itself but they all have to be different.
COLORS_TITLE = 1
COLORS_LINE = 2
COLORS_MENU_ITEM = 3
COLORS_MENU_ITEM_SELECTED = 4
COLORS_INPUT = 5
COLORS_GREEN = 6
COLORS_RED = 7
COLORS_YELLOW = 8
COLORS_BLUE = 9
COLORS_WHITE = 10
# curses keys that are not defined by Curses, but that are returned by getch
KEY_ENTER = 10
KEY_ESCAPE = 27
# Constructor.
# Display a list of choices, ask for user input and execute the choice made.
# Repeat the operation unless one of the code returns the :menu_exit symbol.
#
# Parameters::
# * *title* (String): Title of those choices
# * *key_presses* (Array<Object>): List of key presses to automatically apply [default: []]
# Can be characters or ascii values, as returned by curses' getch.
# The list is modified in place along with its consumption, so that it can be reused in sub-menus if needed.
# * *&menu_items_def* (Proc): Code to be called to get the list of choices. This code can call the following methods to design the menu:
# * Parameters::
# * *menu* (CursesMenu): The CursesMenu instance
def initialize(title, key_presses: [], &menu_items_def)
@current_menu_items = nil
@curses_initialized = false
current_items = gather_menu_items(&menu_items_def)
selected_idx = 0
raise "Menu #{title} has no items to select" if selected_idx.nil?
window = curses_menu_initialize
begin
max_displayed_items = window.maxy - 5
display_first_idx = 0
display_first_char_idx = 0
loop do
# TODO: Don't redraw fixed items for performance
# Display the title
window.setpos(0, 0)
print(window, '', default_color_pair: COLORS_TITLE, pad: '=')
print(window, "= #{title}", default_color_pair: COLORS_TITLE, pad: ' ', single_line: true)
print(window, '', default_color_pair: COLORS_TITLE, pad: '-')
# Display the menu
current_items[display_first_idx..display_first_idx + max_displayed_items - 1].each.with_index do |item_info, idx|
selected = display_first_idx + idx == selected_idx
# Keep a cache of titles as they can be loaded in a lazy way for performance
item_info[:title_cached] = item_info[:title].is_a?(Proc) ? item_info[:title].call : item_info[:title] unless item_info.key?(:title_cached)
print(
window,
item_info[:title_cached],
from: display_first_char_idx,
default_color_pair: item_info.key?(:actions) ? COLORS_MENU_ITEM : COLORS_LINE,
force_color_pair: selected ? COLORS_MENU_ITEM_SELECTED : nil,
pad: selected ? ' ' : nil,
single_line: true
)
end
# Display the footer
window.setpos(window.maxy - 2, 0)
print(window, '', default_color_pair: COLORS_TITLE, pad: '=')
display_actions = {
'Arrows/Home/End' => 'Navigate',
'Esc' => 'Exit'
}
# Keep a cache of actions as they can be loaded in a lazy way for performance
current_items[selected_idx][:actions_cached] = current_items[selected_idx][:actions].is_a?(Proc) ? current_items[selected_idx][:actions].call : current_items[selected_idx][:actions] unless current_items[selected_idx].key?(:actions_cached)
if current_items[selected_idx][:actions_cached]
display_actions.merge!(
current_items[selected_idx][:actions_cached].to_h do |action_shortcut, action_info|
[
case action_shortcut
when KEY_ENTER
'Enter'
else
action_shortcut
end,
action_info[:name]
]
end
)
end
print(
window,
"= #{display_actions.sort.map { |(shortcut, name)| "#{shortcut}: #{name}" }.join(' | ')}",
from: display_first_char_idx,
default_color_pair: COLORS_TITLE,
pad: ' ',
add_nl: false,
single_line: true
)
window.refresh
user_choice = nil
loop do
user_choice = key_presses.empty? ? window.getch : key_presses.shift
break unless user_choice.nil?
sleep 0.01
end
case user_choice
when Curses::KEY_RIGHT
display_first_char_idx += 1
when Curses::KEY_LEFT
display_first_char_idx -= 1
when Curses::KEY_UP
selected_idx -= 1
when Curses::KEY_PPAGE
selected_idx -= max_displayed_items - 1
when Curses::KEY_DOWN
selected_idx += 1
when Curses::KEY_NPAGE
selected_idx += max_displayed_items - 1
when Curses::KEY_HOME
selected_idx = 0
when Curses::KEY_END
selected_idx = current_items.size - 1
when KEY_ESCAPE
break
else
# Check actions
# Keep a cache of actions as they can be loaded in a lazy way for performance
current_items[selected_idx][:actions_cached] = current_items[selected_idx][:actions].is_a?(Proc) ? current_items[selected_idx][:actions].call : current_items[selected_idx][:actions] unless current_items[selected_idx].key?(:actions_cached)
if current_items[selected_idx][:actions_cached]&.key?(user_choice)
curses_menu_finalize
result = current_items[selected_idx][:actions_cached][user_choice][:execute].call
if result.is_a?(Symbol)
case result
when :menu_exit
break
when :menu_refresh
current_items = gather_menu_items(&menu_items_def)
end
end
window = curses_menu_initialize
window.clear
end
end
# Stay in bounds
display_first_char_idx = 0 if display_first_char_idx.negative?
selected_idx = current_items.size - 1 if selected_idx >= current_items.size
selected_idx = 0 if selected_idx.negative?
if selected_idx < display_first_idx
display_first_idx = selected_idx
elsif selected_idx >= display_first_idx + max_displayed_items
display_first_idx = selected_idx - max_displayed_items + 1
end
end
ensure
curses_menu_finalize
end
end
# Register a new menu item.
# This method is meant to be called from a choose_from call.
#
# Parameters::
# * *title* (String, CursesRow or Proc): Text to be displayed for this item, or Proc returning this text when needed (lazy loading)
# * *actions* (Hash<Object, Hash<Symbol,Object> > or Proc): Associated actions to this item, per shortcut, or Proc returning those actions when needed (lazy loading) [default: {}]
# * *name* (String): Name of this action (displayed at the bottom of the menu)
# * *execute* (Proc): Code called when this action is selected
# In case of lazy loading (with a Proc), the title Proc will always be called first.
# * *&action* (Proc): Code called if the item is selected (action for the enter key) [optional].
# * Result::
# * Symbol or Object: If the code returns a symbol, the menu will behave in a specific way:
# * *menu_exit*: the menu selection exits.
# * *menu_refresh*: The menu will compute again its items.
def item(title, actions: {}, &action)
menu_item_def = { title: title }
all_actions =
if action.nil?
actions
else
mapped_default_action = { KEY_ENTER => { name: 'Select', execute: action } }
if actions.is_a?(Proc)
# Make sure we keep the lazyness
proc do
actions.call.merge(mapped_default_action)
end
else
actions.merge(mapped_default_action)
end
end
menu_item_def[:actions] = all_actions if all_actions.is_a?(Proc) || !all_actions.empty?
@current_menu_items << menu_item_def
end
private
# Display a given curses string information.
#
# Parameters::
# * *window* (Window): The curses window in which we display.
# * *string* (String or CursesRow): The curses row, or as a single String.
# * See CursesRow#print_on for all the other parameters description
def print(window, string, from: 0, to: nil, default_color_pair: COLORS_LINE, force_color_pair: nil, pad: nil, add_nl: true, single_line: false)
string = CursesRow.new({ default: { text: string } }) if string.is_a?(String)
string.print_on(
window,
from: from,
to: to,
default_color_pair: default_color_pair,
force_color_pair: force_color_pair,
pad: pad,
add_nl: add_nl,
single_line: single_line
)
end
# Initialize and get the curses menu window
#
# Result::
# * Window: The curses menu window
def curses_menu_initialize
Curses.init_screen
# Use non-blocking key read, otherwise using Popen3 later blocks
Curses.timeout = 0
Curses.start_color
Curses.init_pair(COLORS_TITLE, Curses::COLOR_BLACK, Curses::COLOR_CYAN)
Curses.init_pair(COLORS_LINE, Curses::COLOR_WHITE, Curses::COLOR_BLACK)
Curses.init_pair(COLORS_MENU_ITEM, Curses::COLOR_WHITE, Curses::COLOR_BLACK)
Curses.init_pair(COLORS_MENU_ITEM_SELECTED, Curses::COLOR_BLACK, Curses::COLOR_WHITE)
Curses.init_pair(COLORS_INPUT, Curses::COLOR_WHITE, Curses::COLOR_BLUE)
Curses.init_pair(COLORS_GREEN, Curses::COLOR_GREEN, Curses::COLOR_BLACK)
Curses.init_pair(COLORS_RED, Curses::COLOR_RED, Curses::COLOR_BLACK)
Curses.init_pair(COLORS_YELLOW, Curses::COLOR_YELLOW, Curses::COLOR_BLACK)
Curses.init_pair(COLORS_BLUE, Curses::COLOR_BLUE, Curses::COLOR_BLACK)
Curses.init_pair(COLORS_WHITE, Curses::COLOR_WHITE, Curses::COLOR_BLACK)
window = Curses.stdscr
window.keypad = true
@curses_initialized = true
window
end
# Finalize the curses menu window
def curses_menu_finalize
Curses.close_screen if @curses_initialized
@curses_initialized = false
end
# Get menu items.
#
# Parameters::
# * Proc: Code defining the menu items
# * *menu* (CursesMenu): The menu for which we gather items.
# Result::
# * Array< Hash<Symbol,Object> >: List of items to be displayed
# * *title* (String): Item title to display
# * *actions* (Hash<Object, Hash<Symbol,Object> >): Associated actions to this item, per shortcut [optional]
# * *name* (String): Name of this action (displayed at the bottom of the menu)
# * *execute* (Proc): Code called when this action is selected
def gather_menu_items
@current_menu_items = []
yield self
@current_menu_items
end
end