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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: make_selection
3
- Version: 1.0.1
3
+ Version: 1.0.2
4
4
  Summary: Package for interactive command line menu
5
5
  Author-email: Steven Frazee <stevefrazee123@gmail.com>
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "make_selection"
3
- version = "1.0.1"
3
+ version = "1.0.2"
4
4
  authors = [
5
5
  { name="Steven Frazee", email="stevefrazee123@gmail.com" },
6
6
  ]
@@ -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,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: make_selection
3
- Version: 1.0.1
3
+ Version: 1.0.2
4
4
  Summary: Package for interactive command line menu
5
5
  Author-email: Steven Frazee <stevefrazee123@gmail.com>
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -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