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