make-selection 1.0.1__tar.gz → 1.0.2__tar.gz
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.
- {make_selection-1.0.1 → make_selection-1.0.2}/PKG-INFO +1 -1
- {make_selection-1.0.1 → make_selection-1.0.2}/pyproject.toml +1 -1
- make_selection-1.0.2/src/make_selection/make_selection.py +230 -0
- {make_selection-1.0.1 → make_selection-1.0.2}/src/make_selection.egg-info/PKG-INFO +1 -1
- make_selection-1.0.1/src/make_selection/make_selection.py +0 -167
- {make_selection-1.0.1 → make_selection-1.0.2}/LICENSE +0 -0
- {make_selection-1.0.1 → make_selection-1.0.2}/README.md +0 -0
- {make_selection-1.0.1 → make_selection-1.0.2}/setup.cfg +0 -0
- {make_selection-1.0.1 → make_selection-1.0.2}/src/make_selection/__init__.py +0 -0
- {make_selection-1.0.1 → make_selection-1.0.2}/src/make_selection.egg-info/SOURCES.txt +0 -0
- {make_selection-1.0.1 → make_selection-1.0.2}/src/make_selection.egg-info/dependency_links.txt +0 -0
- {make_selection-1.0.1 → make_selection-1.0.2}/src/make_selection.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Module for interactive command line menu. Simply accepts a list of str-able objects
|
|
3
|
+
and allows user to select using arrow keys.
|
|
4
|
+
|
|
5
|
+
Ansi escape codes are used as described here: https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797
|
|
6
|
+
"""
|
|
7
|
+
# TODO: work on all platforms
|
|
8
|
+
# TODO: multi_select: Maintain original index for re-insertion.
|
|
9
|
+
# TODO: multi_select: TAB switches to delete mode.
|
|
10
|
+
import sys
|
|
11
|
+
if sys.platform != "win32":
|
|
12
|
+
raise NotImplementedError("This module is only available on Windows.")
|
|
13
|
+
from typing import Any
|
|
14
|
+
from copy import copy
|
|
15
|
+
import msvcrt
|
|
16
|
+
import ctypes
|
|
17
|
+
|
|
18
|
+
stdout = -11
|
|
19
|
+
enable_ansi_codes = 7
|
|
20
|
+
kernel32 = ctypes.windll.kernel32
|
|
21
|
+
kernel32.SetConsoleMode(kernel32.GetStdHandle(stdout), enable_ansi_codes)
|
|
22
|
+
|
|
23
|
+
ANSI_MOVE_CURSOR = "\x1b[{up}F\r\x1b[{right}C"
|
|
24
|
+
ANSI_HIGHLIGHT = "\x1b[30;47m"
|
|
25
|
+
ANSI_HIGHLIGHT_SEARCH_STRING = "\x1b[95;47m"
|
|
26
|
+
ANSI_YELLOW = "\x1b[93m"
|
|
27
|
+
ANSI_BLUE = "\x1b[94m"
|
|
28
|
+
ANSI_MAGENTA = "\x1b[95m"
|
|
29
|
+
ANSI_GREEN = "\x1b[92m"
|
|
30
|
+
ANSI_RESET = "\x1b[0m"
|
|
31
|
+
|
|
32
|
+
SPECIAL_KEY = 224
|
|
33
|
+
UP_ARROW = 72
|
|
34
|
+
DOWN_ARROW = 80
|
|
35
|
+
ENTER_KEY = 13
|
|
36
|
+
CTL_C = 3
|
|
37
|
+
BACKSPACE = 8
|
|
38
|
+
SPACEBAR = 32
|
|
39
|
+
CTL_RIGHT = 116
|
|
40
|
+
|
|
41
|
+
class Menu:
|
|
42
|
+
def __init__(self, options: list, label: str, window_size: int=10, multi_select: bool=False) -> None:
|
|
43
|
+
assert options
|
|
44
|
+
assert label
|
|
45
|
+
assert 1 <= window_size and window_size <= 25
|
|
46
|
+
window_size = min((len(options)), window_size)
|
|
47
|
+
|
|
48
|
+
self.options_original = options
|
|
49
|
+
self.options_current = copy(options)
|
|
50
|
+
self.options_selected = []
|
|
51
|
+
self.search_indices = []
|
|
52
|
+
self.search_string = ""
|
|
53
|
+
self.label = label
|
|
54
|
+
self.selected_index = 0
|
|
55
|
+
self.multi_select = multi_select
|
|
56
|
+
self.window_top = 0
|
|
57
|
+
self.window_size_original = window_size
|
|
58
|
+
self.window_size_current = window_size
|
|
59
|
+
if multi_select:
|
|
60
|
+
self.help_string = "Enter: Select, Ctl+C: Cancel, Ctl\u2192: Done"
|
|
61
|
+
else:
|
|
62
|
+
self.help_string = "Enter: Select, Ctl+C: Cancel"
|
|
63
|
+
|
|
64
|
+
def show(self):
|
|
65
|
+
self.printMenu()
|
|
66
|
+
while True:
|
|
67
|
+
something_changed = False
|
|
68
|
+
char = self.getChar()
|
|
69
|
+
if char == SPECIAL_KEY:
|
|
70
|
+
char = self.getChar()
|
|
71
|
+
if char == CTL_RIGHT and self.multi_select:
|
|
72
|
+
self.printSelected()
|
|
73
|
+
return self.options_selected
|
|
74
|
+
elif 1 < len(self.options_current):
|
|
75
|
+
# NOTE: Update selected index
|
|
76
|
+
if char == UP_ARROW:
|
|
77
|
+
self.selected_index = (self.selected_index - 1) % len(self.options_current)
|
|
78
|
+
something_changed = True
|
|
79
|
+
elif char == DOWN_ARROW:
|
|
80
|
+
self.selected_index = (self.selected_index + 1) % len(self.options_current)
|
|
81
|
+
something_changed = True
|
|
82
|
+
|
|
83
|
+
# NOTE: Update window
|
|
84
|
+
if something_changed:
|
|
85
|
+
bottom = self.window_top + self.window_size_current
|
|
86
|
+
if self.selected_index < self.window_top:
|
|
87
|
+
self.window_top = self.selected_index
|
|
88
|
+
elif bottom <= self.selected_index:
|
|
89
|
+
self.window_top = self.selected_index - self.window_size_current + 1
|
|
90
|
+
elif self.isSearchableChar(char):
|
|
91
|
+
char = chr(char)
|
|
92
|
+
self.search_string = f"{self.search_string}{char}".lstrip()
|
|
93
|
+
if self.search_string:
|
|
94
|
+
print(char, end="", flush=True)
|
|
95
|
+
self.search(self.options_current)
|
|
96
|
+
something_changed = True
|
|
97
|
+
elif char == BACKSPACE and 0 < len(self.search_string):
|
|
98
|
+
self.search_string = self.search_string[:-1]
|
|
99
|
+
# NOTE: Move left once ([1D), print space, move left again
|
|
100
|
+
print("\x1b[1D \x1b[1D", end="", flush=True)
|
|
101
|
+
self.search(self.options_original)
|
|
102
|
+
something_changed = True
|
|
103
|
+
elif char == ENTER_KEY and self.options_current:
|
|
104
|
+
if self.multi_select:
|
|
105
|
+
self.multiSelectAdd()
|
|
106
|
+
something_changed = True
|
|
107
|
+
else:
|
|
108
|
+
self.printSelected()
|
|
109
|
+
return self.options_current[self.selected_index]
|
|
110
|
+
elif char == CTL_C:
|
|
111
|
+
self.clearMenu()
|
|
112
|
+
print("cancelled")
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
if something_changed:
|
|
116
|
+
self.clearMenu()
|
|
117
|
+
self.printMenu()
|
|
118
|
+
|
|
119
|
+
def search(self, search_list: list) -> None:
|
|
120
|
+
found_indices = []
|
|
121
|
+
found_options = []
|
|
122
|
+
for o in search_list:
|
|
123
|
+
found_i = str(o).lower().find(self.search_string.lower())
|
|
124
|
+
if found_i != -1:
|
|
125
|
+
found_indices.append(found_i)
|
|
126
|
+
found_options.append(o)
|
|
127
|
+
self.options_current = found_options
|
|
128
|
+
self.search_indices = found_indices
|
|
129
|
+
self.resetWindow()
|
|
130
|
+
|
|
131
|
+
def multiSelectAdd(self) -> None:
|
|
132
|
+
selected_option = self.options_current[self.selected_index]
|
|
133
|
+
self.options_current.remove(selected_option)
|
|
134
|
+
self.options_original.remove(selected_option)
|
|
135
|
+
self.options_selected.append(selected_option)
|
|
136
|
+
self.resetWindow()
|
|
137
|
+
|
|
138
|
+
def getChar(self) -> int:
|
|
139
|
+
return ord(msvcrt.getch())
|
|
140
|
+
|
|
141
|
+
def isSearchableChar(self, char):
|
|
142
|
+
return (32 <= char and char <= 126)
|
|
143
|
+
|
|
144
|
+
def resetWindow(self):
|
|
145
|
+
self.window_top = 0
|
|
146
|
+
self.selected_index = 0
|
|
147
|
+
self.window_size_current = min((len(self.options_current), self.window_size_original))
|
|
148
|
+
|
|
149
|
+
def clearMenu(self):
|
|
150
|
+
"""
|
|
151
|
+
Clears from beginning of current line to end of screen (not end of line).
|
|
152
|
+
This assumes the cursor is on the top line (label), which is currently always true.
|
|
153
|
+
|
|
154
|
+
[0G move cursor to column 0.
|
|
155
|
+
[J clears to end of screen (not end of line).
|
|
156
|
+
"""
|
|
157
|
+
print("\x1b[0G\x1b[J", end="", flush=True)
|
|
158
|
+
|
|
159
|
+
def printMenu(self):
|
|
160
|
+
header = f"{ANSI_BLUE}{self.label}>{ANSI_RESET}{self.search_string}"
|
|
161
|
+
header_num_lines = 1
|
|
162
|
+
if self.multi_select:
|
|
163
|
+
header += f"\n{ANSI_GREEN}{len(self.options_selected)} items added!{ANSI_RESET}"
|
|
164
|
+
header_num_lines = 2
|
|
165
|
+
print(header)
|
|
166
|
+
|
|
167
|
+
bottom = self.window_top + self.window_size_current
|
|
168
|
+
if not self.options_current:
|
|
169
|
+
print(f"{ANSI_BLUE}no matches found{ANSI_RESET}")
|
|
170
|
+
# NOTE: window size is 0 here after empty search, but we are printing 1 line
|
|
171
|
+
# so we need to set it so the footer prints correctly
|
|
172
|
+
self.window_size_current = 1
|
|
173
|
+
else:
|
|
174
|
+
for i in range(self.window_top, bottom):
|
|
175
|
+
option_to_print = str(self.options_current[i])
|
|
176
|
+
if self.search_indices:
|
|
177
|
+
highlight_beg = self.search_indices[i]
|
|
178
|
+
highlight_end = highlight_beg + len(self.search_string)
|
|
179
|
+
opt_beg = option_to_print[0 : highlight_beg]
|
|
180
|
+
opt_mid = option_to_print[highlight_beg : highlight_end]
|
|
181
|
+
opt_end = option_to_print[highlight_end :]
|
|
182
|
+
if i == self.selected_index:
|
|
183
|
+
option_to_print = f"{ANSI_HIGHLIGHT}{opt_beg}{ANSI_HIGHLIGHT_SEARCH_STRING}{opt_mid}{ANSI_HIGHLIGHT}{opt_end}{ANSI_RESET}"
|
|
184
|
+
else:
|
|
185
|
+
option_to_print = f"{opt_beg}{ANSI_MAGENTA}{opt_mid}{ANSI_RESET}{opt_end}"
|
|
186
|
+
elif i == self.selected_index:
|
|
187
|
+
option_to_print = f"{ANSI_HIGHLIGHT}{option_to_print}{ANSI_RESET}"
|
|
188
|
+
print(option_to_print)
|
|
189
|
+
|
|
190
|
+
footer = f"{ANSI_YELLOW}{self.help_string}{ANSI_RESET}\n"
|
|
191
|
+
footer_num_lines = 1
|
|
192
|
+
print(f"{footer}{ANSI_MOVE_CURSOR.format(up=self.window_size_current + header_num_lines + footer_num_lines, right=len(self.label) + len(self.search_string) + 1)}", end="", flush=True)
|
|
193
|
+
|
|
194
|
+
def printSelected(self):
|
|
195
|
+
self.clearMenu()
|
|
196
|
+
if self.multi_select:
|
|
197
|
+
if len(self.options_selected) <= 3:
|
|
198
|
+
print(f"{self.label}> {self.options_selected} ({len(self.options_selected)} items)")
|
|
199
|
+
else:
|
|
200
|
+
print(f"{self.label}> [{self.options_selected[0]}, ..., {self.options_selected[-1]}] ({len(self.options_selected)} items)")
|
|
201
|
+
else:
|
|
202
|
+
print(f"{self.label}> {self.options_current[self.selected_index]}")
|
|
203
|
+
|
|
204
|
+
def makeSelection(options: list[Any], label: str, window_size: int=None, multi_select: bool=False) -> Any:
|
|
205
|
+
"""
|
|
206
|
+
Entry point for menu selection.
|
|
207
|
+
|
|
208
|
+
Parameters
|
|
209
|
+
----------
|
|
210
|
+
options
|
|
211
|
+
List of str-able objects.
|
|
212
|
+
label
|
|
213
|
+
Label to describe the items being selected.
|
|
214
|
+
window_size
|
|
215
|
+
Max number of items to show at once.
|
|
216
|
+
multi_select
|
|
217
|
+
Select list of items.
|
|
218
|
+
|
|
219
|
+
Returns
|
|
220
|
+
-------
|
|
221
|
+
Selected value or list of selected values.
|
|
222
|
+
"""
|
|
223
|
+
if window_size:
|
|
224
|
+
return Menu(options, label, window_size=window_size, multi_select=multi_select).show()
|
|
225
|
+
else:
|
|
226
|
+
return Menu(options, label, multi_select=multi_select).show()
|
|
227
|
+
|
|
228
|
+
if __name__ == "__main__":
|
|
229
|
+
print(f"Returns: '{makeSelection(['interactive', 'cli', 'menu'], 'make_selection')}'")
|
|
230
|
+
print(f"Returns: '{makeSelection(['interactive', 'cli', 'menu'], 'make_selection', multi_select=True)}'")
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Module for interactive command line menu. Simply accepts a list of str-able objects
|
|
3
|
-
and allows user to select using arrow keys.
|
|
4
|
-
"""
|
|
5
|
-
import sys
|
|
6
|
-
if sys.platform != "win32":
|
|
7
|
-
raise NotImplementedError("This module is only available on Windows.")
|
|
8
|
-
from typing import Any
|
|
9
|
-
import msvcrt
|
|
10
|
-
import ctypes
|
|
11
|
-
|
|
12
|
-
stdout = -11
|
|
13
|
-
enable_ansi_codes = 7
|
|
14
|
-
kernel32 = ctypes.windll.kernel32
|
|
15
|
-
kernel32.SetConsoleMode(kernel32.GetStdHandle(stdout), enable_ansi_codes)
|
|
16
|
-
|
|
17
|
-
ANSI_MOVE_CURSOR = "\x1b[{up}F\r\x1b[{right}C"
|
|
18
|
-
ANSI_HIGHLIGHT = "\x1b[30;47m"
|
|
19
|
-
ANSI_YELLOW = "\x1b[93m"
|
|
20
|
-
ANSI_BLUE = "\x1b[94m"
|
|
21
|
-
ANSI_RESET = "\x1b[0m"
|
|
22
|
-
SPECIAL_KEY = 224
|
|
23
|
-
UP_ARROW = 72
|
|
24
|
-
DOWN_ARROW = 80
|
|
25
|
-
ENTER_KEY = 13
|
|
26
|
-
CTL_C = 3
|
|
27
|
-
BACKSPACE = 8
|
|
28
|
-
SPACEBAR = 32
|
|
29
|
-
|
|
30
|
-
class Menu:
|
|
31
|
-
def __init__(self, options: list, label: str, window_size: int=10) -> None:
|
|
32
|
-
assert options
|
|
33
|
-
assert label
|
|
34
|
-
assert 1 <= window_size
|
|
35
|
-
window_size = min((len(options)), window_size)
|
|
36
|
-
|
|
37
|
-
self.options_original = options
|
|
38
|
-
self.options_current = options
|
|
39
|
-
self.search_indices = []
|
|
40
|
-
self.search_string = ""
|
|
41
|
-
self.label = label
|
|
42
|
-
self.selected_index = 0
|
|
43
|
-
self.window_top = 0
|
|
44
|
-
self.window_size_original = window_size
|
|
45
|
-
self.window_size_current = window_size
|
|
46
|
-
self.help_string = "Enter: Select, Ctl+C: Cancel"
|
|
47
|
-
|
|
48
|
-
def show(self):
|
|
49
|
-
print(f"{ANSI_BLUE}{self.label}>{ANSI_RESET}")
|
|
50
|
-
self.printMenu()
|
|
51
|
-
while True:
|
|
52
|
-
something_changed = False
|
|
53
|
-
char = self.getChar()
|
|
54
|
-
if char == SPECIAL_KEY:
|
|
55
|
-
char = self.getChar()
|
|
56
|
-
if 1 < len(self.options_current):
|
|
57
|
-
# Update selected index
|
|
58
|
-
if char == UP_ARROW:
|
|
59
|
-
self.selected_index = (self.selected_index - 1) % len(self.options_current)
|
|
60
|
-
something_changed = True
|
|
61
|
-
elif char == DOWN_ARROW:
|
|
62
|
-
self.selected_index = (self.selected_index + 1) % len(self.options_current)
|
|
63
|
-
something_changed = True
|
|
64
|
-
|
|
65
|
-
# Update window
|
|
66
|
-
if something_changed:
|
|
67
|
-
bottom = self.window_top + self.window_size_current
|
|
68
|
-
if self.selected_index < self.window_top:
|
|
69
|
-
self.window_top = self.selected_index
|
|
70
|
-
elif bottom <= self.selected_index:
|
|
71
|
-
self.window_top = self.selected_index - self.window_size_current + 1
|
|
72
|
-
elif self.isSearchableChar(char):
|
|
73
|
-
char = chr(char)
|
|
74
|
-
self.search_string = f"{self.search_string}{char}".lstrip()
|
|
75
|
-
if self.search_string:
|
|
76
|
-
print(char, end="", flush=True)
|
|
77
|
-
self.search(self.options_current)
|
|
78
|
-
something_changed = True
|
|
79
|
-
elif char == BACKSPACE and 0 < len(self.search_string):
|
|
80
|
-
self.search_string = self.search_string[:-1]
|
|
81
|
-
# Move left, print space, move left again
|
|
82
|
-
print("\x1b[1D \x1b[1D", end="", flush=True)
|
|
83
|
-
self.search(self.options_original)
|
|
84
|
-
something_changed = True
|
|
85
|
-
elif char == ENTER_KEY and self.options_current:
|
|
86
|
-
self.printSelected()
|
|
87
|
-
return self.options_current[self.selected_index]
|
|
88
|
-
elif char == CTL_C:
|
|
89
|
-
self.clearMenu(clear_label=True)
|
|
90
|
-
print("cancelled")
|
|
91
|
-
return None
|
|
92
|
-
|
|
93
|
-
if something_changed:
|
|
94
|
-
self.clearMenu()
|
|
95
|
-
self.printMenu()
|
|
96
|
-
|
|
97
|
-
def search(self, search_list: list) -> None:
|
|
98
|
-
found_indices = []
|
|
99
|
-
found_options = []
|
|
100
|
-
for o in search_list:
|
|
101
|
-
found_i = str(o).lower().find(self.search_string.lower())
|
|
102
|
-
if found_i != -1:
|
|
103
|
-
found_indices.append(found_i)
|
|
104
|
-
found_options.append(o)
|
|
105
|
-
self.options_current = found_options
|
|
106
|
-
self.search_indices = found_indices
|
|
107
|
-
|
|
108
|
-
# Reset window after modifying options
|
|
109
|
-
self.window_top = 0
|
|
110
|
-
self.selected_index = 0
|
|
111
|
-
self.window_size_current = min((len(self.options_current), self.window_size_original))
|
|
112
|
-
|
|
113
|
-
def getChar(self) -> int:
|
|
114
|
-
return ord(msvcrt.getch())
|
|
115
|
-
|
|
116
|
-
def isSearchableChar(self, char):
|
|
117
|
-
return (32 <= char and char <= 126)
|
|
118
|
-
|
|
119
|
-
def clearMenu(self, clear_label=False):
|
|
120
|
-
if clear_label:
|
|
121
|
-
print("\x1b[0G\x1b[J", end="", flush=True)
|
|
122
|
-
else:
|
|
123
|
-
print("\x1b[J")
|
|
124
|
-
|
|
125
|
-
def printMenu(self):
|
|
126
|
-
bottom = self.window_top + self.window_size_current
|
|
127
|
-
if not self.options_current:
|
|
128
|
-
print(f"{ANSI_BLUE}no matches found{ANSI_RESET}")
|
|
129
|
-
# NOTE: window size is 0 here after empty search, but we are printing 1 line
|
|
130
|
-
# so we need to set it so the footer prints correctly
|
|
131
|
-
self.window_size_current = 1
|
|
132
|
-
else:
|
|
133
|
-
for i in range(self.window_top, bottom):
|
|
134
|
-
if i == self.selected_index:
|
|
135
|
-
print(f"{ANSI_HIGHLIGHT}{self.options_current[i]}{ANSI_RESET}")
|
|
136
|
-
else:
|
|
137
|
-
print(self.options_current[i])
|
|
138
|
-
print(f"{ANSI_YELLOW}{self.help_string}{ANSI_RESET}\n{ANSI_MOVE_CURSOR.format(up=self.window_size_current + 2, right=len(self.label) + len(self.search_string) + 1)}", end="", flush=True)
|
|
139
|
-
|
|
140
|
-
def printSelected(self):
|
|
141
|
-
self.clearMenu(clear_label=True)
|
|
142
|
-
print(f"{self.label}> {self.options_current[self.selected_index]}")
|
|
143
|
-
|
|
144
|
-
def makeSelection(options: list[Any], label: str, window_size: int=None) -> Any:
|
|
145
|
-
"""
|
|
146
|
-
Entry point for menu selection.
|
|
147
|
-
|
|
148
|
-
Parameters
|
|
149
|
-
----------
|
|
150
|
-
options
|
|
151
|
-
List of str-able objects.
|
|
152
|
-
label
|
|
153
|
-
Label to describe the items being selected.
|
|
154
|
-
window_size
|
|
155
|
-
Max number of items to show at once.
|
|
156
|
-
|
|
157
|
-
Returns
|
|
158
|
-
-------
|
|
159
|
-
Selected value.
|
|
160
|
-
"""
|
|
161
|
-
if window_size:
|
|
162
|
-
return Menu(options, label, window_size).show()
|
|
163
|
-
else:
|
|
164
|
-
return Menu(options, label).show()
|
|
165
|
-
|
|
166
|
-
if __name__ == "__main__":
|
|
167
|
-
print(f"Returns: '{makeSelection(['interactive', 'cli', 'menu'], 'make_selection')}'")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{make_selection-1.0.1 → make_selection-1.0.2}/src/make_selection.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|