New upstream release.
Debian Janitor
2 years ago
0 | Metadata-Version: 1.2 | |
0 | Metadata-Version: 2.1 | |
1 | 1 | Name: panwid |
2 | Version: 0.3.0.dev15 | |
2 | Version: 0.3.4 | |
3 | 3 | Summary: Useful widgets for urwid |
4 | 4 | Home-page: https://github.com/tonycpsu/panwid |
5 | 5 | Author: Tony Cebzanov |
6 | 6 | Author-email: tonycpsu@gmail.com |
7 | 7 | License: UNKNOWN |
8 | Description: UNKNOWN | |
9 | 8 | Platform: UNKNOWN |
10 | 9 | Classifier: Environment :: Console |
11 | 10 | Classifier: License :: OSI Approved :: GNU General Public License v2 (GPLv2) |
12 | 11 | Classifier: Intended Audience :: Developers |
13 | 12 | Requires-Python: >=3.6 |
13 | License-File: LICENSE | |
14 | ||
15 | UNKNOWN | |
16 |
2 | 2 | |
3 | 3 | A collection of widgets for [Urwid](https://urwid.org/). |
4 | 4 | |
5 | Currently consists of the following: | |
5 | Currently consists of the following sub-modules: | |
6 | 6 | |
7 | ## Dropdown ## | |
7 | ## autocomplete ## | |
8 | 8 | |
9 | Dropdown menu widget with autocomplete support. | |
9 | Adds autocomplete functionality to a container widget. See `Dropdown` | |
10 | implementation for how it works until there's proper documentation. | |
10 | 11 | |
11 | [![asciicast](https://asciinema.org/a/m23L8xPJsTQRxzOCwvc1SuduN.png)](https://asciinema.org/a/m23L8xPJsTQRxzOCwvc1SuduN?autoplay=1) | |
12 | ||
13 | ## DataTable ## | |
12 | ## datatable ## | |
14 | 13 | |
15 | 14 | Widget for displaying tabular data. |
16 | 15 | |
21 | 20 | |
22 | 21 | [![asciicast](https://asciinema.org/a/iRbvnuv7DERhZrdKKBfpGtXqw.png)](https://asciinema.org/a/iRbvnuv7DERhZrdKKBfpGtXqw?autoplay=1) |
23 | 22 | |
24 | ## ScrollingListbox ## | |
23 | ## dialog ## | |
25 | 24 | |
26 | Listbox with an optional scrollbar. Can signal to other widgets | |
27 | (e.g. DataTable) when to fetch more data. Used by both Dropdown and | |
28 | DataTable, but can be used separately. | |
25 | A set of simple classes for implementing pop-up dialogs. | |
29 | 26 | |
30 | ## TabView ## | |
27 | ## dropdown ## | |
28 | ||
29 | Dropdown menu widget with autocomplete support. | |
30 | ||
31 | [![asciicast](https://asciinema.org/a/m23L8xPJsTQRxzOCwvc1SuduN.png)](https://asciinema.org/a/m23L8xPJsTQRxzOCwvc1SuduN?autoplay=1) | |
32 | ||
33 | ## highlightable ## | |
34 | ||
35 | Adds the ability for text widgets (or any widget with text in them) to have | |
36 | strings highlighted in them. See `Dropdown` implementation until there's proper | |
37 | documentation. | |
38 | ||
39 | ## keymap ## | |
40 | ||
41 | Adds ability to define keyboard mappings across multiple widgets in your | |
42 | application without having to write Urwid `keypress`` methods. See `Dropdown` | |
43 | implementation until there's proper documentation. | |
44 | ||
45 | ## progressbar ## | |
46 | ||
47 | A configurable horizontal progress bar that uses unicode box drawing characters | |
48 | for sub-character-width resolution. | |
49 | ||
50 | ## scroll ## | |
51 | ||
52 | Makes any fixed or flow widget vertically scrollable. Copied with permission | |
53 | from `rndusr/stig`. | |
54 | ||
55 | ## sparkwidgets ## | |
56 | ||
57 | A set of sparkline-ish widgets for displaying data visually using a small number | |
58 | of screen characters. | |
59 | ||
60 | ## tabview ## | |
31 | 61 | |
32 | 62 | A container widget that allows selection of content via tab handles. |
33 | 63 |
0 | python-panwid (0.3.4-1) UNRELEASED; urgency=low | |
1 | ||
2 | * New upstream release. | |
3 | ||
4 | -- Debian Janitor <janitor@jelmer.uk> Sat, 16 Oct 2021 05:18:29 -0000 | |
5 | ||
0 | 6 | python-panwid (0.3.0.dev15-2) unstable; urgency=medium |
1 | 7 | |
2 | 8 | * Build-depend on locales-all to fix FTBFS (Closes: #963400) |
0 | from . import listbox | |
1 | from .listbox import * | |
0 | from . import autocomplete | |
1 | from .autocomplete import * | |
2 | 2 | from . import datatable |
3 | 3 | from .datatable import * |
4 | 4 | from . import dialog |
5 | 5 | from .dialog import * |
6 | 6 | from . import dropdown |
7 | 7 | from .dropdown import * |
8 | from . import highlightable | |
9 | from .highlightable import * | |
8 | 10 | from . import keymap |
9 | 11 | from .keymap import * |
12 | from . import listbox | |
13 | from .listbox import * | |
10 | 14 | from . import scroll |
11 | 15 | from .scroll import * |
16 | from . import sparkwidgets | |
17 | from .sparkwidgets import * | |
12 | 18 | from . import tabview |
13 | 19 | from .tabview import * |
14 | 20 | |
15 | __version__ = "0.3.0.dev15" | |
21 | __version__ = "0.3.4" | |
16 | 22 | |
17 | 23 | __all__ = ( |
18 | listbox.__all__ | |
24 | autocomplete.__all__ | |
19 | 25 | + datatable.__all__ |
20 | 26 | + dialog.__all__ |
21 | 27 | + dropdown.__all__ |
28 | + highlightable.__all__ | |
22 | 29 | + keymap.__all__ |
30 | + listbox.__all__ | |
23 | 31 | + scroll.__all__ |
32 | + sparkwidgets.__all__ | |
24 | 33 | + tabview.__all__ |
25 | 34 | ) |
0 | import logging | |
1 | logger = logging.getLogger(__name__) | |
2 | import itertools | |
3 | ||
4 | import urwid | |
5 | ||
6 | from .highlightable import HighlightableTextMixin | |
7 | from .keymap import * | |
8 | ||
9 | @keymapped() | |
10 | class AutoCompleteEdit(urwid.Edit): | |
11 | ||
12 | signals = ["select", "close", "complete_next", "complete_prev"] | |
13 | ||
14 | KEYMAP = { | |
15 | "enter": "confirm", | |
16 | "esc": "cancel" | |
17 | } | |
18 | ||
19 | def clear(self): | |
20 | self.set_edit_text("") | |
21 | ||
22 | def confirm(self): | |
23 | self._emit("select") | |
24 | self._emit("close") | |
25 | ||
26 | def cancel(self): | |
27 | self._emit("close") | |
28 | ||
29 | def complete_next(self): | |
30 | self._emit("complete_next") | |
31 | ||
32 | def complete_prev(self): | |
33 | self._emit("complete_prev") | |
34 | ||
35 | def keypress(self, size, key): | |
36 | return super().keypress(size, key) | |
37 | ||
38 | @keymapped() | |
39 | class AutoCompleteBar(urwid.WidgetWrap): | |
40 | ||
41 | signals = ["change", "complete_prev", "complete_next", "select", "close"] | |
42 | ||
43 | prompt_attr = "dropdown_prompt" | |
44 | ||
45 | def __init__(self, prompt_attr=None): | |
46 | ||
47 | self.prompt_attr = prompt_attr or self.prompt_attr | |
48 | self.prompt = urwid.Text((self.prompt_attr, "> ")) | |
49 | self.text = AutoCompleteEdit("") | |
50 | # self.text.selectable = lambda x: False | |
51 | self.cols = urwid.Columns([ | |
52 | (2, self.prompt), | |
53 | ("weight", 1, self.text) | |
54 | ], dividechars=0) | |
55 | self.cols.focus_position = 1 | |
56 | self.filler = urwid.Filler(self.cols, valign="bottom") | |
57 | urwid.connect_signal(self.text, "postchange", self.text_changed) | |
58 | urwid.connect_signal(self.text, "complete_prev", lambda source: self._emit("complete_prev")) | |
59 | urwid.connect_signal(self.text, "complete_next", lambda source: self._emit("complete_next")) | |
60 | urwid.connect_signal(self.text, "select", lambda source: self._emit("select")) | |
61 | urwid.connect_signal(self.text, "close", lambda source: self._emit("close")) | |
62 | super(AutoCompleteBar, self).__init__(self.filler) | |
63 | ||
64 | def set_prompt(self, text): | |
65 | ||
66 | self.prompt.set_text((self.prompt_attr, text)) | |
67 | ||
68 | def set_text(self, text): | |
69 | ||
70 | self.text.set_edit_text(text) | |
71 | ||
72 | def text_changed(self, source, text): | |
73 | self._emit("change", text) | |
74 | ||
75 | def confirm(self): | |
76 | self._emit("select") | |
77 | self._emit("close") | |
78 | ||
79 | def cancel(self): | |
80 | self._emit("close") | |
81 | ||
82 | def __len__(self): | |
83 | return len(self.body) | |
84 | ||
85 | def keypress(self, size, key): | |
86 | return super().keypress(size, key) | |
87 | ||
88 | @keymapped() | |
89 | class AutoCompleteMixin(object): | |
90 | ||
91 | auto_complete = None | |
92 | prompt_attr = "dropdown_prompt" | |
93 | ||
94 | def __init__(self, auto_complete, prompt_attr=None, *args, **kwargs): | |
95 | super().__init__(self.complete_container, *args, **kwargs) | |
96 | if auto_complete is not None: self.auto_complete = auto_complete | |
97 | if prompt_attr is not None: | |
98 | self.prompt_attr = prompt_attr | |
99 | self.auto_complete_bar = None | |
100 | self.completing = False | |
101 | self.complete_anywhere = False | |
102 | self.case_sensitive = False | |
103 | self.last_complete_pos = None | |
104 | self.complete_string_location = None | |
105 | self.last_filter_text = None | |
106 | ||
107 | if self.auto_complete: | |
108 | self.auto_complete_bar = AutoCompleteBar(prompt_attr=self.prompt_attr) | |
109 | ||
110 | ||
111 | urwid.connect_signal( | |
112 | self.auto_complete_bar, "change", | |
113 | lambda source, text: self.complete() | |
114 | ) | |
115 | urwid.connect_signal( | |
116 | self.auto_complete_bar, "complete_prev", | |
117 | lambda source: self.complete_prev() | |
118 | ) | |
119 | urwid.connect_signal( | |
120 | self.auto_complete_bar, "complete_next", | |
121 | lambda source: self.complete_next() | |
122 | ) | |
123 | ||
124 | urwid.connect_signal( | |
125 | self.auto_complete_bar, "select", self.on_complete_select | |
126 | ) | |
127 | urwid.connect_signal( | |
128 | self.auto_complete_bar, "close", self.on_complete_close | |
129 | ) | |
130 | ||
131 | def keypress(self, size, key): | |
132 | return super().keypress(size, key) | |
133 | # key = super().keypress(size, key) | |
134 | # if self.completing and key == "enter": | |
135 | # self.on_complete_select(self) | |
136 | # else: | |
137 | # return key | |
138 | ||
139 | @property | |
140 | def complete_container(self): | |
141 | raise NotImplementedError | |
142 | ||
143 | @property | |
144 | def complete_container_position(self): | |
145 | return 1 | |
146 | ||
147 | @property | |
148 | def complete_body_position(self): | |
149 | return 0 | |
150 | ||
151 | @property | |
152 | def complete_body(self): | |
153 | raise NotImplementedError | |
154 | ||
155 | @property | |
156 | def complete_items(self): | |
157 | raise NotImplementedError | |
158 | ||
159 | ||
160 | def complete_widget_at_pos(self, pos): | |
161 | return self.complete_body[pos] | |
162 | ||
163 | def complete_set_focus(self, pos): | |
164 | self.focus_position = pos | |
165 | ||
166 | @keymap_command() | |
167 | def complete_prefix(self): | |
168 | self.complete_on() | |
169 | ||
170 | @keymap_command() | |
171 | def complete_substring(self): | |
172 | self.complete_on(anywhere=True) | |
173 | ||
174 | def complete_prev(self): | |
175 | self.complete(step=-1) | |
176 | ||
177 | def complete_next(self): | |
178 | self.complete(step=1) | |
179 | ||
180 | def complete_on(self, anywhere=False, case_sensitive=False): | |
181 | ||
182 | if self.completing: | |
183 | return | |
184 | self.completing = True | |
185 | self.show_bar() | |
186 | if anywhere: | |
187 | self.complete_anywhere = True | |
188 | else: | |
189 | self.complete_anywhere = False | |
190 | ||
191 | if case_sensitive: | |
192 | self.case_sensitive = True | |
193 | else: | |
194 | self.case_sensitive = False | |
195 | ||
196 | def complete_compare_substring(self, search, candidate): | |
197 | try: | |
198 | return candidate.index(search) | |
199 | except ValueError: | |
200 | return None | |
201 | ||
202 | def complete_compare_fn(self, search, candidate): | |
203 | ||
204 | if self.case_sensitive: | |
205 | f = lambda x: str(x) | |
206 | else: | |
207 | f = lambda x: str(x.lower()) | |
208 | ||
209 | if self.complete_anywhere: | |
210 | return self.complete_compare_substring(f(search), f(candidate)) | |
211 | else: | |
212 | return 0 if self.complete_compare_substring(f(search), f(candidate))==0 else None | |
213 | # return f(candidate) | |
214 | ||
215 | ||
216 | @keymap_command() | |
217 | def complete_off(self): | |
218 | ||
219 | if not self.completing: | |
220 | return | |
221 | self.filter_text = "" | |
222 | ||
223 | self.hide_bar() | |
224 | self.completing = False | |
225 | ||
226 | @keymap_command | |
227 | def complete(self, step=None, no_wrap=False): | |
228 | ||
229 | if not self.filter_text: | |
230 | return | |
231 | ||
232 | # if not step and self.filter_text == self.last_filter_text: | |
233 | # return | |
234 | ||
235 | logger.info(f"complete: {self.filter_text}") | |
236 | ||
237 | if self.last_complete_pos: | |
238 | widget = self.complete_widget_at_pos(self.last_complete_pos) | |
239 | if isinstance(widget, HighlightableTextMixin): | |
240 | widget.unhighlight() | |
241 | ||
242 | self.initial_pos = self.complete_body.get_focus()[1] | |
243 | positions = itertools.cycle( | |
244 | self.complete_body.positions(reverse=(step and step < 0)) | |
245 | ) | |
246 | pos = next(positions) | |
247 | while pos != self.initial_pos: | |
248 | logger.info(pos) | |
249 | pos = next(positions) | |
250 | for i in range(abs(step or 0)): | |
251 | pos = next(positions) | |
252 | ||
253 | while True: | |
254 | widget = self.complete_widget_at_pos(pos) | |
255 | complete_index = self.complete_compare_fn(self.filter_text, str(widget)) | |
256 | if complete_index is not None: | |
257 | self.last_complete_pos = pos | |
258 | if isinstance(widget, HighlightableTextMixin): | |
259 | widget.highlight(complete_index, complete_index+len(self.filter_text)) | |
260 | self.complete_set_focus(pos) | |
261 | break | |
262 | pos = next(positions) | |
263 | if pos == self.initial_pos: | |
264 | break | |
265 | ||
266 | logger.info("done") | |
267 | self.last_filter_text = self.filter_text | |
268 | ||
269 | @keymap_command() | |
270 | def cancel(self): | |
271 | logger.debug("cancel") | |
272 | self.complete_container.focus_position = self.selected_button | |
273 | self.close() | |
274 | ||
275 | def close(self): | |
276 | self._emit("close") | |
277 | ||
278 | def show_bar(self): | |
279 | pos = self.complete_container_pos | |
280 | self.complete_container.contents[pos:pos+1] += [( | |
281 | self.auto_complete_bar, | |
282 | self.complete_container.options("given", 1) | |
283 | )] | |
284 | # self.box.height -= 1 | |
285 | self.complete_container.focus_position = pos | |
286 | ||
287 | def hide_bar(self): | |
288 | pos = self.complete_container_pos | |
289 | widget = self.complete_widget_at_pos(self.complete_body.get_focus()[1]) | |
290 | if isinstance(widget, HighlightableTextMixin): | |
291 | widget.unhighlight() | |
292 | self.complete_container.focus_position = self.complete_body_position | |
293 | del self.complete_container.contents[pos] | |
294 | # self.box.height += 1 | |
295 | ||
296 | @property | |
297 | def filter_text(self): | |
298 | return self.auto_complete_bar.text.get_text()[0] | |
299 | ||
300 | @filter_text.setter | |
301 | def filter_text(self, value): | |
302 | return self.auto_complete_bar.set_text(value) | |
303 | ||
304 | def on_complete_select(self, source): | |
305 | widget = self.complete_widget_at_pos(self.complete_body.get_focus()[1]) | |
306 | self.complete_off() | |
307 | self._emit("select", self.last_complete_pos, widget) | |
308 | self._emit("close") | |
309 | ||
310 | def on_complete_close(self, source): | |
311 | self.complete_off() | |
312 | ||
313 | __all__ = ["AutoCompleteMixin"] |
34 | 34 | |
35 | 35 | self._width = None |
36 | 36 | self._height = None |
37 | self.contents_rows = None | |
37 | 38 | # self.width = None |
38 | 39 | |
39 | 40 | if column.padding: |
46 | 47 | # logger.info(f"{self.column.name}, {self.column.width}, {self.column.align}") |
47 | 48 | # if self.row.row_height is not None: |
48 | 49 | |
49 | self.filler = urwid.Filler(self.contents) | |
50 | # self.filler = urwid.Filler(self.contents) | |
50 | 51 | |
51 | 52 | self.normal_attr_map = {} |
52 | 53 | self.highlight_attr_map = {} |
61 | 62 | self.highlight_focus_map.update(self.table.highlight_focus_map) |
62 | 63 | |
63 | 64 | self.attrmap = urwid.AttrMap( |
64 | self.filler, | |
65 | # self.filler, | |
66 | urwid.Filler(self.contents) if "flow" in self.contents.sizing() else self.contents, | |
65 | 67 | attr_map = self.normal_attr_map, |
66 | 68 | focus_map = self.normal_focus_map |
67 | 69 | ) |
171 | 173 | maxrow = size[1] |
172 | 174 | self._height = maxrow |
173 | 175 | else: |
174 | contents_rows = self.contents.rows(size, focus) | |
176 | self.contents_rows = self.contents.rows(size, focus) | |
175 | 177 | self._height = contents_rows |
176 | if (getattr(self.column, "truncate", None) | |
177 | and isinstance(self.contents, urwid.Widget) | |
178 | and hasattr(self.contents, "truncate") | |
179 | ): | |
180 | self.contents.truncate( | |
181 | self.width - (self.padding*2), end_char=self.column.truncate | |
182 | ) | |
178 | ||
179 | if getattr(self.column, "truncate", None): | |
180 | rows = self.inner_contents.pack((self.width,))[1] | |
181 | if rows > 1: | |
182 | self.truncate() | |
183 | 183 | return super().render(size, focus) |
184 | 184 | |
185 | 185 | @property |
190 | 190 | def height(self): |
191 | 191 | return self._height |
192 | 192 | |
193 | # def rows(self, size, focus=False): | |
194 | # if getattr(self.column, "truncate", None): | |
195 | # return 1 | |
196 | # contents_rows = self.contents.rows((maxcol,), focus) | |
197 | # return contents_rows | |
198 | # try: | |
199 | # return super().rows(size, focus) | |
200 | # except Exception as e: | |
201 | # raise Exception(self, size, self.contents, e) | |
193 | @property | |
194 | def inner_contents(self): | |
195 | return self.contents | |
196 | ||
197 | def truncate(self): | |
198 | pass | |
199 | ||
202 | 200 | |
203 | 201 | class DataTableDividerCell(DataTableCell): |
204 | 202 | |
231 | 229 | |
232 | 230 | def update_contents(self): |
233 | 231 | |
234 | try: | |
235 | contents = self.table.decorate( | |
236 | self.row, | |
237 | self.column, | |
238 | self.formatted_value | |
232 | self.inner = self.table.decorate( | |
233 | self.row, | |
234 | self.column, | |
235 | self.formatted_value | |
236 | ) | |
237 | ||
238 | # try: | |
239 | # self.inner = self.table.decorate( | |
240 | # self.row, | |
241 | # self.column, | |
242 | # self.formatted_value | |
243 | # ) | |
244 | # except Exception as e: | |
245 | # logger.exception(e) | |
246 | # self.inner = urwid.Text("") | |
247 | ||
248 | if getattr(self.column, "truncate", None): | |
249 | end_char = u"\N{HORIZONTAL ELLIPSIS}" if self.column.truncate is True else self.column.truncate | |
250 | contents = urwid.Columns([ | |
251 | ("weight", 1, self.inner), | |
252 | (0, urwid.Text(end_char)) | |
253 | ]) | |
254 | contents = urwid.Filler(contents) | |
255 | else: | |
256 | width = "pack" | |
257 | ||
258 | contents = urwid.Padding( | |
259 | self.inner, | |
260 | align=self.column.align, | |
261 | width = width, | |
262 | left=self.padding, | |
263 | right=self.padding | |
239 | 264 | ) |
240 | except Exception as e: | |
241 | logger.exception(e) | |
242 | contents = urwid.Text("") | |
243 | ||
244 | contents = urwid.Padding( | |
245 | contents, | |
246 | align=self.column.align, | |
247 | width="pack", | |
248 | left=self.padding, | |
249 | right=self.padding | |
250 | ) | |
265 | # contents = urwid.Filler(contents) | |
251 | 266 | |
252 | 267 | self.contents = contents |
268 | ||
269 | def truncate(self): | |
270 | columns = self.contents.original_widget | |
271 | col = columns.contents[1] | |
272 | col = (col[0], columns.options("given", 1)) | |
273 | del self.contents.original_widget.contents[1] | |
274 | columns.contents.append(col) | |
275 | ||
276 | ||
277 | @property | |
278 | def inner_contents(self): | |
279 | return self.inner | |
253 | 280 | |
254 | 281 | |
255 | 282 | class DataTableDividerBodyCell(DataTableDividerCell, DataTableBodyCell): |
280 | 307 | def update_contents(self): |
281 | 308 | |
282 | 309 | self.label = self.column.label |
283 | self.sort_icon = self.column.sort_icon or self.table.sort_icons | |
310 | if self.column.sort_icon is not None: | |
311 | self.sort_icon = self.column.sort_icon | |
312 | else: | |
313 | self.sort_icon = self.table.sort_icons | |
284 | 314 | |
285 | 315 | label = (self.label |
286 | 316 | if isinstance(self.label, urwid.Widget) |
368 | 398 | if sort and sort[0] == self.column.name: |
369 | 399 | direction = self.DESCENDING_SORT_MARKER if sort[1] else self.ASCENDING_SORT_MARKER |
370 | 400 | self.contents.contents[index][0].set_text(direction) |
371 | else: | |
372 | self.contents.contents[index][0].set_text("") | |
401 | # else: | |
402 | # self.contents.contents[index][0].set_text("") | |
373 | 403 | |
374 | 404 | |
375 | 405 | class DataTableDividerHeaderCell(DataTableDividerCell, DataTableHeaderCell): |
64 | 64 | self.width = self.initial_width |
65 | 65 | |
66 | 66 | def __repr__(self): |
67 | return f"<{self.__class__.__name__}: {self.name}>" | |
67 | return f"<{self.__class__.__name__}: {self.name} ({self.width}, {self.sizing})>" | |
68 | 68 | |
69 | 69 | def width_with_padding(self, table_padding=None): |
70 | 70 | padding = 0 |
96 | 96 | truncate=False, |
97 | 97 | format_fn=None, |
98 | 98 | decoration_fn=None, |
99 | format_record = None, # format_fn is passed full row data | |
100 | 99 | sort_key = None, sort_reverse=False, |
101 | 100 | sort_icon = None, |
102 | 101 | footer_fn = None, footer_arg = "values", **kwargs): |
118 | 117 | self.truncate = truncate |
119 | 118 | self.format_fn = format_fn |
120 | 119 | self.decoration_fn = decoration_fn |
121 | self.format_record = format_record | |
122 | 120 | self.sort_key = sort_key |
123 | 121 | self.sort_reverse = sort_reverse |
124 | 122 | self.sort_icon = sort_icon |
161 | 159 | |
162 | 160 | # First, call the format function for the column, if there is one |
163 | 161 | if self.format_fn: |
164 | try: | |
165 | v = self.format_fn(v) | |
166 | except Exception as e: | |
167 | logger.error("%s format exception: %s" %(self.name, v)) | |
168 | logger.exception(e) | |
169 | raise e | |
162 | v = self.format_fn(v) | |
163 | # try: | |
164 | # v = self.format_fn(v) | |
165 | # except Exception as e: | |
166 | # logger.error("%s format exception: %s" %(self.name, v)) | |
167 | # logger.exception(e) | |
168 | # raise e | |
170 | 169 | return self.format(v) |
171 | 170 | |
172 | 171 |
8 | 8 | |
9 | 9 | def __init__(self, data=None, columns=None, index=None, index_name="index", sort=None): |
10 | 10 | |
11 | self.sidecar_columns = [] | |
11 | 12 | if columns and not index_name in columns: |
12 | 13 | columns.insert(0, index_name) |
13 | 14 | columns += self.DATA_TABLE_COLUMNS |
46 | 47 | "..." if len(self.index) > n else "", |
47 | 48 | df.head(n))) |
48 | 49 | |
49 | def transpose_data(self, rows): | |
50 | data_columns = list(set().union(*(list(d.keys()) for d in rows))) | |
50 | @staticmethod | |
51 | def extract_keys(obj): | |
52 | return obj.keys() if hasattr(obj, "keys") else obj.__dict__.keys() | |
53 | ||
54 | @staticmethod | |
55 | def extract_value(obj, key): | |
56 | if isinstance(obj, collections.abc.MutableMapping): | |
57 | # raise Exception(obj) | |
58 | return obj.get(key, None) | |
59 | else: | |
60 | return getattr(obj, key, None) | |
61 | ||
62 | def transpose_data(self, rows, with_sidecar = False): | |
63 | ||
64 | # raise Exception([ r[self.index_name] for r, s in rows]) | |
65 | ||
66 | if with_sidecar: | |
67 | data_columns, self.sidecar_columns = [ | |
68 | list(set().union(*x)) | |
69 | for x in zip(*[( | |
70 | self.extract_keys(d), | |
71 | self.extract_keys(s) | |
72 | ) | |
73 | for d, s in rows ]) | |
74 | ] | |
75 | ||
76 | else: | |
77 | data_columns = list( | |
78 | set().union(*(list(d.keys() | |
79 | if hasattr(d, "keys") | |
80 | else d.__dict__.keys()) | |
81 | for d in rows)) | |
82 | ) | |
83 | ||
51 | 84 | data_columns += [ |
52 | 85 | c for c in self.columns |
53 | 86 | if c not in data_columns |
87 | and c not in self.sidecar_columns | |
54 | 88 | and c != self.index_name |
55 | 89 | and c not in self.DATA_TABLE_COLUMNS |
56 | 90 | ] |
57 | data_columns += ["_cls", "_details"] | |
91 | data_columns += ["_cls"] | |
58 | 92 | |
59 | 93 | data = dict( |
60 | list(zip((data_columns), | |
61 | [ list(z) for z in zip(*[[ | |
62 | d.get(k, None if k != "_details" else {"open": False, "disabled": False}) | |
63 | if isinstance(d, collections.abc.MutableMapping) | |
64 | else getattr(d, k, None if k != "_details" else {"open": False, "disabled": False}) | |
65 | # for k in data_columns + self.DATA_TABLE_COLUMNS] for d in rows])] | |
66 | for k in data_columns] for d in rows])] | |
67 | )) | |
68 | ) | |
94 | list(zip((data_columns + self.sidecar_columns), | |
95 | [ list(z) for z in zip(*[[ | |
96 | self.extract_value(d, k) if k in data_columns else self.extract_value(s, k) | |
97 | for k in data_columns + self.sidecar_columns] | |
98 | for d, s in ( | |
99 | rows | |
100 | if with_sidecar | |
101 | else [ (r, {}) for r in rows] | |
102 | ) | |
103 | ])] | |
104 | )) | |
105 | ) | |
106 | ||
69 | 107 | return data |
70 | 108 | |
71 | def update_rows(self, rows, limit=None): | |
72 | 109 | |
73 | data = self.transpose_data(rows) | |
110 | def update_rows(self, rows, replace=False, with_sidecar = False): | |
111 | ||
112 | if not len(rows): | |
113 | return [] | |
114 | ||
115 | data = self.transpose_data(rows, with_sidecar = with_sidecar) | |
74 | 116 | # data["_details"] = [{"open": False, "disabled": False}] * len(rows) |
117 | data["_cls"] = [type(rows[0][0] if with_sidecar else rows[0])] * len(rows) # all rows assumed to have same class | |
118 | ||
119 | # raise Exception(data["_cls"]) | |
75 | 120 | # if not "_details" in data: |
76 | 121 | # data["_details"] = [{"open": False, "disabled": False}] * len(rows) |
77 | 122 | |
78 | if not limit: | |
123 | if replace: | |
79 | 124 | if len(rows): |
80 | 125 | indexes = [x for x in self.index if x not in data.get(self.index_name, [])] |
81 | 126 | if len(indexes): |
85 | 130 | |
86 | 131 | # logger.info(f"update_rowGs: {self.index}, {data[self.index_name]}") |
87 | 132 | |
88 | if not len(rows): | |
89 | return [] | |
90 | ||
91 | 133 | if self.index_name not in data: |
92 | 134 | index = list(range(len(self), len(self) + len(rows))) |
93 | 135 | data[self.index_name] = index |
95 | 137 | index = data[self.index_name] |
96 | 138 | |
97 | 139 | for c in data.keys(): |
98 | try: | |
140 | # try: | |
141 | # raise Exception(data[self.index_name], c, data[c]) | |
99 | 142 | self.set(data[self.index_name], c, data[c]) |
100 | except ValueError as e: | |
101 | logger.error(e) | |
102 | logger.info(f"update_rows: {self.index}, {data}") | |
103 | raise Exception(c, len(self.index), len(data[c])) | |
143 | # except ValueError as e: | |
144 | # logger.error(e) | |
145 | # logger.info(f"update_rows: {self.index}, {data}") | |
146 | # | |
147 | for idx in data[self.index_name]: | |
148 | if not self.get(idx, "_details"): | |
149 | self.set(idx, "_details", {"open": False, "disabled": False}) | |
150 | ||
104 | 151 | return data.get(self.index_name, []) |
105 | 152 | |
106 | 153 | def append_rows(self, rows): |
2 | 2 | import urwid |
3 | 3 | import urwid_utils.palette |
4 | 4 | from ..listbox import ScrollingListBox |
5 | from orderedattrdict import OrderedDict | |
5 | from orderedattrdict import AttrDict | |
6 | 6 | from collections.abc import MutableMapping |
7 | 7 | import itertools |
8 | 8 | import copy |
11 | 11 | from dataclasses import * |
12 | 12 | import typing |
13 | 13 | |
14 | try: | |
15 | import pydantic | |
16 | HAVE_PYDANTIC=True | |
17 | except ImportError: | |
18 | HAVE_PYDANTIC=False | |
19 | ||
20 | try: | |
21 | import pony.orm | |
22 | from pony.orm import db_session | |
23 | HAVE_PONY=True | |
24 | except ImportError: | |
25 | HAVE_PONY=False | |
26 | ||
27 | ||
14 | 28 | from .dataframe import * |
15 | 29 | from .rows import * |
16 | 30 | from .columns import * |
29 | 43 | class DataTable(urwid.WidgetWrap, urwid.listbox.ListWalker): |
30 | 44 | |
31 | 45 | |
32 | signals = ["select", "refresh", "focus", "blur", | |
33 | # "focus", "unfocus", "row_focus", "row_unfocus", | |
46 | signals = ["select", "refresh", "focus", "blur", "end", "requery", | |
34 | 47 | "drag_start", "drag_continue", "drag_stop"] |
35 | 48 | |
36 | 49 | ATTR = "table" |
61 | 74 | |
62 | 75 | detail_fn = None |
63 | 76 | detail_selectable = False |
77 | detail_replace = None | |
78 | detail_auto_open = False | |
64 | 79 | detail_hanging_indent = None |
65 | 80 | |
66 | 81 | ui_sort = True |
67 | 82 | ui_resize = True |
68 | row_attr_fn = None | |
83 | row_attr_fn = lambda self, position, data, row: "" | |
84 | ||
85 | with_sidecar = False | |
69 | 86 | |
70 | 87 | attr_map = {} |
71 | 88 | focus_map = {} |
75 | 92 | highlight_focus_map2 = {} |
76 | 93 | |
77 | 94 | def __init__(self, |
78 | columns = None, | |
79 | data = None, | |
80 | limit = None, | |
81 | index = None, | |
82 | with_header = None, with_footer = None, with_scrollbar = None, | |
83 | empty_message = None, | |
84 | row_height = None, | |
85 | cell_selection = None, | |
86 | sort_by = None, query_sort = None, sort_icons = None, | |
87 | sort_refocus = None, | |
88 | no_load_on_init = None, | |
89 | divider = None, padding = None, | |
90 | row_style = None, | |
91 | detail_fn = None, detail_selectable = None, | |
92 | detail_hanging_indent = None, | |
93 | ui_sort = None, | |
94 | ui_resize = None, | |
95 | row_attr_fn = None): | |
95 | columns=None, | |
96 | data=None, | |
97 | limit=None, | |
98 | index=None, | |
99 | with_header=None, with_footer=None, with_scrollbar=None, | |
100 | empty_message=None, | |
101 | row_height=None, | |
102 | cell_selection=None, | |
103 | sort_by=None, query_sort=None, sort_icons=None, | |
104 | sort_refocus=None, | |
105 | no_load_on_init=None, | |
106 | divider=None, padding=None, | |
107 | row_style=None, | |
108 | detail_fn=None, detail_selectable=None, detail_replace=None, | |
109 | detail_auto_open=None, detail_hanging_indent=None, | |
110 | ui_sort=None, | |
111 | ui_resize=None, | |
112 | row_attr_fn=None, | |
113 | with_sidecar=None): | |
96 | 114 | |
97 | 115 | self._focus = 0 |
98 | 116 | self.page = 0 |
160 | 178 | |
161 | 179 | if detail_fn is not None: self.detail_fn = detail_fn |
162 | 180 | if detail_selectable is not None: self.detail_selectable = detail_selectable |
181 | if detail_replace is not None: self.detail_replace = detail_replace | |
182 | if detail_auto_open is not None: self.detail_auto_open = detail_auto_open | |
163 | 183 | if detail_hanging_indent is not None: self.detail_hanging_indent = detail_hanging_indent |
164 | # self.offset = 0 | |
184 | if detail_hanging_indent is not None: self.detail_hanging_indent = detail_hanging_indent | |
185 | if detail_hanging_indent is not None: self.detail_hanging_indent = detail_hanging_indent | |
186 | if detail_hanging_indent is not None: self.detail_hanging_indent = detail_hanging_indent | |
187 | ||
188 | if with_sidecar is not None: self.with_sidecar = with_sidecar | |
189 | ||
165 | 190 | if limit: |
166 | 191 | self.limit = limit |
167 | 192 | |
170 | 195 | self._height = None |
171 | 196 | self._initialized = False |
172 | 197 | self._message_showing = False |
173 | ||
198 | self.pagination_cursor = None | |
174 | 199 | self.filters = None |
175 | 200 | self.filtered_rows = list() |
176 | 201 | |
178 | 203 | self._columns = list(intersperse_divider(self._columns, self.divider)) |
179 | 204 | # self._columns = intersperse(self.divider, self._columns) |
180 | 205 | |
206 | # FIXME: pass reference | |
181 | 207 | for c in self._columns: |
182 | 208 | c.table = self |
183 | 209 | |
187 | 213 | index_name = self.index or None |
188 | 214 | # sorted=True, |
189 | 215 | ) |
190 | # if self.index: | |
191 | # kwargs["index_name"] = self.index | |
192 | ||
193 | # self.df = DataTableDataFrame(**kwargs) | |
216 | ||
194 | 217 | self.df = DataTableDataFrame( |
195 | 218 | columns = self.column_names, |
196 | 219 | sort=False, |
197 | 220 | index_name = self.index or None |
198 | 221 | ) |
199 | ||
200 | 222 | self.pile = urwid.Pile([]) |
201 | 223 | self.listbox = ScrollingListBox( |
202 | 224 | self, infinite=self.limit, |
203 | 225 | with_scrollbar = self.with_scrollbar, |
204 | 226 | row_count_fn = self.row_count |
205 | 227 | ) |
206 | ||
207 | # urwid.connect_signal( | |
208 | # self.listbox, "select", | |
209 | # lambda source, selection: urwid.signals.emit_signal( | |
210 | # self, "select", self, self.get_dataframe_row(selection.index)) | |
211 | # if self.selection | |
212 | # else None | |
213 | # ) | |
214 | 228 | urwid.connect_signal( |
215 | 229 | self.listbox, "drag_start", |
216 | 230 | lambda source, drag_from: urwid.signals.emit_signal( |
288 | 302 | self.attr = urwid.AttrMap( |
289 | 303 | self.pile, |
290 | 304 | attr_map = self.attr_map, |
291 | # focus_map = self.focus_map | |
292 | 305 | ) |
293 | 306 | super(DataTable, self).__init__(self.attr) |
294 | 307 | |
521 | 534 | return index |
522 | 535 | |
523 | 536 | def set_focus(self, position): |
524 | # logger.debug("walker set_focus: %d" %(position)) | |
537 | # if self._focus == position: | |
538 | # return | |
539 | if self.selection and self.detail_auto_open: | |
540 | # logger.info(f"datatable close details: {self._focus}, {position}") | |
541 | self[self._focus].close_details() | |
525 | 542 | self._emit("blur", self._focus) |
526 | 543 | self._focus = position |
544 | if self.selection and self.detail_auto_open: | |
545 | # logger.info(f"datatable open details: {self._focus}, {position}") | |
546 | self.selection.open_details() | |
527 | 547 | self._emit("focus", position) |
528 | 548 | self._modified() |
529 | 549 | |
540 | 560 | # logger.debug("walker get: %d" %(position)) |
541 | 561 | if isinstance(position, slice): |
542 | 562 | return [self[i] for i in range(*position.indices(len(self)))] |
543 | if position < 0 or position >= len(self.filtered_rows): raise IndexError | |
563 | if position < 0 or position >= len(self.filtered_rows): | |
564 | raise IndexError | |
544 | 565 | try: |
545 | 566 | r = self.get_row_by_position(position) |
546 | 567 | return r |
547 | except IndexError: | |
548 | logger.error(traceback.format_exc()) | |
568 | except IndexError as e: | |
569 | logger.debug(traceback.format_exc()) | |
549 | 570 | raise |
550 | 571 | # logger.debug("row: %s, position: %s, len: %d" %(r, position, len(self))) |
551 | 572 | |
579 | 600 | return getattr(self.df, attr) |
580 | 601 | elif attr in ["body"]: |
581 | 602 | return getattr(self.listbox, attr) |
582 | raise AttributeError(attr) | |
603 | else: | |
604 | return object.__getattribute__(self, attr) | |
605 | ||
606 | # raise AttributeError(attr) | |
583 | 607 | # else: |
584 | 608 | # return object.__getattribute__(self, attr) |
585 | 609 | # elif attr == "body": |
591 | 615 | self._width = size[0] |
592 | 616 | if len(size) > 1: |
593 | 617 | self._height = size[1] |
594 | if not self._initialized and not self.no_load_on_init: | |
618 | ||
619 | # if not self._initialized and not self.no_load_on_init: | |
620 | # self._initialized = True | |
621 | # self._invalidate() | |
622 | # self.reset(reset_sort=True) | |
623 | ||
624 | # if not self._initialized: | |
625 | # self._initialized = True | |
626 | # if not self.no_load_on_init: | |
627 | # self._invalidate() | |
628 | # self.reset(reset_sort=True) | |
629 | ||
630 | if not self._initialized: | |
595 | 631 | self._initialized = True |
596 | 632 | self._invalidate() |
597 | self.reset(reset_sort=True) | |
633 | if not self.no_load_on_init: | |
634 | self.reset(reset_sort=True) | |
635 | ||
598 | 636 | return super().render(size, focus) |
599 | 637 | |
600 | 638 | @property |
613 | 651 | |
614 | 652 | def keypress(self, size, key): |
615 | 653 | key = super().keypress(size, key) |
616 | if key == "enter" and not self.selection.details_focused: | |
654 | if key == "enter" and self.selection and not self.selection.details_focused: | |
617 | 655 | self._emit("select", self.selection.data) |
618 | else: | |
619 | # key = super().keypress(size, key) | |
620 | return key | |
656 | # else: | |
657 | # # key = super().keypress(size, key) | |
658 | return key | |
621 | 659 | # if key == "enter": |
622 | 660 | # self._emit("select", self, self.selection) |
623 | 661 | # else: |
654 | 692 | def position_to_index(self, position): |
655 | 693 | # if not self.query_sort and self.sort_by[1]: |
656 | 694 | # position = -(position + 1) |
657 | return self.df.index[position] | |
658 | ||
695 | try: | |
696 | return self.df.index[position] | |
697 | except IndexError as e: | |
698 | # logger.info(f"position_to_index: {position}, {self.df.index}") | |
699 | raise | |
700 | logger.error(traceback.format_exc()) | |
659 | 701 | def index_to_position(self, index): |
660 | 702 | # raise Exception(index, self.df.index) |
661 | return self.df.index.index(index) | |
703 | # return self.df.index.index(index) | |
704 | return self.filtered_rows.index(index) | |
662 | 705 | |
663 | 706 | def get_dataframe_row(self, index): |
664 | # logger.debug("__getitem__: %s" %(index)) | |
665 | # try: | |
666 | # v = self.df[index:index] | |
667 | # except IndexError: | |
668 | # raise Exception | |
669 | # # logger.debug(traceback.format_exc()) | |
670 | ||
671 | 707 | try: |
672 | d = self.df.get_columns(index, as_dict=True) | |
708 | return self.df.get_columns(index, as_dict=True) | |
673 | 709 | except ValueError as e: |
674 | raise Exception(e, index, self.df) | |
710 | raise Exception(e, index, self.df.head(10)) | |
711 | ||
712 | def get_dataframe_row_object(self, index): | |
713 | ||
714 | d = self.get_dataframe_row(index) | |
675 | 715 | cls = d.get("_cls") |
676 | 716 | if cls: |
677 | if hasattr(cls, "__dataclass_fields__"): | |
717 | if HAVE_PYDANTIC and issubclass(cls, pydantic.main.BaseModel): | |
718 | # import ipdb; ipdb.set_trace() | |
719 | return cls( | |
720 | **{ | |
721 | k: v | |
722 | for k, v in d.items() | |
723 | if v | |
724 | } | |
725 | ) | |
726 | elif hasattr(cls, "__dataclass_fields__"): | |
727 | # Python dataclasses | |
678 | 728 | # klass = type(f"DataTableRow_{cls.__name__}", [cls], |
679 | 729 | klass = make_dataclass( |
680 | 730 | f"DataTableRow_{cls.__name__}", |
688 | 738 | for k in set( |
689 | 739 | cls.__dataclass_fields__.keys()) |
690 | 740 | }) |
691 | ||
692 | 741 | return k |
742 | elif HAVE_PONY and issubclass(cls, pony.orm.core.Entity): | |
743 | keys = { | |
744 | k.name: d.get(k.name, None) | |
745 | for k in (cls._pk_ if isinstance(cls._pk_, tuple) else (cls._pk_,)) | |
746 | } | |
747 | # raise Exception(keys) | |
748 | with db_session: | |
749 | return cls.get(**keys) | |
693 | 750 | else: |
694 | return cls(**d) | |
751 | return AttrDict(**d) | |
695 | 752 | else: |
696 | 753 | return AttrDict(**d) |
697 | # if isinstance(d, MutableMapping): | |
698 | # cls = d.get("_cls") | |
699 | # else: | |
700 | # cls = getattr(d, "_cls") | |
701 | ||
702 | # if cls: | |
703 | # return cls(**d) | |
704 | # else: | |
705 | # return AttrDict(**d) | |
754 | ||
706 | 755 | |
707 | 756 | def get_row(self, index): |
708 | 757 | row = self.df.get(index, "_rendered_row") |
709 | ||
758 | details_open = False | |
710 | 759 | if self.df.get(index, "_dirty") or row is None: |
711 | 760 | self.refresh_calculated_fields([index]) |
712 | 761 | # vals = self[index] |
713 | vals = self.get_dataframe_row(index) | |
762 | ||
763 | pos = self.index_to_position(index) | |
764 | vals = self.get_dataframe_row_object(index) | |
714 | 765 | row = self.render_item(index) |
766 | position = self.index_to_position(index) | |
715 | 767 | if self.row_attr_fn: |
716 | attr = self.row_attr_fn(vals) | |
768 | attr = self.row_attr_fn(position, row.data_source, row) | |
717 | 769 | if attr: |
718 | 770 | row.set_attr(attr) |
719 | 771 | focus = self.df.get(index, "_focus_position") |
720 | 772 | if focus is not None: |
721 | 773 | row.set_focus_column(focus) |
774 | if details_open: | |
775 | row.open_details() | |
722 | 776 | self.df.set(index, "_rendered_row", row) |
723 | 777 | self.df.set(index, "_dirty", False) |
778 | ||
724 | 779 | return row |
725 | 780 | |
726 | 781 | def get_row_by_position(self, position): |
727 | index = self.position_to_index(self.filtered_rows[position]) | |
782 | # index = self.position_to_index(self.filtered_rows[position]) | |
783 | index = self.filtered_rows[position] | |
728 | 784 | return self.get_row(index) |
729 | 785 | |
730 | 786 | def get_value(self, row, column): |
737 | 793 | def selection(self): |
738 | 794 | if len(self.body) and self.focus_position is not None: |
739 | 795 | # FIXME: make helpers to map positions to indexes |
740 | return self[self.focus_position] | |
741 | ||
796 | try: | |
797 | return self[self.focus_position] | |
798 | except IndexError: | |
799 | return None | |
800 | ||
801 | @property | |
802 | def selection_data(self): | |
803 | return AttrDict(self.df.get_columns(self.position_to_index(self.focus_position), as_dict=True)) | |
742 | 804 | |
743 | 805 | def render_item(self, index): |
744 | 806 | row = DataTableBodyRow(self, index, |
759 | 821 | if not col.value_fn: continue |
760 | 822 | for index in indexes: |
761 | 823 | if self.df[index, "_dirty"]: |
762 | self.df.set(index, col.name, col.value_fn(self, self.get_dataframe_row(index))) | |
824 | self.df.set(index, col.name, col.value_fn(self, self.get_dataframe_row_object(index))) | |
763 | 825 | |
764 | 826 | def visible_data_column_index(self, column_name): |
765 | 827 | try: |
766 | 828 | return next(i for i, c in enumerate(self.visible_data_columns) |
767 | 829 | if c.name == column_name) |
768 | 830 | |
769 | except StopIteration: | |
831 | except Exception as e: | |
832 | logger.error(f"column not found in visible_data_column_index: {column_name}") | |
833 | logger.exception(e) | |
770 | 834 | raise IndexError |
771 | 835 | |
772 | 836 | def sort_by_column(self, col=None, reverse=None, toggle=False): |
779 | 843 | |
780 | 844 | elif col is None: |
781 | 845 | col = self.sort_column |
846 | ||
782 | 847 | |
783 | 848 | if isinstance(col, int): |
784 | 849 | try: |
791 | 856 | column_name = col |
792 | 857 | try: |
793 | 858 | column_number = self.visible_data_column_index(column_name) |
859 | column_name = col | |
794 | 860 | except: |
795 | raise | |
861 | ||
862 | column_name = self.initial_sort[0] or self.visible_data_columns[0].name | |
863 | if column_name is None: | |
864 | return | |
865 | column_number = self.visible_data_column_index(column_name) | |
866 | ||
796 | 867 | |
797 | 868 | self.sort_column = column_number |
798 | 869 | |
804 | 875 | except: |
805 | 876 | return # FIXME |
806 | 877 | |
807 | if reverse is None and column.sort_reverse is not None: | |
808 | reverse = column.sort_reverse | |
809 | ||
810 | 878 | if toggle and column_name == self.sort_by[0]: |
811 | 879 | reverse = not self.sort_by[1] |
880 | ||
881 | elif reverse is None and column.sort_reverse is not None: | |
882 | reverse = column.sort_reverse | |
883 | ||
812 | 884 | sort_by = (column_name, reverse) |
813 | 885 | # if not self.query_sort: |
814 | 886 | |
831 | 903 | self.focus_position = self.index_to_position(row_index) |
832 | 904 | |
833 | 905 | def sort(self, column, key=None): |
834 | import functools | |
835 | 906 | logger.debug(column) |
836 | 907 | if not key: |
837 | 908 | key = lambda x: (x is None, x) |
847 | 918 | if not isinstance(c, DataTableDivider) |
848 | 919 | ][index] |
849 | 920 | |
850 | logger.info(f"{index}, {idx}") | |
851 | 921 | if self.with_header: |
852 | 922 | self.header.set_focus_column(idx) |
853 | 923 | |
884 | 954 | |
885 | 955 | self._columns += columns |
886 | 956 | for i, column in enumerate(columns): |
957 | # FIXME: pass reference | |
958 | column.table = self | |
887 | 959 | self.df[column.name] = data=data[i] if data else None |
888 | 960 | |
889 | 961 | self.invalidate() |
903 | 975 | self.invalidate() |
904 | 976 | |
905 | 977 | def set_columns(self, columns): |
978 | # logger.info(self._columns) | |
906 | 979 | self.remove_columns([c.name for c in self._columns]) |
907 | 980 | self.add_columns(columns) |
981 | # logger.info(self._columns) | |
908 | 982 | self.reset() |
909 | 983 | |
910 | 984 | def toggle_columns(self, columns, show=None): |
1131 | 1205 | self.header.update() |
1132 | 1206 | if self.with_footer: |
1133 | 1207 | self.footer.update() |
1134 | self._modified() | |
1208 | self.invalidate() | |
1209 | ||
1210 | # self._modified() | |
1135 | 1211 | |
1136 | 1212 | def invalidate_rows(self, indexes): |
1137 | 1213 | if not isinstance(indexes, list): |
1143 | 1219 | self._modified() |
1144 | 1220 | # FIXME: update header / footer if dynamic |
1145 | 1221 | |
1222 | def invalidate_selection(self): | |
1223 | self.invalidate_rows(self.focus_position) | |
1224 | ||
1146 | 1225 | def swap_rows_by_field(self, p0, p1, field=None): |
1147 | 1226 | |
1148 | 1227 | if not field: |
1170 | 1249 | |
1171 | 1250 | def row_count(self): |
1172 | 1251 | |
1173 | if not self.limit: | |
1174 | return None | |
1252 | # if not self.limit: | |
1253 | # return None | |
1175 | 1254 | |
1176 | 1255 | if self.limit: |
1177 | 1256 | return self.query_result_count() |
1186 | 1265 | filters = [filters] |
1187 | 1266 | |
1188 | 1267 | self.filtered_rows = list( |
1189 | i | |
1268 | row[self.df.index_name] | |
1190 | 1269 | for i, row in enumerate(self.df.iterrows()) |
1191 | 1270 | if not filters or all( |
1192 | 1271 | f(row) |
1222 | 1301 | # logger.debug("load_more") |
1223 | 1302 | if position is not None and position > len(self): |
1224 | 1303 | return False |
1225 | self.page = len(self) // self.limit | |
1304 | self.page += 1 | |
1305 | # self.page = len(self) // self.limit | |
1226 | 1306 | offset = (self.page)*self.limit |
1227 | 1307 | # logger.debug(f"offset: {offset}, row count: {self.row_count()}") |
1228 | if (self.row_count() is not None | |
1229 | and len(self) >= self.row_count()): | |
1230 | ||
1231 | return False | |
1232 | ||
1233 | try: | |
1234 | self.requery(offset=offset) | |
1235 | except Exception as e: | |
1236 | raise Exception(f"{position}, {len(self)}, {self.row_count()}, {offset}, {self.limit}, {e}") | |
1237 | ||
1238 | return True | |
1308 | # if (self.row_count() is not None | |
1309 | # and len(self) >= self.row_count()): | |
1310 | # self._emit("end", self.row_count()) | |
1311 | # return False | |
1312 | ||
1313 | updated = self.requery(offset=offset) | |
1314 | # try: | |
1315 | # updated = self.requery(offset=offset) | |
1316 | # except Exception as e: | |
1317 | # raise Exception(f"{position}, {len(self)}, {self.row_count()}, {offset}, {self.limit}, {str(e)}") | |
1318 | ||
1319 | return updated | |
1239 | 1320 | |
1240 | 1321 | def requery(self, offset=None, limit=None, load_all=False, **kwargs): |
1241 | 1322 | logger.debug(f"requery: {offset}, {limit}") |
1258 | 1339 | kwargs["offset"] = offset |
1259 | 1340 | kwargs["limit"] = limit |
1260 | 1341 | |
1261 | if self.data is not None: | |
1262 | rows = self.data | |
1263 | else: | |
1264 | rows = list(self.query(**kwargs)) | |
1265 | ||
1266 | for row in rows: | |
1267 | row["_cls"] = type(row) | |
1268 | ||
1269 | updated = self.df.update_rows(rows, limit=self.limit) | |
1342 | kwargs["cursor"] = self.pagination_cursor | |
1343 | ||
1344 | rows = list(self.query(**kwargs)) if self.data is None else self.data | |
1345 | ||
1346 | if len(rows) and self.sort_by[0]: | |
1347 | self.pagination_cursor = getattr(rows[-1], self.sort_by[0]) | |
1348 | ||
1349 | updated = self.df.update_rows(rows, replace=self.limit is None, with_sidecar = self.with_sidecar) | |
1350 | ||
1270 | 1351 | self.df["_focus_position"] = self.sort_column |
1271 | 1352 | |
1272 | 1353 | self.refresh_calculated_fields() |
1273 | 1354 | self.apply_filters() |
1355 | if not self.query_sort: | |
1356 | self.sort_by_column(self.initial_sort) | |
1357 | ||
1274 | 1358 | |
1275 | 1359 | if len(updated): |
1276 | 1360 | for i in updated: |
1361 | if not i in self.filtered_rows: | |
1362 | continue | |
1277 | 1363 | pos = self.index_to_position(i) |
1278 | 1364 | self[pos].update() |
1279 | self.sort_by_column(*self.sort_by) | |
1365 | # self.sort_by_column(*self.sort_by) | |
1280 | 1366 | |
1281 | 1367 | self._modified() |
1368 | self._emit("requery", self.row_count()) | |
1282 | 1369 | |
1283 | 1370 | if not len(self) and self.empty_message: |
1284 | 1371 | self.show_message(self.empty_message) |
1285 | 1372 | else: |
1286 | 1373 | self.hide_message() |
1374 | return len(updated) | |
1287 | 1375 | # self.invalidate() |
1288 | 1376 | |
1289 | 1377 | |
1293 | 1381 | idx = None |
1294 | 1382 | pos = 0 |
1295 | 1383 | # limit = len(self)-1 |
1384 | self.df.delete_all_rows() | |
1296 | 1385 | if reset: |
1297 | 1386 | self.page = 0 |
1298 | 1387 | offset = 0 |
1299 | 1388 | limit = self.limit |
1300 | self.df.delete_all_rows() | |
1301 | 1389 | else: |
1302 | 1390 | try: |
1303 | 1391 | idx = getattr(self.selection.data, self.index) |
1304 | except (AttributeError, IndexError): | |
1305 | pass | |
1306 | pos = self.focus_position | |
1392 | pos = self.focus_position | |
1393 | except (AttributeError, IndexError, ValueError): | |
1394 | pos = None | |
1307 | 1395 | limit = len(self) |
1308 | 1396 | # del self[:] |
1309 | 1397 | self.requery(offset=offset, limit=limit) |
1398 | ||
1399 | # self.sort_by_column(self.sort_by[0], key=column.sort_key) | |
1310 | 1400 | if self._initialized: |
1311 | 1401 | self.pack_columns() |
1312 | 1402 | |
1314 | 1404 | try: |
1315 | 1405 | pos = self.index_to_position(idx) |
1316 | 1406 | except: |
1317 | pass | |
1318 | self.focus_position = pos | |
1407 | return | |
1408 | if pos is not None: | |
1409 | self.focus_position = pos | |
1319 | 1410 | |
1320 | 1411 | # self.focus_position = 0 |
1321 | 1412 | |
1330 | 1421 | |
1331 | 1422 | |
1332 | 1423 | def reset(self, reset_sort=False): |
1424 | ||
1425 | self.pagination_cursor = None | |
1333 | 1426 | self.refresh(reset=True) |
1334 | 1427 | |
1335 | 1428 | if reset_sort and self.initial_sort is not None: |
1339 | 1432 | # if r.details_open: |
1340 | 1433 | # r.open_details() |
1341 | 1434 | self._modified() |
1435 | if len(self): | |
1436 | self.set_focus(0) | |
1342 | 1437 | # self._invalidate() |
1343 | 1438 | |
1344 | 1439 | def pack_columns(self): |
1368 | 1463 | available -= w |
1369 | 1464 | |
1370 | 1465 | self.resize_body_rows() |
1466 | ||
1371 | 1467 | |
1372 | 1468 | def show_message(self, message): |
1373 | 1469 | |
1389 | 1485 | self.listbox, |
1390 | 1486 | "center", ("relative", 100), "top", ("relative", 100) |
1391 | 1487 | ) |
1488 | overlay.selectable = lambda: True | |
1392 | 1489 | self.listbox_placeholder.original_widget = overlay |
1393 | 1490 | self._message_showing = True |
1394 | 1491 |
0 | 0 | import functools |
1 | from collections import MutableMapping | |
2 | 1 | import itertools |
3 | 2 | |
4 | 3 | import urwid |
26 | 25 | self.padding = padding |
27 | 26 | self.cell_selection = cell_selection |
28 | 27 | self.style = style |
29 | ||
28 | # self.details = None | |
30 | 29 | self.sort = self.table.sort_by |
31 | 30 | self.attr = self.ATTR |
32 | 31 | self.attr_focused = "%s focused" %(self.attr) |
214 | 213 | def __init__(self, row, content, indent=None): |
215 | 214 | |
216 | 215 | self.row = row |
217 | ||
216 | self.contents = content | |
218 | 217 | self.columns = urwid.Columns([ |
219 | 218 | ("weight", 1, content) |
220 | 219 | ]) |
237 | 236 | |
238 | 237 | DIVIDER_CLASS = DataTableDividerBodyCell |
239 | 238 | |
239 | ||
240 | 240 | @property |
241 | 241 | def index(self): |
242 | 242 | return self.content |
243 | 243 | |
244 | 244 | @property |
245 | 245 | def data(self): |
246 | return self.table.get_dataframe_row(self.index) | |
246 | return AttrDict(self.table.get_dataframe_row(self.index)) | |
247 | ||
248 | @property | |
249 | def data_source(self): | |
250 | return self.table.get_dataframe_row_object(self.index) | |
247 | 251 | |
248 | 252 | def __getitem__(self, column): |
249 | 253 | cls = self.table.df[self.index, "_cls"] |
250 | 254 | # row = self.data |
251 | if ( | |
252 | column not in self.table.df.columns | |
253 | and | |
254 | hasattr(cls, "__dataclass_fields__") | |
255 | and | |
256 | type(getattr(cls, column, None)) == property): | |
257 | # logger.info(f"__getitem__ property: {column}={getattr(self.data, column)}") | |
258 | return getattr(self.data, column) | |
255 | if column in self.table.df.columns: | |
256 | # logger.info(f"__getitem__: {column}={self.table.df.get(self.index, column)}") | |
257 | return self.table.df[self.index, column] | |
259 | 258 | else: |
260 | if column in self.table.df.columns: | |
261 | # logger.info(f"__getitem__: {column}={self.table.df.get(self.index, column)}") | |
262 | return self.table.df[self.index, column] | |
263 | else: | |
264 | raise Exception(column, self.table.df.columns) | |
259 | raise KeyError | |
260 | # raise Exception(column, self.table.df.columns) | |
265 | 261 | |
266 | 262 | |
267 | 263 | def __setitem__(self, column, value): |
269 | 265 | # logger.info(f"__setitem__: {column}, {value}, {self.table.df[self.index, column]}") |
270 | 266 | |
271 | 267 | def get(self, key, default=None): |
272 | ||
273 | 268 | try: |
274 | 269 | return self[key] |
275 | 270 | except KeyError: |
278 | 273 | @property |
279 | 274 | def details_open(self): |
280 | 275 | # logger.info(f"{self['_details']}") |
281 | return (self.get("_details") or {}).get("open") | |
276 | # raise Exception(self.get([self.index, "_details"], {})) | |
277 | return self.get("_details", {}).get("open", False) | |
282 | 278 | |
283 | 279 | @details_open.setter |
284 | 280 | def details_open(self, value): |
288 | 284 | |
289 | 285 | @property |
290 | 286 | def details_disabled(self): |
291 | return (self.get("_details") or {}).get("disabled") | |
287 | return (not self.table.detail_selectable) or self.get([self.index, "_details"], {}).get("disabled", False) | |
292 | 288 | |
293 | 289 | @details_disabled.setter |
294 | 290 | def details_disabled(self, value): |
300 | 296 | |
301 | 297 | @property |
302 | 298 | def details_focused(self): |
303 | return self.details_open and (self.pile.focus_position > 0) | |
299 | return self.details_open and ( | |
300 | len(self.pile.contents) == 0 | |
301 | or self.pile.focus_position > 0 | |
302 | ) | |
304 | 303 | |
305 | 304 | @details_focused.setter |
306 | 305 | def details_focused(self, value): |
307 | 306 | if value: |
308 | self.pile.focus_position = 1 | |
307 | self.pile.focus_position = len(self.pile.contents)-1 | |
309 | 308 | else: |
310 | 309 | self.pile.focus_position = 0 |
311 | 310 | |
311 | @property | |
312 | def details(self): | |
313 | if not getattr(self, "_details", None): | |
314 | ||
315 | content = self.table.detail_fn((self.data_source)) | |
316 | logger.debug(f"open_details: {type(content)}") | |
317 | if not content: | |
318 | return | |
319 | ||
320 | # self.table.header.render( (self.table.width,) ) | |
321 | indent_width = 0 | |
322 | visible_count = itertools.count() | |
323 | ||
324 | def should_indent(x): | |
325 | if (isinstance(self.table.detail_hanging_indent, int) | |
326 | and (x[2] is None or x[2] <= self.table.detail_hanging_indent)): | |
327 | return True | |
328 | elif (isinstance(self.table.detail_hanging_indent, str) | |
329 | and x[1].name != self.table.detail_hanging_indent): | |
330 | return True | |
331 | return False | |
332 | ||
333 | if self.table.detail_hanging_indent: | |
334 | indent_width = sum([ | |
335 | x[1].width if not x[1].hide else 0 | |
336 | for x in itertools.takewhile( | |
337 | should_indent, | |
338 | [ (i, c, next(visible_count) if not c.hide else None) | |
339 | for i, c in enumerate(self.table._columns) ] | |
340 | ) | |
341 | ]) | |
342 | ||
343 | self._details = DataTableDetails(self, content, indent_width) | |
344 | return self._details | |
345 | ||
346 | ||
312 | 347 | def open_details(self): |
313 | 348 | |
314 | if not self.table.detail_fn or len(self.pile.contents) > 1: | |
349 | if not self.table.detail_fn or not self.details or self.details_open: | |
315 | 350 | return |
316 | content = self.table.detail_fn(self.data) | |
317 | ||
318 | self.table.header.render( (self.table.width,) ) | |
319 | indent_width = 0 | |
320 | visible_count = itertools.count() | |
321 | ||
322 | def should_indent(x): | |
323 | if (isinstance(self.table.detail_hanging_indent, int) | |
324 | and (x[2] is None or x[2] <= self.table.detail_hanging_indent)): | |
325 | return True | |
326 | elif (isinstance(self.table.detail_hanging_indent, str) | |
327 | and x[1].name != self.table.detail_hanging_indent): | |
328 | return True | |
329 | return False | |
330 | ||
331 | if self.table.detail_hanging_indent: | |
332 | indent_width = sum([ | |
333 | x[1].width if not x[1].hide else 0 | |
334 | for x in itertools.takewhile( | |
335 | should_indent, | |
336 | [ (i, c, next(visible_count) if not c.hide else None) | |
337 | for i, c in enumerate(self.table._columns) ] | |
338 | ) | |
339 | ]) | |
340 | ||
341 | self.details = DataTableDetails(self, content, indent_width) | |
351 | ||
352 | if len(self.pile.contents) > 1: | |
353 | return | |
354 | ||
355 | if self.table.detail_replace: | |
356 | self.pile.contents[0] = (urwid.Filler(urwid.Text("")), self.pile.options("given", 0)) | |
357 | ||
342 | 358 | self.pile.contents.append( |
343 | 359 | (self.details, self.pile.options("pack")) |
344 | 360 | ) |
361 | ||
362 | self.details_focused = True | |
363 | if not self["_details"]: | |
364 | self["_details"] = AttrDict() | |
345 | 365 | self["_details"]["open"] = True |
346 | 366 | |
347 | 367 | |
348 | 368 | def close_details(self): |
349 | 369 | if not self.table.detail_fn or not self.details_open: |
350 | 370 | return |
371 | # raise Exception | |
351 | 372 | self["_details"]["open"] = False |
352 | # del self.contents.contents[0] | |
353 | ||
354 | # self.box.height -= self.pile.contents[1][0].rows( (self.table.width,) ) | |
355 | del self.pile.contents[1] | |
373 | ||
374 | if self.table.detail_replace: | |
375 | self.pile.contents[0] = (self.box, self.pile.options("pack")) | |
376 | ||
377 | # del self.pile.contents[:] | |
378 | # self.pile.contents.append( | |
379 | # (self.box, self.pile.options("pack")) | |
380 | # ) | |
381 | if len(self.pile.contents) >= 2: | |
382 | del self.pile.contents[1] | |
356 | 383 | |
357 | 384 | def toggle_details(self): |
358 | 385 | |
360 | 387 | self.close_details() |
361 | 388 | else: |
362 | 389 | self.open_details() |
363 | ||
364 | # def enable_details(self): | |
365 | # self["_details"]["disabled"] = False | |
366 | ||
367 | # def disable_details(self): | |
368 | # self["_details"]["disabled"] = True | |
369 | ||
370 | # def focus_details(self): | |
371 | # self.pile.focus_position = 1 | |
372 | ||
373 | # def unfocus_details(self): | |
374 | # self.pile.focus_position = 0 | |
375 | 390 | |
376 | 391 | |
377 | 392 | def set_attr(self, attr): |
0 | import logging | |
1 | logger = logging.getLogger(__name__) | |
2 | ||
0 | 3 | import urwid |
4 | import asyncio | |
1 | 5 | |
2 | 6 | class PopUpMixin(object): |
3 | 7 | |
37 | 41 | super(PopUpOverlay,self).__init__(*args, **kwargs) |
38 | 42 | |
39 | 43 | def keypress(self, size, key): |
44 | key = super().keypress(size, key) | |
40 | 45 | if key in [ "esc", "q" ]: |
41 | 46 | self.parent.close_popup() |
42 | 47 | else: |
43 | return super(PopUpOverlay, self).keypress(size, key) | |
48 | return key | |
44 | 49 | |
45 | 50 | class BasePopUp(urwid.WidgetWrap): |
46 | 51 | |
49 | 54 | def selectable(self): |
50 | 55 | return True |
51 | 56 | |
52 | class BaseDialog(BasePopUp): | |
57 | class ChoiceDialog(BasePopUp): | |
53 | 58 | |
54 | 59 | choices = [] |
55 | 60 | signals = ["select"] |
58 | 63 | self.parent = parent |
59 | 64 | if prompt: self.prompt = prompt |
60 | 65 | self.text = urwid.Text( |
61 | self.prompt + "[%s]" %("".join(list(self.choices.keys()))), align="center" | |
62 | ) | |
63 | super(BaseDialog, self).__init__( | |
66 | self.prompt + " [%s]" %("".join(list(self.choices.keys()))), align="center" | |
67 | ) | |
68 | super(ChoiceDialog, self).__init__( | |
64 | 69 | urwid.Filler(urwid.Padding(self.text)) |
65 | 70 | ) |
66 | 71 | |
67 | # def selectable(self): | |
68 | # return True | |
72 | @property | |
73 | def choices(self): | |
74 | raise NotImplementedError | |
69 | 75 | |
70 | 76 | def keypress(self, size, key): |
71 | 77 | if key in list(self.choices.keys()): |
72 | 78 | self.choices[key]() |
79 | self._emit("select", key) | |
73 | 80 | else: |
74 | 81 | return key |
82 | ||
83 | ||
84 | class SquareButton(urwid.Button): | |
85 | ||
86 | button_left = urwid.Text("[") | |
87 | button_right = urwid.Text("]") | |
88 | ||
89 | def pack(self, size, focus=False): | |
90 | cols = sum( | |
91 | [ w.pack()[0] for w in [ | |
92 | self.button_left, | |
93 | self._label, | |
94 | self.button_right | |
95 | ]]) + self._w.dividechars*2 | |
96 | ||
97 | return ( cols, ) | |
98 | ||
99 | class OKCancelDialog(BasePopUp): | |
100 | ||
101 | def __init__(self, parent, *args, **kwargs): | |
102 | ||
103 | self.parent = parent | |
104 | ||
105 | self.ok_button = SquareButton(("bold", "OK")) | |
106 | ||
107 | urwid.connect_signal( | |
108 | self.ok_button, "click", | |
109 | lambda s: self.confirm() | |
110 | ) | |
111 | ||
112 | self.cancel_button = SquareButton(("bold", "Cancel")) | |
113 | ||
114 | urwid.connect_signal( | |
115 | self.cancel_button, "click", | |
116 | lambda s: self.cancel() | |
117 | ) | |
118 | ||
119 | ||
120 | self.body = urwid.Pile([]) | |
121 | for name, widget in self.widgets.items(): | |
122 | setattr(self, name, widget) | |
123 | self.body.contents.append( | |
124 | (widget, self.body.options("weight", 1)) | |
125 | ) | |
126 | ||
127 | self.pile = urwid.Pile( | |
128 | [ | |
129 | (2, urwid.Filler(urwid.Padding(self.body), valign="top")), | |
130 | ("weight", 1, urwid.Padding( | |
131 | urwid.Columns([ | |
132 | ("weight", 1, | |
133 | urwid.Padding( | |
134 | self.ok_button, align="center", width=12) | |
135 | ), | |
136 | ("weight", 1, | |
137 | urwid.Padding( | |
138 | self.cancel_button, align="center", width=12) | |
139 | ) | |
140 | ]), | |
141 | align="center" | |
142 | )), | |
143 | ] | |
144 | ) | |
145 | self.body_position = 0 | |
146 | if self.title: | |
147 | self.pile.contents.insert( | |
148 | 0, | |
149 | (urwid.Filler( | |
150 | urwid.AttrMap( | |
151 | urwid.Padding( | |
152 | urwid.Text(self.title) | |
153 | ), | |
154 | "header" | |
155 | ) | |
156 | ), self.pile.options("given", 2)) | |
157 | ) | |
158 | self.body_position += 1 | |
159 | ||
160 | self.pile.selectable = lambda: True | |
161 | self.pile.focus_position = self.body_position | |
162 | super(OKCancelDialog, self).__init__( | |
163 | urwid.Filler(self.pile, valign="top") | |
164 | ) | |
165 | ||
166 | @property | |
167 | def title(self): | |
168 | return None | |
169 | ||
170 | @property | |
171 | def widgets(self): | |
172 | raise RuntimeError("must set widgets property") | |
173 | ||
174 | def action(self): | |
175 | raise RuntimeError("must override action method") | |
176 | ||
177 | @property | |
178 | def ok_focus_path(self): | |
179 | return [self.body_position+1,0] | |
180 | ||
181 | @property | |
182 | def cancel_focus_path(self): | |
183 | return [self.body_position+1,1] | |
184 | ||
185 | @property | |
186 | def focus_paths(self): | |
187 | return [ | |
188 | [self.body_position, i] | |
189 | for i in range(len(self.body.contents)) | |
190 | ] + [ | |
191 | self.ok_focus_path, | |
192 | self.cancel_focus_path | |
193 | ] | |
194 | ||
195 | def cycle_focus(self, step): | |
196 | path = self.pile.get_focus_path()[:2] | |
197 | logger.info(f"{path}, {self.focus_paths}") | |
198 | self.pile.set_focus_path( | |
199 | self.focus_paths[ | |
200 | (self.focus_paths.index(path) + step) % len(self.focus_paths) | |
201 | ] | |
202 | ) | |
203 | ||
204 | def confirm(self): | |
205 | rv = self.action() | |
206 | if asyncio.iscoroutine(rv): | |
207 | asyncio.get_event_loop().create_task(rv) | |
208 | ||
209 | self.close() | |
210 | ||
211 | def cancel(self): | |
212 | self.close() | |
213 | ||
214 | def close(self): | |
215 | self._emit("close_popup") | |
216 | ||
217 | def selectable(self): | |
218 | return True | |
219 | ||
220 | def keypress(self, size, key): | |
221 | if key in ["tab", "shift tab"]: | |
222 | self.cycle_focus(1 if key == "tab" else -1) | |
223 | return | |
224 | else: | |
225 | key = super().keypress(size, key) | |
226 | if key == "enter": | |
227 | self.confirm() | |
228 | else: | |
229 | return key | |
230 | ||
231 | ||
232 | ||
233 | class ConfirmDialog(ChoiceDialog): | |
234 | ||
235 | def __init__(self, parent, *args, **kwargs): | |
236 | super(ConfirmDialog, self).__init__(parent, *args, **kwargs) | |
237 | ||
238 | def action(self, value): | |
239 | raise RuntimeError("must override action method") | |
240 | ||
241 | @property | |
242 | def prompt(self): | |
243 | return "Are you sure?" | |
244 | ||
245 | def confirm(self): | |
246 | self.action() | |
247 | self.close() | |
248 | ||
249 | def cancel(self): | |
250 | self.close() | |
251 | ||
252 | def close(self): | |
253 | self.parent.close_popup() | |
254 | ||
255 | @property | |
256 | def choices(self): | |
257 | return { | |
258 | "y": self.confirm, | |
259 | "n": self.cancel | |
260 | } | |
75 | 261 | |
76 | 262 | class BaseView(urwid.WidgetWrap): |
77 | 263 | |
107 | 293 | __all__ = [ |
108 | 294 | "BaseView", |
109 | 295 | "BasePopUp", |
110 | "BaseDialog", | |
296 | "ChoiceDialog", | |
111 | 297 | ] |
12 | 12 | # import urwid_readline |
13 | 13 | from orderedattrdict import AttrDict |
14 | 14 | |
15 | from .datatable import * | |
15 | # from .datatable import * | |
16 | from .listbox import ScrollingListBox | |
16 | 17 | from .keymap import * |
17 | from .listbox import ScrollingListBox | |
18 | from .highlightable import HighlightableTextMixin | |
19 | from .autocomplete import AutoCompleteMixin | |
18 | 20 | |
19 | 21 | class DropdownButton(urwid.Button): |
22 | ||
23 | text_attr = "dropdown_text" | |
20 | 24 | |
21 | 25 | left_chars = u"" |
22 | 26 | right_chars = u"" |
23 | 27 | |
24 | def __init__(self, label, left_chars=None, right_chars=None): | |
28 | ||
29 | def __init__( | |
30 | self, label, | |
31 | text_attr=None, | |
32 | left_chars=None, right_chars=None | |
33 | ): | |
25 | 34 | |
26 | 35 | self.label_text = label |
36 | if text_attr: | |
37 | self.text_attr = text_attr | |
27 | 38 | if left_chars: |
28 | 39 | self.left_chars = left_chars |
29 | 40 | if right_chars: |
38 | 49 | ('weight', 1, self._label), |
39 | 50 | (len(self.right_chars), self.button_right) |
40 | 51 | ], dividechars=0) |
41 | self.set_label(("dropdown_text", self.label_text)) | |
52 | self.set_label((self.text_attr, self.label_text)) | |
42 | 53 | super(urwid.Button, self).__init__(self.cols) |
43 | 54 | |
44 | 55 | @property |
50 | 61 | return self.decoration_width + len(self.label_text) |
51 | 62 | |
52 | 63 | |
53 | class DropdownItem(urwid.WidgetWrap): | |
64 | class DropdownItem(HighlightableTextMixin, urwid.WidgetWrap): | |
54 | 65 | |
55 | 66 | signals = ["click"] |
56 | 67 | |
68 | text_attr = "dropdown_text" | |
69 | highlight_attr = "dropdown_highlight" | |
70 | focused_attr = "dropdown_focused" | |
71 | ||
57 | 72 | def __init__(self, label, value, |
58 | margin=0, left_chars=None, right_chars=None): | |
73 | margin=0, | |
74 | text_attr=None, | |
75 | focused_attr=None, | |
76 | highlight_attr=None, | |
77 | left_chars=None, right_chars=None): | |
59 | 78 | |
60 | 79 | self.label_text = label |
61 | 80 | self.value = value |
62 | 81 | self.margin = margin |
63 | # self.button = urwid.Button(("dropdown_text", self.label_text)) | |
82 | if text_attr: | |
83 | self.text_attr = text_attr | |
84 | if focused_attr: | |
85 | self.focused_attr = focused_attr | |
86 | if highlight_attr: | |
87 | self.highlight_attr = highlight_attr | |
64 | 88 | self.button = DropdownButton( |
65 | 89 | self.label_text, |
90 | text_attr=self.text_attr, | |
66 | 91 | left_chars=left_chars, right_chars=right_chars |
67 | 92 | ) |
93 | ||
68 | 94 | self.padding = urwid.Padding(self.button, width=("relative", 100), |
69 | 95 | left=self.margin, right=self.margin) |
70 | # self.padding = self.button | |
71 | ||
72 | ||
73 | self.attr = urwid.AttrMap(self.padding, {None: "dropdown_text"}) | |
96 | ||
97 | ||
98 | self.attr = urwid.AttrMap(self.padding, {None: self.text_attr}) | |
74 | 99 | self.attr.set_focus_map({ |
75 | None: "dropdown_focused", | |
76 | "dropdown_text": "dropdown_focused" | |
100 | None: self.focused_attr, | |
101 | self.text_attr: self.focused_attr | |
77 | 102 | }) |
78 | 103 | super(DropdownItem, self).__init__(self.attr) |
79 | 104 | urwid.connect_signal( |
83 | 108 | ) |
84 | 109 | |
85 | 110 | @property |
111 | def highlight_source(self): | |
112 | return self.label_text | |
113 | ||
114 | @property | |
115 | def highlightable_attr_normal(self): | |
116 | return self.text_attr | |
117 | ||
118 | @property | |
119 | def highlightable_attr_highlight(self): | |
120 | return self.highlight_attr | |
121 | ||
122 | def on_highlight(self): | |
123 | self.set_text(self.highlight_content) | |
124 | ||
125 | def on_unhighlight(self): | |
126 | self.set_text(self.highlight_source) | |
127 | ||
128 | @property | |
86 | 129 | def width(self): |
87 | 130 | return self.button.width + 2*self.margin |
88 | 131 | |
103 | 146 | def label(self): |
104 | 147 | return self.button.label |
105 | 148 | |
106 | def set_label(self, label): | |
107 | logger.debug("set_label: " + repr(label) ) | |
108 | self.button.set_label(label) | |
109 | ||
110 | def highlight_text(self, s, case_sensitive=False): | |
111 | ||
112 | (a, b, c) = re.search( | |
113 | r"(.*?)(%s)(.*)" %(s), | |
114 | self.label_text, | |
115 | re.IGNORECASE if not case_sensitive else 0 | |
116 | ).groups() | |
117 | ||
118 | self.set_label([ | |
119 | ("dropdown_text", a), | |
120 | ("dropdown_highlight", b), | |
121 | ("dropdown_text", c), | |
122 | ]) | |
123 | ||
124 | def unhighlight(self): | |
125 | self.set_label(("dropdown_text", self.label_text)) | |
126 | ||
127 | ||
128 | # class AutoCompleteEdit(urwid_readline.ReadlineEdit): | |
149 | def set_text(self, text): | |
150 | self.button.set_label(text) | |
151 | ||
129 | 152 | @keymapped() |
130 | class AutoCompleteEdit(urwid.Edit): | |
131 | ||
132 | signals = ["close"] | |
133 | ||
134 | @keymap_command() | |
135 | def clear(self): | |
136 | raise Exception | |
137 | self.set_edit_text("") | |
138 | ||
139 | def keypress(self, size, key): | |
140 | if key == "enter": | |
141 | self._emit("close") | |
142 | return super(AutoCompleteEdit, self).keypress(size, key) | |
143 | ||
144 | class AutoCompleteBar(urwid.WidgetWrap): | |
145 | ||
146 | signals = ["change", "close"] | |
147 | def __init__(self): | |
148 | ||
149 | self.prompt = urwid.Text(("dropdown_prompt", "> ")) | |
150 | self.text = AutoCompleteEdit("") | |
151 | # self.text.selectable = lambda x: False | |
152 | self.cols = urwid.Columns([ | |
153 | (2, self.prompt), | |
154 | ("weight", 1, self.text) | |
155 | ], dividechars=0) | |
156 | self.cols.focus_position = 1 | |
157 | self.filler = urwid.Filler(self.cols, valign="bottom") | |
158 | urwid.connect_signal(self.text, "postchange", self.text_changed) | |
159 | urwid.connect_signal(self.text, "close", lambda source: self._emit("close")) | |
160 | super(AutoCompleteBar, self).__init__(self.filler) | |
161 | ||
162 | def set_prompt(self, text): | |
163 | ||
164 | self.prompt.set_text(("dropdown_prompt", text)) | |
165 | ||
166 | def set_text(self, text): | |
167 | ||
168 | self.text.set_edit_text(text) | |
169 | ||
170 | def text_changed(self, source, text): | |
171 | self._emit("change", text) | |
172 | ||
173 | ||
174 | @keymapped() | |
175 | class DropdownDialog(urwid.WidgetWrap, KeymapMovementMixin): | |
153 | class DropdownDialog(AutoCompleteMixin, urwid.WidgetWrap, KeymapMovementMixin): | |
176 | 154 | |
177 | 155 | signals = ["select", "close"] |
156 | ||
157 | text_attr = "dropdown_text" | |
178 | 158 | |
179 | 159 | min_width = 4 |
180 | 160 | |
181 | 161 | label = None |
182 | 162 | border = None |
183 | 163 | scrollbar = False |
184 | auto_complete = False | |
185 | 164 | margin = 0 |
186 | 165 | max_height = None |
187 | 166 | |
194 | 173 | border=False, |
195 | 174 | margin = None, |
196 | 175 | scrollbar=None, |
197 | auto_complete=None, | |
176 | text_attr=None, | |
177 | focused_attr=None, | |
178 | prompt_attr=None, | |
198 | 179 | left_chars=None, |
199 | 180 | right_chars=None, |
200 | 181 | left_chars_top=None, |
201 | 182 | rigth_chars_top=None, |
202 | 183 | max_height=None, |
203 | keymap = {} | |
184 | keymap = {}, | |
185 | **kwargs | |
204 | 186 | ): |
187 | ||
205 | 188 | self.drop_down = drop_down |
206 | 189 | self.items = items |
207 | 190 | if label is not None: self.label = label |
208 | 191 | if border is not None: self.border = border |
209 | 192 | if margin is not None: self.margin = margin |
210 | 193 | if scrollbar is not None: self.scrollbar = scrollbar |
211 | if auto_complete is not None: self.auto_complete = auto_complete | |
194 | if text_attr: | |
195 | self.text_attr = text_attr | |
196 | if focused_attr: | |
197 | self.focused_attr = focused_attr | |
198 | if prompt_attr: | |
199 | self.prompt_attr = prompt_attr | |
212 | 200 | if max_height is not None: self.max_height = max_height |
213 | ||
214 | # self.KEYMAP = keymap | |
215 | ||
216 | self.completing = False | |
217 | self.complete_anywhere = False | |
218 | self.last_complete_index = None | |
219 | self.last_filter_text = None | |
220 | ||
221 | 201 | self.selected_button = 0 |
222 | 202 | buttons = [] |
223 | 203 | |
224 | 204 | buttons = [ |
225 | 205 | DropdownItem( |
226 | 206 | label=l, value=v, margin=self.margin, |
207 | text_attr=self.text_attr, | |
208 | focused_attr=self.focused_attr, | |
227 | 209 | left_chars=left_chars, |
228 | 210 | right_chars=right_chars, |
229 | 211 | ) |
236 | 218 | urwid.connect_signal( |
237 | 219 | self.dropdown_buttons, |
238 | 220 | 'select', |
239 | lambda source, selection: self.select_button(selection) | |
240 | ) | |
241 | ||
242 | box_height = self.height -2 if self.border else self.height | |
243 | self.box = urwid.BoxAdapter(self.dropdown_buttons, box_height) | |
244 | self.fill = urwid.Filler(self.box) | |
221 | lambda source, selection: self.on_complete_select(source) | |
222 | ) | |
223 | ||
245 | 224 | kwargs = {} |
246 | 225 | if self.label is not None: |
247 | 226 | kwargs["title"] = self.label |
248 | 227 | kwargs["tlcorner"] = u"\N{BOX DRAWINGS LIGHT DOWN AND HORIZONTAL}" |
249 | 228 | kwargs["trcorner"] = u"\N{BOX DRAWINGS LIGHT DOWN AND LEFT}" |
250 | 229 | |
251 | w = self.fill | |
230 | w = self.dropdown_buttons | |
252 | 231 | if self.border: |
253 | 232 | w = urwid.LineBox(w, **kwargs) |
254 | ||
255 | if self.auto_complete: | |
256 | self.auto_complete_bar = AutoCompleteBar() | |
257 | ||
258 | urwid.connect_signal(self.auto_complete_bar, | |
259 | "change", | |
260 | lambda source, text: self.complete()) | |
261 | ||
262 | urwid.connect_signal(self.auto_complete_bar, | |
263 | "close", | |
264 | lambda source: self.complete_off()) | |
265 | 233 | |
266 | 234 | self.pile = urwid.Pile([ |
267 | 235 | ("weight", 1, w), |
268 | 236 | ]) |
269 | self.__super.__init__(self.pile) | |
270 | ||
271 | ||
272 | @property | |
273 | def KEYMAP(self): | |
274 | return self.drop_down.KEYMAP | |
275 | ||
276 | @property | |
277 | def filter_text(self): | |
278 | return self.auto_complete_bar.text.get_text()[0] | |
279 | ||
280 | @filter_text.setter | |
281 | def filter_text(self, value): | |
282 | return self.auto_complete_bar.set_text(value) | |
237 | super().__init__(self.pile) | |
238 | ||
239 | @property | |
240 | def complete_container(self): | |
241 | return self.pile | |
242 | ||
243 | @property | |
244 | def complete_container_pos(self): | |
245 | return 1 | |
246 | ||
247 | @property | |
248 | def complete_body(self): | |
249 | return self.body | |
250 | ||
251 | @property | |
252 | def complete_items(self): | |
253 | return self.body | |
283 | 254 | |
284 | 255 | @property |
285 | 256 | def max_item_width(self): |
324 | 295 | def selection(self): |
325 | 296 | return self.dropdown_buttons.selection |
326 | 297 | |
327 | def select_button(self, button): | |
328 | ||
329 | # logger.debug("select_button: %s" %(button)) | |
330 | label = button.label | |
331 | value = button.value | |
332 | self.selected_button = self.focus_position | |
333 | self.complete_off() | |
334 | self._emit("select", button) | |
335 | self._emit("close") | |
298 | # def on_complete_select(self, pos, widget): | |
299 | ||
300 | # # logger.debug("select_button: %s" %(button)) | |
301 | # label = widget.label | |
302 | # value = widget.value | |
303 | # self.selected_button = self.focus_position | |
304 | # self.complete_off() | |
305 | # self._emit("select", widget) | |
306 | # self._emit("close") | |
336 | 307 | |
337 | 308 | # def keypress(self, size, key): |
338 | ||
339 | # raise Exception | |
340 | # logger.debug("DropdownDialog.keypress: %s" %(key)) | |
341 | # if self.completing: | |
342 | # if key in ["enter", "up", "down"]: | |
343 | # self.complete_off() | |
344 | # else: | |
345 | # return key | |
346 | # else: | |
347 | # return super(DropdownDialog, self).keypress(size, key) | |
309 | # return super(DropdownDialog, self).keypress(size, key) | |
348 | 310 | |
349 | 311 | |
350 | 312 | @property |
353 | 315 | return None |
354 | 316 | return self.body[self.focus_position].value |
355 | 317 | |
356 | @keymap_command() | |
357 | def complete_prefix(self): | |
358 | self.complete_on() | |
359 | ||
360 | @keymap_command() | |
361 | def complete_substring(self): | |
362 | self.complete_on(anywhere=True) | |
363 | ||
364 | def complete_on(self, anywhere=False, case_sensitive=False): | |
365 | ||
366 | if self.completing: | |
367 | return | |
368 | self.completing = True | |
369 | # self.auto_complete_bar.set_prompt("> ") | |
370 | # self.pile.focus_position = 1 | |
371 | self.show_bar() | |
372 | if anywhere: | |
373 | self.complete_anywhere = True | |
374 | else: | |
375 | self.complete_anywhere = False | |
376 | ||
377 | ||
378 | @keymap_command() | |
379 | def complete_off(self): | |
380 | ||
381 | if not self.completing: | |
382 | return | |
383 | self.filter_text = "" | |
384 | ||
385 | self.hide_bar() | |
386 | self.completing = False | |
387 | ||
388 | @keymap_command | |
389 | def complete(self, step=None, no_wrap=False, case_sensitive=False): | |
390 | ||
391 | if not self.filter_text: | |
392 | return | |
393 | ||
394 | if not step and self.filter_text == self.last_filter_text: | |
395 | return | |
396 | ||
397 | logger.info("complete") | |
398 | ||
399 | if self.last_complete_index: | |
400 | self[self.last_complete_index].unhighlight() | |
401 | ||
402 | start=0 | |
403 | ||
404 | if step: | |
405 | if step > 0: | |
406 | start = (self.last_complete_index) % len(self) + 1 | |
407 | else: | |
408 | start = len(self) - self.last_complete_index | |
409 | ||
410 | if no_wrap: | |
411 | end = len(self) | |
412 | else: | |
413 | end = start+len(self) | |
414 | ||
415 | ||
416 | if case_sensitive: | |
417 | g = lambda x: x | |
418 | else: | |
419 | g = lambda x: str(x).lower() | |
420 | ||
421 | if self.complete_anywhere: | |
422 | f = lambda x: g(self.filter_text) in g(x) | |
423 | else: | |
424 | f = lambda x: g(x).startswith(g(self.filter_text)) | |
425 | ||
426 | cycle = itertools.cycle( | |
427 | enumerate(self.body) | |
428 | if step is None or step > 0 | |
429 | else reversed(list(enumerate(self.body))) | |
430 | ) | |
431 | rows = list(itertools.islice(cycle, start, end)) | |
432 | logger.info(f"{start}, {end}, len: {len(rows)}") | |
433 | for i, w in rows: | |
434 | logger.info(i) | |
435 | if f(w): | |
436 | self.last_complete_index = i | |
437 | self[i].highlight_text(self.filter_text) | |
438 | self.focus_position = i | |
439 | break | |
440 | else: | |
441 | self.filter_text = self.last_filter_text | |
442 | if self.last_complete_index: | |
443 | self[self.last_complete_index].highlight_text(self.filter_text) | |
444 | # self.last_complete_index = None | |
445 | ||
446 | self.last_filter_text = self.filter_text | |
447 | ||
448 | @keymap_command() | |
449 | def cancel(self): | |
450 | logger.debug("cancel") | |
451 | self.focus_position = self.selected_button | |
452 | self.close() | |
453 | ||
454 | def close(self): | |
455 | self._emit("close") | |
456 | ||
457 | def show_bar(self): | |
458 | self.pile.contents.append( | |
459 | (self.auto_complete_bar, self.pile.options("given", 1)) | |
460 | ) | |
461 | self.box.height -= 1 | |
462 | self.pile.focus_position = 1 | |
463 | ||
464 | def hide_bar(self): | |
465 | self[self.focus_position].unhighlight() | |
466 | self.pile.focus_position = 0 | |
467 | del self.pile.contents[1] | |
468 | self.box.height += 1 | |
469 | ||
470 | 318 | @keymapped() |
471 | 319 | class Dropdown(urwid.PopUpLauncher): |
472 | 320 | # Based in part on SelectOne widget from |
474 | 322 | |
475 | 323 | signals = ["change"] |
476 | 324 | |
325 | text_attr = "dropdown_text" | |
326 | label_attr = "dropdown_label" | |
327 | focused_attr = "dropdown_focused" | |
328 | highlight_attr = "dropdown_highlight" | |
329 | prompt_attr = "dropdown_prompt" | |
330 | ||
331 | auto_complete = None | |
477 | 332 | label = None |
478 | 333 | empty_label = u"\N{EMPTY SET}" |
334 | expanded = False | |
479 | 335 | margin = 0 |
480 | 336 | |
481 | 337 | def __init__( |
482 | 338 | self, |
483 | items = None, | |
484 | label = None, | |
485 | default = None, | |
486 | border = False, scrollbar = False, | |
487 | margin = None, | |
488 | left_chars = None, right_chars = None, | |
489 | left_chars_top = None, right_chars_top = None, | |
490 | auto_complete = False, | |
491 | max_height = 10, | |
339 | items=None, | |
340 | label=None, | |
341 | default=None, | |
342 | expanded=None, | |
343 | border=False, scrollbar=False, | |
344 | margin=None, | |
345 | text_attr=None, | |
346 | label_attr=None, | |
347 | focused_attr=None, | |
348 | highlight_attr=None, | |
349 | prompt_attr=None, | |
350 | left_chars=None, right_chars=None, | |
351 | left_chars_top=None, right_chars_top=None, | |
352 | auto_complete=None, | |
353 | max_height=10, | |
492 | 354 | # keymap = {} |
493 | 355 | ): |
494 | 356 | |
496 | 358 | self._items = items |
497 | 359 | if label is not None: |
498 | 360 | self.label = label |
361 | if expanded is not None: | |
362 | self.expanded = expanded | |
499 | 363 | self.default = default |
500 | 364 | |
501 | 365 | self.border = border |
502 | 366 | self.scrollbar = scrollbar |
503 | self.auto_complete = auto_complete | |
367 | if auto_complete is not None: self.auto_complete = auto_complete | |
368 | ||
504 | 369 | # self.keymap = keymap |
505 | 370 | |
506 | 371 | if margin: |
507 | 372 | self.margin = margin |
373 | ||
374 | if text_attr: | |
375 | self.text_attr = text_attr | |
376 | if label_attr: | |
377 | self.label_attr = label_attr | |
378 | if focused_attr: | |
379 | self.focused_attr = focused_attr | |
380 | if highlight_attr: | |
381 | self.highlight_attr = highlight_attr | |
382 | if prompt_attr: | |
383 | self.prompt_attr = prompt_attr | |
508 | 384 | |
509 | 385 | if isinstance(self.items, list): |
510 | 386 | if len(self.items): |
522 | 398 | self.button = DropdownItem( |
523 | 399 | u"", None, |
524 | 400 | margin=self.margin, |
401 | text_attr=self.text_attr, | |
402 | highlight_attr=self.highlight_attr, | |
403 | focused_attr=self.focused_attr, | |
525 | 404 | left_chars = left_chars_top if left_chars_top else left_chars, |
526 | 405 | right_chars = right_chars_top if right_chars_top else right_chars |
527 | 406 | ) |
530 | 409 | self, |
531 | 410 | self._items, |
532 | 411 | self.default, |
533 | label = self.label, | |
534 | border = self.border, | |
535 | margin = self.margin, | |
536 | left_chars = left_chars, | |
537 | right_chars = right_chars, | |
538 | auto_complete = self.auto_complete, | |
539 | scrollbar = scrollbar, | |
540 | max_height = max_height, | |
541 | # keymap = self.KEYMAP | |
412 | label=self.label, | |
413 | border=self.border, | |
414 | margin=self.margin, | |
415 | text_attr=self.text_attr, | |
416 | focused_attr=self.focused_attr, | |
417 | prompt_attr=self.prompt_attr, | |
418 | left_chars=left_chars, | |
419 | right_chars=right_chars, | |
420 | auto_complete=self.auto_complete, | |
421 | scrollbar=scrollbar, | |
422 | max_height=max_height, | |
423 | # keymap=self.KEYMAP | |
542 | 424 | ) |
543 | 425 | |
544 | 426 | urwid.connect_signal( |
545 | 427 | self.pop_up, |
546 | 428 | "select", |
547 | lambda souce, selection: self.select(selection) | |
429 | lambda souce, pos, selection: self.select(selection) | |
548 | 430 | ) |
549 | 431 | |
550 | 432 | urwid.connect_signal( |
551 | 433 | self.pop_up, |
552 | 434 | "close", |
553 | lambda button: self.close_pop_up() | |
435 | lambda source: self.close_pop_up() | |
554 | 436 | ) |
555 | 437 | |
556 | 438 | if self.default is not None: |
557 | 439 | try: |
558 | 440 | if isinstance(self.default, str): |
559 | self.select_label(self.default) | |
441 | try: | |
442 | self.select_label(self.default) | |
443 | except ValueError: | |
444 | pass | |
560 | 445 | else: |
561 | 446 | raise StopIteration |
562 | 447 | except StopIteration: |
568 | 453 | if len(self): |
569 | 454 | self.select(self.selection) |
570 | 455 | else: |
571 | self.button.set_label(("dropdown_text", self.empty_label)) | |
456 | self.button.set_text((self.text_attr, self.empty_label)) | |
572 | 457 | |
573 | 458 | cols = [ (self.button_width, self.button) ] |
574 | 459 | |
575 | 460 | if self.label: |
576 | 461 | cols[0:0] = [ |
577 | ("pack", urwid.Text([("dropdown_label", "%s: " %(self.label))])), | |
462 | ("pack", urwid.Text([(self.label_attr, "%s: " %(self.label))])), | |
578 | 463 | ] |
579 | 464 | self.columns = urwid.Columns(cols, dividechars=0) |
580 | 465 | |
589 | 474 | 'click', |
590 | 475 | lambda button: self.open_pop_up() |
591 | 476 | ) |
477 | if self.expanded: | |
478 | self.open_pop_up() | |
592 | 479 | |
593 | 480 | @classmethod |
594 | 481 | def get_palette_entries(cls): |
595 | 482 | return { |
596 | 483 | "dropdown_text": PaletteEntry( |
597 | foreground = "light gray", | |
598 | background = "dark blue", | |
599 | foreground_high = "light gray", | |
600 | background_high = "#003", | |
484 | foreground="light gray", | |
485 | background="dark blue", | |
486 | foreground_high="light gray", | |
487 | background_high="#003", | |
601 | 488 | ), |
602 | 489 | "dropdown_focused": PaletteEntry( |
603 | foreground = "white", | |
604 | background = "light blue", | |
605 | foreground_high = "white", | |
606 | background_high = "#009", | |
490 | foreground="white", | |
491 | background="light blue", | |
492 | foreground_high="white", | |
493 | background_high="#009", | |
607 | 494 | ), |
608 | 495 | "dropdown_highlight": PaletteEntry( |
609 | foreground = "yellow", | |
610 | background = "light blue", | |
611 | foreground_high = "yellow", | |
612 | background_high = "#009", | |
496 | foreground="yellow", | |
497 | background="light blue", | |
498 | foreground_high="yellow", | |
499 | background_high="#009", | |
613 | 500 | ), |
614 | 501 | "dropdown_label": PaletteEntry( |
615 | foreground = "white", | |
616 | background = "black" | |
502 | foreground="white", | |
503 | background="black" | |
617 | 504 | ), |
618 | 505 | "dropdown_prompt": PaletteEntry( |
619 | foreground = "light blue", | |
620 | background = "black" | |
506 | foreground="light blue", | |
507 | background="black" | |
621 | 508 | ) |
622 | 509 | } |
623 | 510 | |
683 | 570 | super(Dropdown, self).open_pop_up() |
684 | 571 | |
685 | 572 | def close_pop_up(self): |
686 | super(Dropdown, self).close_pop_up() | |
573 | super().close_pop_up() | |
687 | 574 | |
688 | 575 | def get_pop_up_parameters(self): |
689 | 576 | return {'left': (len(self.label) + 2 if self.label else 0), |
698 | 585 | |
699 | 586 | @focus_position.setter |
700 | 587 | def focus_position(self, pos): |
588 | if pos == self.focus_position: | |
589 | return | |
701 | 590 | # self.select_index(pos) |
702 | 591 | old_pos = self.focus_position |
703 | 592 | self.pop_up.selected_button = self.pop_up.focus_position = pos |
743 | 632 | |
744 | 633 | f = lambda x: x |
745 | 634 | if not case_sensitive: |
746 | f = lambda x: x.lower() | |
747 | ||
748 | index = next(itertools.dropwhile( | |
749 | lambda x: f(x[1]) != f(label), | |
750 | enumerate((self._items.keys()) | |
751 | ) | |
752 | ))[0] | |
635 | f = lambda x: x.lower() if isinstance(x, str) else x | |
636 | ||
637 | try: | |
638 | index = next(itertools.dropwhile( | |
639 | lambda x: f(x[1]) != f(label), | |
640 | enumerate((self._items.keys()) | |
641 | ) | |
642 | ))[0] | |
643 | except StopIteration: | |
644 | raise ValueError | |
753 | 645 | self.focus_position = index |
754 | 646 | |
755 | 647 | |
806 | 698 | |
807 | 699 | def cycle_prev(self): |
808 | 700 | self.cycle(-1) |
701 | ||
702 | def action(self): | |
703 | pass | |
809 | 704 | |
810 | 705 | @keymap_command("cycle") |
811 | 706 | def cycle(self, n): |
819 | 714 | |
820 | 715 | def select(self, button): |
821 | 716 | logger.debug("select: %s" %(button)) |
822 | self.button.set_label(("dropdown_text", button.label)) | |
717 | self.button.set_text((self.text_attr, button.label)) | |
823 | 718 | self.pop_up.dropdown_buttons.listbox.set_focus_valign("top") |
824 | 719 | # if old_pos != pos: |
720 | self.action() | |
825 | 721 | self._emit("change", self.selected_label, self.selected_value) |
826 | 722 | |
827 | 723 | # def set_items(self, items, selected_value): |
0 | import logging | |
1 | logger = logging.getLogger(__name__) | |
2 | ||
3 | class HighlightableTextMixin(object): | |
4 | ||
5 | @property | |
6 | def highlight_state(self): | |
7 | if not getattr(self, "_highlight_state", False): | |
8 | self._highlight_state = False | |
9 | self._highlight_case_sensitive = False | |
10 | self._highlight_string = None | |
11 | return self._highlight_state | |
12 | ||
13 | @property | |
14 | def highlight_content(self): | |
15 | if self.highlight_state: | |
16 | return self.get_highlight_text() | |
17 | else: | |
18 | return self.highlight_source | |
19 | ||
20 | ||
21 | def highlight(self, start, end): | |
22 | self._highlight_state = True | |
23 | self._highlight_location = (start, end) | |
24 | self.on_highlight() | |
25 | ||
26 | def unhighlight(self): | |
27 | self._highlight_state = False | |
28 | self._highlight_location = None | |
29 | self.on_unhighlight() | |
30 | ||
31 | def get_highlight_text(self): | |
32 | ||
33 | if not self._highlight_location: | |
34 | return None | |
35 | ||
36 | return [ | |
37 | (self.highlightable_attr_normal, self.highlight_source[:self._highlight_location[0]]), | |
38 | (self.highlightable_attr_highlight, self.highlight_source[self._highlight_location[0]:self._highlight_location[1]]), | |
39 | (self.highlightable_attr_normal, self.highlight_source[self._highlight_location[1]:]), | |
40 | ] | |
41 | ||
42 | @property | |
43 | def highlight_source(self): | |
44 | raise NotImplementedError | |
45 | ||
46 | @property | |
47 | def highlightable_attr_normal(self): | |
48 | raise NotImplementedError | |
49 | ||
50 | @property | |
51 | def highlightable_attr_highlight(self): | |
52 | raise NotImplementedError | |
53 | ||
54 | def on_highlight(self): | |
55 | pass | |
56 | ||
57 | def on_unhighlight(self): | |
58 | pass | |
59 | ||
60 | __all__ = ["HighlightableTextMixin"] |
1 | 1 | |
2 | 2 | import logging |
3 | 3 | logger = logging.getLogger(__name__) |
4 | # import os | |
5 | # if os.environ.get("DEBUG"): | |
6 | # logger.setLevel(logging.DEBUG) | |
7 | # formatter = logging.Formatter("%(asctime)s [%(levelname)8s] %(message)s", | |
8 | # datefmt='%Y-%m-%d %H:%M:%S') | |
9 | # fh = logging.FileHandler("keymap.log") | |
10 | # fh.setFormatter(formatter) | |
11 | # logger.addHandler(fh) | |
12 | # else: | |
13 | # logger.addHandler(logging.NullHandler()) | |
14 | 4 | |
15 | 5 | import six |
6 | import asyncio | |
16 | 7 | import urwid |
17 | 8 | import re |
9 | ||
10 | KEYMAP_GLOBAL = {} | |
18 | 11 | |
19 | 12 | _camel_snake_re_1 = re.compile(r'(.)([A-Z][a-z]+)') |
20 | 13 | _camel_snake_re_2 = re.compile('([a-z0-9])([A-Z])') |
48 | 41 | |
49 | 42 | def wrapper(cls): |
50 | 43 | |
51 | def keypress_decorator(func): | |
44 | cls.KEYMAP_MERGED = {} | |
52 | 45 | |
53 | def keypress(self, size, key): | |
54 | key = super(cls, self).keypress(size, key) | |
55 | if not key: | |
56 | return | |
57 | logger.debug("%s wrapped keypress: %s" %(self.__class__.__name__, key)) | |
58 | # logger.debug("%s scope: %s, keymap: %s" %(self.__class__.__name__, self.KEYMAP_SCOPE, getattr(self, "KEYMAP", None))) | |
59 | for scope in [cls.KEYMAP_SCOPE, "any"]: | |
60 | # logger.debug("key: %s, scope: %s, %s, %s" %(key, scope, self.KEYMAP_SCOPE, self.KEYMAP)) | |
61 | if not scope in self.KEYMAP.keys(): | |
62 | continue | |
63 | if key in self.KEYMAP[scope]: | |
64 | val = self.KEYMAP[scope][key] | |
65 | if not isinstance(val, list): | |
66 | val = [val] | |
67 | for cmd in val: | |
68 | args = [] | |
69 | kwargs = {} | |
70 | if isinstance(cmd, tuple): | |
71 | if len(cmd) == 3: | |
72 | (cmd, args, kwargs) = cmd | |
73 | elif len(cmd) == 2: | |
74 | (cmd, args) = cmd | |
75 | else: | |
76 | raise Exception | |
77 | command = cmd.replace(" ", "_") | |
78 | if not command in self.KEYMAP_MAPPING: | |
79 | logger.debug("%s: %s not in mapping %s" %(cls, key, self.KEYMAP_MAPPING)) | |
80 | if hasattr(self, command): | |
81 | fn_name = command | |
82 | else: | |
83 | fn_name = self.KEYMAP_MAPPING[command] | |
84 | f = getattr(self, fn_name) | |
85 | f(*args, **kwargs) | |
86 | return key | |
87 | else: | |
88 | return key | |
46 | if not hasattr(cls, "KEYMAP_SCOPE"): | |
47 | cls.KEYMAP_SCOPE = classmethod(lambda cls: camel_to_snake(cls.__name__)) | |
48 | elif isinstance(cls.KEYMAP_SCOPE, str): | |
49 | cls.KEYMAP_SCOPE = classmethod(lambda cls: cls.KEYMAP_SCOPE) | |
89 | 50 | |
90 | return key | |
51 | if not cls.KEYMAP_SCOPE() in cls.KEYMAP_MERGED: | |
52 | cls.KEYMAP_MERGED[cls.KEYMAP_SCOPE()] = {} | |
53 | if getattr(cls, "KEYMAP", False): | |
54 | cls.KEYMAP_MERGED[cls.KEYMAP_SCOPE()].update(**cls.KEYMAP) | |
91 | 55 | |
92 | return keypress | |
93 | 56 | |
94 | def default_keypress(self, size, key): | |
95 | logger.debug("default keypress: %s" %(key)) | |
96 | key = super(cls, self).keypress(size, key) | |
97 | return key | |
57 | for base in cls.mro(): | |
58 | if hasattr(base, "KEYMAP"): | |
59 | if not base.KEYMAP_SCOPE() in cls.KEYMAP_MERGED: | |
60 | cls.KEYMAP_MERGED[base.KEYMAP_SCOPE()] = {} | |
61 | cls.KEYMAP_MERGED[base.KEYMAP_SCOPE()].update(**base.KEYMAP) | |
98 | 62 | |
99 | if not hasattr(cls, "KEYMAP"): | |
100 | cls.KEYMAP = {} | |
101 | scope = camel_to_snake(cls.__name__) | |
102 | cls.KEYMAP_SCOPE = scope | |
103 | func = getattr(cls, "keypress", None) | |
104 | logger.debug("func class: %s" %(cls.__name__)) | |
105 | if not func: | |
106 | logger.debug("setting default keypress for %s" %(cls.__name__)) | |
107 | cls.keypress = default_keypress | |
108 | else: | |
109 | cls.keypress = keypress_decorator(func) | |
63 | # from pprint import pprint; print(cls.KEYMAP_MERGED) | |
110 | 64 | if not hasattr(cls, "KEYMAP_MAPPING"): |
111 | 65 | cls.KEYMAP_MAPPING = {} |
66 | ||
67 | cls.KEYMAP_MAPPING.update(**getattr(cls.__base__, "KEYMAP_MAPPING", {})) | |
68 | ||
112 | 69 | cls.KEYMAP_MAPPING.update({ |
113 | 70 | (getattr(getattr(cls, k), "_keymap_command", k) or k).replace(" ", "_"): k |
114 | 71 | for k in cls.__dict__.keys() |
115 | 72 | if hasattr(getattr(cls, k), '_keymap') |
116 | 73 | }) |
74 | ||
75 | def keymap_command(self, cmd): | |
76 | logger.debug(f"keymap_command: {cmd}") | |
77 | args = [] | |
78 | kwargs = {} | |
79 | ||
80 | if callable(cmd): | |
81 | f = cmd | |
82 | else: | |
83 | if isinstance(cmd, tuple): | |
84 | if len(cmd) == 3: | |
85 | (cmd, args, kwargs) = cmd | |
86 | elif len(cmd) == 2: | |
87 | if isinstance(cmd[1], dict): | |
88 | (cmd, kwargs) = cmd | |
89 | else: | |
90 | (cmd, args) = cmd | |
91 | else: | |
92 | raise Exception | |
93 | elif isinstance(cmd, str): | |
94 | cmd = cmd.replace(" ", "_") | |
95 | else: | |
96 | logger.debug(f"keymap command {cmd} not valid") | |
97 | return None | |
98 | ||
99 | if hasattr(self, cmd): | |
100 | fn_name = cmd | |
101 | else: | |
102 | try: | |
103 | fn_name = self.KEYMAP_MAPPING[cmd] | |
104 | except KeyError: | |
105 | raise KeyError(cmd, self.KEYMAP_MAPPING, type(self)) | |
106 | ||
107 | f = getattr(self, fn_name) | |
108 | ||
109 | ret = f(*args, **kwargs) | |
110 | if asyncio.iscoroutine(ret): | |
111 | asyncio.get_event_loop().create_task(ret) | |
112 | return None | |
113 | ||
114 | cls._keymap_command = keymap_command | |
115 | ||
116 | def keymap_register(self, key, cmd): | |
117 | self.KEYMAP_MERGED[cls.KEYMAP_SCOPE()][key] = cmd | |
118 | ||
119 | cls.keymap_register = keymap_register | |
120 | ||
121 | def keypress_decorator(func): | |
122 | ||
123 | ||
124 | def keypress(self, size, key): | |
125 | logger.debug(f"{cls} wrapped keypress: {key}, {cls.KEYMAP_SCOPE()}, {self.KEYMAP_MERGED.get(cls.KEYMAP_SCOPE(), {}).keys()}") | |
126 | ||
127 | if key and callable(func): | |
128 | logger.debug(f"{cls} wrapped keypress, key: {key}, calling orig: {func}") | |
129 | key = func(self, size, key) | |
130 | if key: | |
131 | logger.debug(f"{cls} wrapped keypress, key: {key}, calling super: {super(cls, self).keypress}") | |
132 | key = super(cls, self).keypress(size, key) | |
133 | keymap_combined = dict(self.KEYMAP_MERGED, **KEYMAP_GLOBAL) | |
134 | if key and keymap_combined.get(cls.KEYMAP_SCOPE(), {}).get(key, None): | |
135 | cmd = keymap_combined[cls.KEYMAP_SCOPE()][key] | |
136 | if isinstance(cmd, str) and cmd.startswith("keypress "): | |
137 | new_key = cmd.replace("keypress ", "").strip() | |
138 | logger.debug(f"{cls} remap {key} => {new_key}") | |
139 | key = new_key | |
140 | else: | |
141 | logger.debug(f"{cls} wrapped keypress, key: {key}, calling keymap command") | |
142 | key = self._keymap_command(cmd) | |
143 | return key | |
144 | ||
145 | return keypress | |
146 | ||
147 | cls.keypress = keypress_decorator(getattr(cls, "keypress", None)) | |
117 | 148 | return cls |
149 | ||
118 | 150 | return wrapper |
151 | ||
152 | ||
119 | 153 | |
120 | 154 | |
121 | 155 | @keymapped() |
122 | 156 | class KeymapMovementMixin(object): |
157 | ||
158 | @classmethod | |
159 | def KEYMAP_SCOPE(cls): | |
160 | return "movement" | |
123 | 161 | |
124 | 162 | def cycle_position(self, n): |
125 | 163 |
3 | 3 | |
4 | 4 | import urwid |
5 | 5 | from urwid_utils.palette import * |
6 | from .scroll import ScrollBar | |
6 | 7 | |
7 | 8 | class ListBoxScrollBar(urwid.WidgetWrap): |
8 | 9 | |
24 | 25 | ) |
25 | 26 | scroll_marker_height = max( height * (height / self.parent.row_count ), 1) |
26 | 27 | else: |
27 | scroll_position = -1 | |
28 | scroll_position = 0 | |
28 | 29 | |
29 | 30 | pos_marker = urwid.AttrMap(urwid.Text(" "), |
30 | 31 | {None: "scroll_pos"} |
56 | 57 | marker = begin_marker |
57 | 58 | elif i+1 == height and self.parent.row_count == self.parent.focus_position+1: |
58 | 59 | marker = end_marker |
59 | elif len(self.parent.body) == self.parent.focus_position+1 and i == scroll_position + scroll_marker_height//2: | |
60 | elif self.parent.focus_position is not None and len(self.parent.body) == self.parent.focus_position+1 and i == scroll_position + scroll_marker_height//2: | |
60 | 61 | marker = down_marker |
61 | 62 | else: |
62 | 63 | marker = pos_marker |
76 | 77 | # FIXME: mouse click/drag |
77 | 78 | return False |
78 | 79 | |
79 | ||
80 | 80 | class ScrollingListBox(urwid.WidgetWrap): |
81 | 81 | |
82 | 82 | signals = ["select", |
83 | 83 | "drag_start", "drag_continue", "drag_stop", |
84 | 84 | "load_more"] |
85 | 85 | |
86 | scrollbar_class = ScrollBar | |
87 | ||
86 | 88 | def __init__(self, body, |
87 | 89 | infinite = False, |
88 | 90 | with_scrollbar=False, |
89 | scroll_rows=None, | |
90 | row_count_fn = None): | |
91 | row_count_fn = None, | |
92 | thumb_char=None, | |
93 | trough_char=None, | |
94 | thumb_indicator_top=None, | |
95 | thumb_indicator_bottom=None): | |
91 | 96 | |
92 | 97 | self.infinite = infinite |
93 | 98 | self.with_scrollbar = with_scrollbar |
94 | self.scroll_rows = scroll_rows | |
95 | 99 | self.row_count_fn = row_count_fn |
100 | ||
101 | self._width = None | |
102 | self._height = 0 | |
103 | self._rows_max = None | |
96 | 104 | |
97 | 105 | self.mouse_state = 0 |
98 | 106 | self.drag_from = None |
99 | 107 | self.drag_last = None |
100 | 108 | self.drag_to = None |
101 | 109 | self.load_more = False |
102 | self.height = 0 | |
103 | 110 | self.page = 0 |
104 | 111 | |
105 | 112 | self.queued_keypress = None |
106 | ||
107 | self.listbox = urwid.ListBox(body) | |
113 | w = self.listbox = urwid.ListBox(body) | |
114 | ||
108 | 115 | self.columns = urwid.Columns([ |
109 | 116 | ('weight', 1, self.listbox) |
110 | 117 | ]) |
114 | 121 | (self.scroll_bar, self.columns.options("given", 1)) |
115 | 122 | ) |
116 | 123 | super(ScrollingListBox, self).__init__(self.columns) |
124 | urwid.connect_signal(self.body, "modified", self.on_modified) | |
125 | ||
126 | def on_modified(self): | |
127 | if self.with_scrollbar and len(self.body): | |
128 | self.scroll_bar.update(self.size) | |
129 | ||
130 | def rows_max(self, size, focus=False): | |
131 | return urwid.ListBox.rows_max(self, size, focus) | |
132 | ||
117 | 133 | |
118 | 134 | @classmethod |
119 | 135 | def get_palette_entries(cls): |
154 | 170 | def mouse_event(self, size, event, button, col, row, focus): |
155 | 171 | |
156 | 172 | SCROLL_WHEEL_HEIGHT_RATIO = 0.5 |
157 | if row < 0 or row >= self.height: | |
173 | if row < 0 or row >= self._height or not len(self.listbox.body): | |
158 | 174 | return |
159 | 175 | if event == 'mouse press': |
160 | 176 | if button == 1: |
161 | 177 | self.mouse_state = 1 |
162 | 178 | self.drag_from = self.drag_last = (col, row) |
163 | 179 | elif button == 4: |
164 | pos = self.listbox.focus_position - int(self.height * SCROLL_WHEEL_HEIGHT_RATIO) | |
180 | pos = self.listbox.focus_position - int(self._height * SCROLL_WHEEL_HEIGHT_RATIO) | |
165 | 181 | if pos < 0: |
166 | 182 | pos = 0 |
167 | 183 | self.listbox.focus_position = pos |
168 | 184 | self.listbox.make_cursor_visible(size) |
169 | 185 | self._invalidate() |
170 | 186 | elif button == 5: |
171 | pos = self.listbox.focus_position + int(self.height * SCROLL_WHEEL_HEIGHT_RATIO) | |
187 | pos = self.listbox.focus_position + int(self._height * SCROLL_WHEEL_HEIGHT_RATIO) | |
172 | 188 | if pos > len(self.listbox.body) - 1: |
173 | 189 | if self.infinite: |
174 | 190 | self.load_more = True |
234 | 250 | if len(self.body): |
235 | 251 | return self.body[self.focus_position] |
236 | 252 | |
253 | @property | |
254 | def size(self): | |
255 | return (self._width, self._height) | |
237 | 256 | |
238 | 257 | def render(self, size, focus=False): |
239 | 258 | |
240 | 259 | maxcol = size[0] |
241 | self.width = maxcol | |
260 | self._width = maxcol | |
242 | 261 | if len(size) > 1: |
243 | 262 | maxrow = size[1] |
244 | self.height = maxrow | |
263 | modified = self._height == 0 | |
264 | self._height = maxrow | |
265 | if modified: | |
266 | self.on_modified() | |
267 | else: | |
268 | self._height = 0 | |
245 | 269 | |
246 | 270 | |
247 | 271 | |
262 | 286 | urwid.signals.emit_signal( |
263 | 287 | self, "load_more", focus) |
264 | 288 | if (self.queued_keypress |
265 | and focus | |
266 | and focus < len(self.body) | |
289 | and focus is not None | |
290 | # and focus < len(self.body)-1 | |
267 | 291 | ): |
268 | # logger.info("send queued keypress") | |
292 | # logger.info(f"send queued keypress: {focus}, {len(self.body)}") | |
269 | 293 | self.keypress(size, self.queued_keypress) |
270 | 294 | self.queued_keypress = None |
271 | 295 | # self.listbox._invalidate() |
272 | 296 | # self._invalidate() |
273 | 297 | |
274 | if self.with_scrollbar and len(self.body): | |
275 | self.scroll_bar.update(size) | |
276 | ||
277 | 298 | return super(ScrollingListBox, self).render(size, focus) |
278 | 299 | |
279 | 300 | |
295 | 316 | def focus_position(self): |
296 | 317 | if not len(self.listbox.body): |
297 | 318 | raise IndexError |
298 | if len(self.listbox.body): | |
319 | try: | |
299 | 320 | return self.listbox.focus_position |
321 | except IndexError: | |
322 | pass | |
300 | 323 | return None |
301 | 324 | |
302 | 325 | @focus_position.setter |
0 | #!/usr/bin/env python3 | |
1 | ||
2 | import urwid | |
3 | ||
4 | from .sparkwidgets import * | |
5 | ||
6 | class ProgressBar(urwid.WidgetWrap): | |
7 | ||
8 | def __init__(self, width, maximum, value=0, | |
9 | progress_color=None, remaining_color=None): | |
10 | self.width = width | |
11 | self.maximum = maximum | |
12 | self.value = value | |
13 | self.progress_color = progress_color or DEFAULT_BAR_COLOR | |
14 | self.remaining_color = remaining_color or DEFAULT_LABEL_COLOR | |
15 | self.placeholder = urwid.WidgetPlaceholder(urwid.Text("")) | |
16 | self.update() | |
17 | super().__init__(self.placeholder) | |
18 | ||
19 | def pack(self, size, focus=False): | |
20 | return (self.width, 1) | |
21 | ||
22 | @property | |
23 | def value_label(self): | |
24 | label_text = str(self.value) | |
25 | bar_len = self.spark_bar.bar_width(0) | |
26 | attr1 = f"{DEFAULT_LABEL_COLOR}:{self.progress_color}" | |
27 | content = [(attr1, label_text[:bar_len])] | |
28 | if len(label_text) > bar_len-1: | |
29 | attr2 = f"{DEFAULT_LABEL_COLOR}:{self.remaining_color}" | |
30 | content.append((attr2, label_text[bar_len:])) | |
31 | return urwid.Text(content) | |
32 | ||
33 | @property | |
34 | def maximum_label(self): | |
35 | label_text = str(self.maximum) | |
36 | bar_len = self.spark_bar.bar_width(1) | |
37 | attr1 = f"{DEFAULT_LABEL_COLOR}:{self.remaining_color}" | |
38 | content = [] | |
39 | if bar_len: | |
40 | content.append((attr1, label_text[-bar_len:])) | |
41 | if len(label_text) > bar_len: | |
42 | attr2 = f"{DEFAULT_LABEL_COLOR}:{self.progress_color}" | |
43 | content.insert(0, (attr2, label_text[:-bar_len or None])) | |
44 | return urwid.Text(content) | |
45 | ||
46 | def update(self): | |
47 | value_label = None | |
48 | maximum_label = None | |
49 | ||
50 | self.spark_bar = SparkBarWidget( | |
51 | [ | |
52 | SparkBarItem(self.value, bcolor=self.progress_color), | |
53 | SparkBarItem(self.maximum-self.value, bcolor=self.remaining_color), | |
54 | ], width=self.width | |
55 | ) | |
56 | overlay1 = urwid.Overlay( | |
57 | urwid.Filler(self.value_label), | |
58 | urwid.Filler(self.spark_bar), | |
59 | "left", | |
60 | len(self.value_label.get_text()[0]), | |
61 | "top", | |
62 | 1 | |
63 | ) | |
64 | label_len = len(self.maximum_label.get_text()[0]) | |
65 | overlay2 = urwid.Overlay( | |
66 | urwid.Filler(self.maximum_label), | |
67 | overlay1, | |
68 | "left", | |
69 | label_len, | |
70 | "top", | |
71 | 1, | |
72 | left=self.width - label_len | |
73 | ) | |
74 | self.placeholder.original_widget = urwid.BoxAdapter(overlay2, 1) | |
75 | ||
76 | def set_value(self, value): | |
77 | self.value = value | |
78 | self.update() | |
79 | ||
80 | @property | |
81 | def items(self): | |
82 | return self.spark_bar.items | |
83 | ||
84 | __all__ = ["ProgressBar"] |
25 | 25 | # Scrollbar positions |
26 | 26 | SCROLLBAR_LEFT = 'left' |
27 | 27 | SCROLLBAR_RIGHT = 'right' |
28 | ||
29 | # Add support for ScrollBar class (see stig.tui.scroll) | |
30 | # https://github.com/urwid/urwid/issues/226 | |
31 | class ListBox_patched(urwid.ListBox): | |
32 | def __init__(self, *args, **kwargs): | |
33 | super().__init__(*args, **kwargs) | |
34 | self._rows_max = None | |
35 | ||
36 | def _invalidate(self): | |
37 | super()._invalidate() | |
38 | self._rows_max = None | |
39 | ||
40 | def get_scrollpos(self, size, focus=False): | |
41 | """Current scrolling position | |
42 | Lower limit is 0, upper limit is the highest index of `body`. | |
43 | """ | |
44 | middle, top, bottom = self.calculate_visible(size, focus) | |
45 | if middle is None: | |
46 | return 0 | |
47 | else: | |
48 | offset_rows, _, focus_pos, _, _ = middle | |
49 | maxcol, maxrow = size | |
50 | flow_size = (maxcol,) | |
51 | ||
52 | body = self.body | |
53 | if hasattr(body, 'positions'): | |
54 | # For body[pos], pos can be anything, not just an int. In that | |
55 | # case, the positions() method returns an interable of valid | |
56 | # positions. | |
57 | positions = tuple(self.body.positions()) | |
58 | focus_index = positions.index(focus_pos) | |
59 | widgets_above_focus = (body[pos] for pos in positions[:focus_index]) | |
60 | else: | |
61 | # Treat body like a normal list | |
62 | widgets_above_focus = (w for w in body[:focus_pos]) | |
63 | ||
64 | rows_above_focus = sum(w.rows(flow_size) for w in widgets_above_focus) | |
65 | rows_above_top = rows_above_focus - offset_rows | |
66 | return rows_above_top | |
67 | ||
68 | def rows_max(self, size, focus=False): | |
69 | if self._rows_max is None: | |
70 | flow_size = (size[0],) | |
71 | body = self.body | |
72 | if hasattr(body, 'positions'): | |
73 | self._rows_max = sum(body[pos].rows(flow_size) for pos in body.positions()) | |
74 | else: | |
75 | self._rows_max = sum(w.rows(flow_size) for w in self.body) | |
76 | return self._rows_max | |
77 | ||
78 | urwid.ListBox = ListBox_patched | |
28 | 79 | |
29 | 80 | class Scrollable(urwid.WidgetDecoration): |
30 | 81 | |
271 | 322 | return self._rows_max_cached |
272 | 323 | |
273 | 324 | |
325 | DEFAULT_THUMB_CHAR = '\u2588' | |
326 | DEFAULT_TROUGH_CHAR = " " | |
327 | DEFAULT_SIDE = SCROLLBAR_RIGHT | |
328 | ||
274 | 329 | class ScrollBar(urwid.WidgetDecoration): |
330 | ||
331 | _thumb_char = DEFAULT_THUMB_CHAR | |
332 | _trough_char = DEFAULT_TROUGH_CHAR | |
333 | _thumb_indicator_top = None | |
334 | _thumb_indicator_bottom = None | |
335 | _scroll_bar_side = DEFAULT_SIDE | |
336 | ||
275 | 337 | def sizing(self): |
276 | 338 | return frozenset((BOX,)) |
277 | 339 | |
278 | 340 | def selectable(self): |
279 | 341 | return True |
280 | 342 | |
281 | def __init__(self, widget, thumb_char=u'\u2588', trough_char=' ', | |
282 | side=SCROLLBAR_RIGHT, width=1): | |
343 | def __init__(self, widget, | |
344 | thumb_char=None, trough_char=None, | |
345 | thumb_indicator_top=None, thumb_indicator_bottom=None, | |
346 | side=DEFAULT_SIDE, width=1): | |
283 | 347 | """Box widget that adds a scrollbar to `widget` |
284 | 348 | |
285 | 349 | `widget` must be a box widget with the following methods: |
298 | 362 | if BOX not in widget.sizing(): |
299 | 363 | raise ValueError('Not a box widget: %r' % widget) |
300 | 364 | self.__super.__init__(widget) |
301 | self._thumb_char = thumb_char | |
302 | self._trough_char = trough_char | |
365 | if thumb_char is not None: | |
366 | self._thumb_char = thumb_char | |
367 | if trough_char is not None: | |
368 | self._trough_char = trough_char | |
369 | if thumb_indicator_top is not None: | |
370 | self._thumb_indicator_top = thumb_indicator_top | |
371 | if thumb_indicator_bottom is not None: | |
372 | self._thumb_indicator_bottom = thumb_indicator_bottom | |
373 | ||
303 | 374 | self.scrollbar_side = side |
304 | 375 | self.scrollbar_width = max(1, width) |
305 | 376 | self._original_widget_size = (0, 0) |
346 | 417 | # fill gaps in shard_tail!" or "cviews overflow gaps in shard_tail!" |
347 | 418 | # exceptions. Stacking the same SolidCanvas is a workaround. |
348 | 419 | # https://github.com/urwid/urwid/issues/226#issuecomment-437176837 |
349 | top = urwid.SolidCanvas(self._trough_char, sb_width, 1) | |
350 | thumb = urwid.SolidCanvas(self._thumb_char, sb_width, 1) | |
351 | bottom = urwid.SolidCanvas(self._trough_char, sb_width, 1) | |
420 | ||
421 | thumb_top = thumb_bottom = None | |
422 | if (self._thumb_indicator_top | |
423 | or self._thumb_indicator_bottom) and hasattr(ow.body, "positions"): | |
424 | if hasattr(ow.body, "focus"): | |
425 | pos = ow.body.focus | |
426 | elif hasattr(ow.body, "get_focus"): | |
427 | pos = ow.body.get_focus()[1] | |
428 | ||
429 | try: | |
430 | head = next(iter(ow.body.positions())) | |
431 | except StopIteration: | |
432 | head = None | |
433 | if pos == head: | |
434 | if isinstance(self._thumb_indicator_top, tuple): | |
435 | attr, char = self._thumb_indicator_top | |
436 | else: | |
437 | attr, char = None, self._thumb_indicator_top | |
438 | ||
439 | if char: | |
440 | thumb_top = urwid.Text( | |
441 | (attr, char * sb_width), | |
442 | wrap="any" | |
443 | ).render((sb_width,)) | |
444 | if thumb_height: | |
445 | thumb_height -= 1 | |
446 | try: | |
447 | tail = next(iter(ow.body.positions(reverse=True))) | |
448 | except StopIteration: | |
449 | tail = None | |
450 | if pos == tail: | |
451 | if isinstance(self._thumb_indicator_bottom, tuple): | |
452 | attr, char = self._thumb_indicator_bottom | |
453 | else: | |
454 | attr, char = None, self._thumb_indicator_bottom | |
455 | ||
456 | if char: | |
457 | thumb_bottom = urwid.Text( | |
458 | (attr, char * sb_width), | |
459 | wrap="any" | |
460 | ).render((sb_width,)) | |
461 | if thumb_height: | |
462 | thumb_height -= 1 | |
463 | ||
464 | if isinstance(self._trough_char, tuple): | |
465 | trough_attr, trough_char = self._trough_char | |
466 | else: | |
467 | trough_attr, trough_char = None, self._trough_char | |
468 | ||
469 | top = urwid.Text( | |
470 | (trough_attr, trough_char * top_height * sb_width), | |
471 | wrap="any" | |
472 | ).render((sb_width,)) | |
473 | ||
474 | if isinstance(self._thumb_char, tuple): | |
475 | thumb_attr, thumb_char = self._thumb_char | |
476 | else: | |
477 | thumb_attr, thumb_char = (None, self._thumb_char) | |
478 | thumb = urwid.Text( | |
479 | (thumb_attr, thumb_char * thumb_height * sb_width), | |
480 | wrap="any" | |
481 | ).render((sb_width,)) | |
482 | ||
483 | bottom = urwid.Text( | |
484 | (trough_attr, trough_char * bottom_height * sb_width), | |
485 | wrap="any" | |
486 | ).render((sb_width,)) | |
487 | ||
488 | ||
352 | 489 | sb_canv = urwid.CanvasCombine( |
353 | [(top, None, False)] * top_height + | |
354 | [(thumb, None, False)] * thumb_height + | |
355 | [(bottom, None, False)] * bottom_height, | |
490 | [ (top, None, False)] * (1 if top_height else 0) + | |
491 | [ (thumb_top, None, False)] * (1 if thumb_top else 0) + | |
492 | [ (thumb, None, False)] * (1 if thumb_height else 0) + | |
493 | [ (thumb_bottom, None, False)] * (1 if thumb_bottom else 0) + | |
494 | [ (bottom, None, False)] * (1 if bottom_height else 0) | |
356 | 495 | ) |
357 | 496 | |
358 | 497 | combinelist = [(ow_canv, None, True, ow_size[0]), |
0 | """ | |
1 | ```sparkwidgets``` | |
2 | ======================== | |
3 | ||
4 | A set of sparkline-ish widgets for urwid | |
5 | ||
6 | This module contains a set of urwid text-like widgets for creating tiny but | |
7 | hopefully useful sparkline-like visualizations of data. | |
8 | """ | |
9 | ||
10 | import urwid | |
11 | from urwid_utils.palette import * | |
12 | from collections import deque | |
13 | import math | |
14 | import operator | |
15 | import itertools | |
16 | import collections | |
17 | from dataclasses import dataclass | |
18 | ||
19 | BLOCK_VERTICAL = [ chr(x) for x in range(0x2581, 0x2589) ] | |
20 | BLOCK_HORIZONTAL = [" "] + [ chr(x) for x in range(0x258F, 0x2587, -1) ] | |
21 | ||
22 | DEFAULT_LABEL_COLOR = "black" | |
23 | DEFAULT_LABEL_COLOR_DARK = "black" | |
24 | DEFAULT_LABEL_COLOR_LIGHT = "white" | |
25 | ||
26 | DEFAULT_BAR_COLOR = "white" | |
27 | ||
28 | DISTINCT_COLORS_16 = urwid.display_common._BASIC_COLORS[1:] | |
29 | ||
30 | DISTINCT_COLORS_256 = [ | |
31 | '#f00', '#080', '#00f', '#d6f', '#0ad', '#f80', '#8f0', '#666', | |
32 | '#f88', '#808', '#0fd', '#66f', '#aa8', '#060', '#faf', '#860', | |
33 | '#60a', '#600', '#ff8', '#086', '#8a6', '#adf', '#88a', '#f60', | |
34 | '#068', '#a66', '#f0a', '#fda' | |
35 | ] | |
36 | ||
37 | DISTINCT_COLORS_TRUE = [ | |
38 | '#ff0000', '#008c00', '#0000ff', '#c34fff', | |
39 | '#01a5ca', '#ec9d00', '#76ff00', '#595354', | |
40 | '#ff7598', '#940073', '#00f3cc', '#4853ff', | |
41 | '#a6a19a', '#004301', '#edb7ff', '#8a6800', | |
42 | '#6100a3', '#5c0011', '#fff585', '#007b69', | |
43 | '#92b853', '#abd4ff', '#7e79a3', '#ff5401', | |
44 | '#0a577d', '#a8615c', '#e700b9', '#ffc3a6' | |
45 | ] | |
46 | ||
47 | COLOR_SCHEMES = { | |
48 | "mono": { | |
49 | "mode": "mono" | |
50 | }, | |
51 | "rotate_16": { | |
52 | "mode": "rotate", | |
53 | "colors": DISTINCT_COLORS_16 | |
54 | }, | |
55 | "rotate_256": { | |
56 | "mode": "rotate", | |
57 | "colors": DISTINCT_COLORS_256 | |
58 | }, | |
59 | "rotate_true": { | |
60 | "mode": "rotate", | |
61 | "colors": DISTINCT_COLORS_TRUE | |
62 | }, | |
63 | "signed": { | |
64 | "mode": "rules", | |
65 | "colors": { | |
66 | "nonnegative": "default", | |
67 | "negative": "dark red" | |
68 | }, | |
69 | "rules": [ | |
70 | ( "<", 0, "negative" ), | |
71 | ( "else", "nonnegative" ), | |
72 | ] | |
73 | } | |
74 | } | |
75 | ||
76 | def pairwise(iterable): | |
77 | "s -> (s0,s1), (s1,s2), (s2, s3), ..." | |
78 | a, b = itertools.tee(iterable) | |
79 | next(b, None) | |
80 | return zip(a, b) | |
81 | ||
82 | ||
83 | def get_palette_entries( | |
84 | chart_colors = None, | |
85 | label_colors = None | |
86 | ): | |
87 | ||
88 | NORMAL_FG_MONO = "white" | |
89 | NORMAL_FG_16 = "light gray" | |
90 | NORMAL_BG_16 = "black" | |
91 | NORMAL_FG_256 = "light gray" | |
92 | NORMAL_BG_256 = "black" | |
93 | ||
94 | palette_entries = {} | |
95 | ||
96 | if not label_colors: | |
97 | label_colors = list(set([ | |
98 | DEFAULT_LABEL_COLOR, | |
99 | DEFAULT_LABEL_COLOR_DARK, | |
100 | DEFAULT_LABEL_COLOR_LIGHT | |
101 | ])) | |
102 | ||
103 | ||
104 | if chart_colors: | |
105 | colors = chart_colors | |
106 | else: | |
107 | colors = (urwid.display_common._BASIC_COLORS | |
108 | + DISTINCT_COLORS_256 | |
109 | + DISTINCT_COLORS_TRUE ) | |
110 | ||
111 | fcolors = colors + label_colors | |
112 | bcolors = colors | |
113 | ||
114 | for fcolor in fcolors: | |
115 | if isinstance(fcolor, PaletteEntry): | |
116 | fname = fcolor.name | |
117 | ffg = fcolor.foreground | |
118 | fbg = NORMAL_BG_16 | |
119 | ffghi = fcolor.foreground_high | |
120 | fbghi = NORMAL_BG_256 | |
121 | else: | |
122 | fname = fcolor | |
123 | ffg = (fcolor | |
124 | if fcolor in urwid.display_common._BASIC_COLORS | |
125 | else NORMAL_FG_16) | |
126 | fbg = NORMAL_BG_16 | |
127 | ffghi = fcolor | |
128 | fbghi = NORMAL_BG_256 | |
129 | ||
130 | palette_entries.update({ | |
131 | fname: PaletteEntry( | |
132 | name = fname, | |
133 | mono = NORMAL_FG_MONO, | |
134 | foreground = ffg, | |
135 | background = fbg, | |
136 | foreground_high = ffghi, | |
137 | background_high = fbghi | |
138 | ), | |
139 | }) | |
140 | ||
141 | for bcolor in bcolors: | |
142 | ||
143 | if isinstance(bcolor, PaletteEntry): | |
144 | bname = "%s:%s" %(fname, bcolor.name) | |
145 | bfg = ffg | |
146 | bbg = bcolor.background | |
147 | bfghi = ffghi | |
148 | bbghi = bcolor.background_high | |
149 | else: | |
150 | bname = "%s:%s" %(fname, bcolor) | |
151 | bfg = fcolor | |
152 | bbg = bcolor | |
153 | bfghi = fcolor | |
154 | bbghi = bcolor | |
155 | ||
156 | palette_entries.update({ | |
157 | bname: PaletteEntry( | |
158 | name = bname, | |
159 | mono = NORMAL_FG_MONO, | |
160 | foreground = (bfg | |
161 | if bfg in urwid.display_common._BASIC_COLORS | |
162 | else NORMAL_BG_16), | |
163 | background = (bbg | |
164 | if bbg in urwid.display_common._BASIC_COLORS | |
165 | else NORMAL_BG_16), | |
166 | foreground_high = bfghi, | |
167 | background_high = bbghi | |
168 | ), | |
169 | }) | |
170 | ||
171 | return palette_entries | |
172 | ||
173 | ||
174 | ||
175 | OPERATOR_MAP = { | |
176 | "<": operator.lt, | |
177 | "<=": operator.le, | |
178 | ">": operator.gt, | |
179 | ">=": operator.ge, | |
180 | "=": operator.eq, | |
181 | "else": lambda a, b: True | |
182 | } | |
183 | ||
184 | ||
185 | class SparkWidget(urwid.Text): | |
186 | ||
187 | @staticmethod | |
188 | def make_rule_function(scheme): | |
189 | ||
190 | rules = scheme["rules"] | |
191 | def rule_function(value): | |
192 | return scheme["colors"].get( | |
193 | next(iter(filter( | |
194 | lambda rule: all( | |
195 | OPERATOR_MAP[cond[0]](value, cond[1] if len(cond) > 1 else None) | |
196 | for cond in [rule] | |
197 | ), scheme["rules"] | |
198 | )))[-1] | |
199 | ) | |
200 | return rule_function | |
201 | ||
202 | ||
203 | @staticmethod | |
204 | def normalize(v, a, b, scale_min, scale_max): | |
205 | ||
206 | if scale_max == scale_min: | |
207 | return v | |
208 | return max( | |
209 | a, | |
210 | min( | |
211 | b, | |
212 | (((v - scale_min) / (scale_max - scale_min) ) * (b - a) + a) | |
213 | ) | |
214 | ) | |
215 | ||
216 | ||
217 | def parse_scheme(self, scheme): | |
218 | ||
219 | if isinstance(scheme, dict): | |
220 | color_scheme = scheme | |
221 | else: | |
222 | try: | |
223 | color_scheme = COLOR_SCHEMES[scheme] | |
224 | except: | |
225 | return lambda x: scheme | |
226 | # raise Exception("Unknown color scheme: %s" %(scheme)) | |
227 | ||
228 | mode = color_scheme["mode"] | |
229 | if mode == "mono": | |
230 | return None | |
231 | ||
232 | elif mode == "rotate": | |
233 | return deque(color_scheme["colors"]) | |
234 | ||
235 | elif mode == "rules": | |
236 | return self.make_rule_function(color_scheme) | |
237 | ||
238 | else: | |
239 | raise Exception("Unknown color scheme mode: %s" %(mode)) | |
240 | ||
241 | @property | |
242 | def current_color(self): | |
243 | ||
244 | return self.colors[0] | |
245 | ||
246 | def next_color(self): | |
247 | if not self.colors: | |
248 | return | |
249 | self.colors.rotate(-1) | |
250 | return self.current_color | |
251 | ||
252 | def get_color(self, item): | |
253 | if not self.colors: | |
254 | color = None | |
255 | elif callable(self.colors): | |
256 | color = self.colors(item) | |
257 | elif isinstance(self.colors, collections.Iterable): | |
258 | color = self.current_color | |
259 | self.next_color() | |
260 | return color | |
261 | else: | |
262 | raise Exception(self.colors) | |
263 | ||
264 | return color | |
265 | ||
266 | ||
267 | class SparkColumnWidget(SparkWidget): | |
268 | """ | |
269 | A sparkline-ish column widget for Urwid. | |
270 | ||
271 | Given a list of numeric values, this widget will draw a small text-based | |
272 | vertical bar graph of the values, one character per value. Column segments | |
273 | can be colorized according to a color scheme or by assigning each | |
274 | value a color. | |
275 | ||
276 | :param items: A list of items to be charted in the widget. Items can be | |
277 | either numeric values or tuples, the latter of which must be of the form | |
278 | ('attribute', value) where attribute is an urwid text attribute and value | |
279 | is a numeric value. | |
280 | ||
281 | :param color_scheme: A string or dictionary containing the name of or | |
282 | definition of a color scheme for the widget. | |
283 | ||
284 | :param underline: one of None, "negative", or "min", specifying values that | |
285 | should be marked in the chart. "negative" shows negative values as little | |
286 | dots at the bottom of the chart, while "min" uses a unicode combining | |
287 | three dots character to indicate minimum values. Results of this and the | |
288 | rest of these parameters may not look great with all terminals / fonts, | |
289 | so if this looks weird, don't use it. | |
290 | ||
291 | :param overline: one of None or "max" specfying values that should be marked | |
292 | in the chart. "max" draws three dots above the max value. See underline | |
293 | description for caveats. | |
294 | ||
295 | :param scale_min: Set a minimum scale for the chart. By default, the range | |
296 | of the chart's Y axis will expand to show all values, but this parameter | |
297 | can be used to restrict or expand the Y-axis. | |
298 | ||
299 | :param scale_max: Set the maximum for the Y axis. -- see scale_min. | |
300 | """ | |
301 | ||
302 | chars = BLOCK_VERTICAL | |
303 | ||
304 | def __init__(self, items, | |
305 | color_scheme = "mono", | |
306 | scale_min = None, | |
307 | scale_max = None, | |
308 | underline = None, | |
309 | overline = None, | |
310 | *args, **kwargs): | |
311 | ||
312 | self.items = items | |
313 | self.colors = self.parse_scheme(color_scheme) | |
314 | ||
315 | self.underline = underline | |
316 | self.overline = overline | |
317 | ||
318 | self.values = [ i[1] if isinstance(i, tuple) else i for i in self.items ] | |
319 | ||
320 | v_min = min(self.values) | |
321 | v_max = max(self.values) | |
322 | ||
323 | ||
324 | def item_to_glyph(item): | |
325 | ||
326 | color = None | |
327 | ||
328 | if isinstance(item, tuple): | |
329 | color = item[0] | |
330 | value = item[1] | |
331 | else: | |
332 | color = self.get_color(item) | |
333 | value = item | |
334 | ||
335 | if self.underline == "negative" and value < 0: | |
336 | glyph = " \N{COMBINING DOT BELOW}" | |
337 | else: | |
338 | ||
339 | ||
340 | # idx = scale_value(value, scale_min=scale_min, scale_max=scale_max) | |
341 | idx = self.normalize( | |
342 | value, 0, len(self.chars)-1, | |
343 | scale_min if scale_min else v_min, | |
344 | scale_max if scale_max else v_max) | |
345 | ||
346 | glyph = self.chars[int(round(idx))] | |
347 | ||
348 | if self.underline == "min" and value == v_min: | |
349 | glyph = "%s\N{COMBINING TRIPLE UNDERDOT}" %(glyph) | |
350 | ||
351 | if self.overline == "max" and value == v_max: | |
352 | glyph = "%s\N{COMBINING THREE DOTS ABOVE}" %(glyph) | |
353 | ||
354 | if color: | |
355 | return (color, glyph) | |
356 | else: | |
357 | return glyph | |
358 | ||
359 | self.sparktext = [ | |
360 | item_to_glyph(i) | |
361 | for i in self.items | |
362 | ] | |
363 | super(SparkColumnWidget, self).__init__(self.sparktext, *args, **kwargs) | |
364 | ||
365 | ||
366 | # via https://github.com/rg3/dhondt | |
367 | def dhondt_formula(votes, seats): | |
368 | return votes / (seats + 1) | |
369 | ||
370 | def bar_widths(party_votes, total_seats): | |
371 | # Calculate the quotients matrix (list in this case). | |
372 | quot = [] | |
373 | ret = dict() | |
374 | for p in dict(enumerate(party_votes)): | |
375 | ret[p] = 0 | |
376 | for s in range(0, total_seats): | |
377 | q = dhondt_formula(party_votes[p], s) | |
378 | quot.append((q, p)) | |
379 | ||
380 | # Sort the quotients by value. | |
381 | quot.sort(reverse=True) | |
382 | ||
383 | # Take the highest quotients with the assigned parties. | |
384 | for s in range(0, total_seats): | |
385 | ret[quot[s][1]] += 1 | |
386 | return list(ret.values()) | |
387 | ||
388 | ||
389 | @dataclass | |
390 | class SparkBarItem: | |
391 | ||
392 | value: int | |
393 | label: str = None | |
394 | fcolor: str = None | |
395 | bcolor: str = None | |
396 | align: str = "<" | |
397 | fill: str = " " | |
398 | ||
399 | @property | |
400 | def steps(self): | |
401 | return len(BLOCK_HORIZONTAL) | |
402 | ||
403 | def formatted_label(self, total): | |
404 | if self.label is None: | |
405 | return None | |
406 | try: | |
407 | pct = int(round(self.value/total*100, 0)) | |
408 | except: | |
409 | pct = "" | |
410 | ||
411 | return str(self.label).format( | |
412 | value=self.value, | |
413 | pct=pct | |
414 | ) | |
415 | def truncated_label(self, width, total): | |
416 | ||
417 | label = self.formatted_label(total) | |
418 | if not label: | |
419 | return None | |
420 | return ( | |
421 | label[:width-1] + "\N{HORIZONTAL ELLIPSIS}" | |
422 | if len(label) > width | |
423 | else label | |
424 | ) | |
425 | ||
426 | # s = "{label:.{n}}".format( | |
427 | # label=self.formatted_label(total), | |
428 | # n=min(len(label), width), | |
429 | # ) | |
430 | # if len(s) > width: | |
431 | # chars[-1] = "\N{HORIZONTAL ELLIPSIS}" | |
432 | ||
433 | ||
434 | def output(self, width, total, next_color=None): | |
435 | ||
436 | steps_width = width % self.steps if next_color else None | |
437 | chars_width = width // self.steps# - (1 if steps_width else 0) | |
438 | # print(width, chars_width, steps_width) | |
439 | label = self.truncated_label(chars_width, total) | |
440 | if label: | |
441 | chars = "{:{a}{m}.{m}}".format( | |
442 | label, | |
443 | m=max(chars_width, 0), | |
444 | a=self.align or "<", | |
445 | ) | |
446 | # if len(label) > chars_width: | |
447 | # chars[-1] = "\N{HORIZONTAL ELLIPSIS}" | |
448 | else: | |
449 | chars = self.fill * chars_width | |
450 | ||
451 | ||
452 | ||
453 | output = [ | |
454 | ( | |
455 | "%s:%s" %( | |
456 | self.fcolor or DEFAULT_LABEL_COLOR, | |
457 | self.bcolor or DEFAULT_BAR_COLOR), chars | |
458 | ) | |
459 | ] | |
460 | ||
461 | if steps_width: | |
462 | attr = f"{self.bcolor}:{next_color}" | |
463 | output.append( | |
464 | (attr, BLOCK_HORIZONTAL[steps_width]) | |
465 | ) | |
466 | return output | |
467 | ||
468 | ||
469 | class SparkBarWidget(SparkWidget): | |
470 | """ | |
471 | A sparkline-ish horizontal stacked bar widget for Urwid. | |
472 | ||
473 | This widget graphs a set of values in a horizontal bar style. | |
474 | ||
475 | :param items: A list of items to be charted in the widget. Items can be | |
476 | either numeric values or tuples, the latter of which must be of the form | |
477 | ('attribute', value) where attribute is an urwid text attribute and value | |
478 | is a numeric value. | |
479 | ||
480 | :param width: Width of the widget in characters. | |
481 | ||
482 | :param color_scheme: A string or dictionary containing the name of or | |
483 | definition of a color scheme for the widget. | |
484 | """ | |
485 | ||
486 | fill_char = " " | |
487 | ||
488 | def __init__(self, items, width, | |
489 | color_scheme="mono", | |
490 | label_color=None, | |
491 | min_width=None, | |
492 | fit_label=False, | |
493 | normalize=None, | |
494 | fill_char=None, | |
495 | *args, **kwargs): | |
496 | ||
497 | self.items = [ | |
498 | i if isinstance(i, SparkBarItem) else SparkBarItem(i) | |
499 | for i in items | |
500 | ] | |
501 | ||
502 | self.colors = self.parse_scheme(color_scheme) | |
503 | ||
504 | for i in self.items: | |
505 | if not i.bcolor: | |
506 | i.bcolor = self.get_color(i) | |
507 | if fill_char: | |
508 | i.fill_char = fill_char | |
509 | ||
510 | self.width = width | |
511 | self.label_color = label_color | |
512 | self.min_width = min_width | |
513 | self.fit_label = fit_label | |
514 | ||
515 | values = None | |
516 | total = None | |
517 | ||
518 | if normalize: | |
519 | values = [ item.value for item in self.items ] | |
520 | v_min = min(values) | |
521 | v_max = max(values) | |
522 | values = [ | |
523 | int(self.normalize(v, | |
524 | normalize[0], normalize[1], | |
525 | v_min, v_max)) | |
526 | for v in values | |
527 | ] | |
528 | for i, v in enumerate(values): | |
529 | self.items[i].value = v | |
530 | ||
531 | ||
532 | filtered_items = self.items | |
533 | values = [i.value for i in filtered_items] | |
534 | total = sum(values) | |
535 | ||
536 | charwidth = total / self.width | |
537 | ||
538 | self.sparktext = [] | |
539 | ||
540 | position = 0 | |
541 | lastcolor = None | |
542 | ||
543 | values = [i.value for i in filtered_items] | |
544 | ||
545 | # number of steps that can be represented within each screen character | |
546 | # represented by Unicode block characters | |
547 | steps = len(BLOCK_HORIZONTAL) | |
548 | ||
549 | # use a prorportional representation algorithm to distribute the number | |
550 | # of available steps among each bar segment | |
551 | self.bars = bar_widths(values, self.width*steps) | |
552 | ||
553 | if self.min_width or self.fit_label: | |
554 | # make any requested adjustments to bar widths | |
555 | for i in range(len(self.bars)): | |
556 | if self.min_width and self.bars[i] < self.min_width*steps: | |
557 | self.bars[i] = self.min_width*steps | |
558 | if self.fit_label: | |
559 | # need some slack here to compensate for self.bars that don't | |
560 | # begin on a character boundary | |
561 | label_len = len(self.items[i].formatted_label(total))+2 | |
562 | if self.bars[i] < label_len*steps: | |
563 | self.bars[i] = label_len*steps | |
564 | # use modified proportions to calculate new proportions that try | |
565 | # to account for min_width and fit_label | |
566 | self.bars = bar_widths(self.bars, self.width*steps) | |
567 | ||
568 | # filtered_items = [item for i, item in enumerate(self.items) if self.bars[i]] | |
569 | # self.bars = [b for b in self.bars if b] | |
570 | ||
571 | for i, (item, item_next) in enumerate(pairwise(filtered_items)): | |
572 | width = self.bars[i] | |
573 | output = item.output(width, total=total, next_color=item_next.bcolor) | |
574 | self.sparktext += output | |
575 | ||
576 | output = filtered_items[-1].output(self.bars[-1], total=total) | |
577 | self.sparktext += output | |
578 | ||
579 | if not self.sparktext: | |
580 | self.sparktext = "" | |
581 | self.set_text(self.sparktext) | |
582 | super(SparkBarWidget, self).__init__(self.sparktext, *args, **kwargs) | |
583 | ||
584 | def bar_width(self, index): | |
585 | return self.bars[index]//len(BLOCK_HORIZONTAL) | |
586 | ||
587 | ||
588 | __all__ = [ | |
589 | "SparkColumnWidget", "SparkBarWidget", "SparkBarItem", | |
590 | "get_palette_entries", | |
591 | "DEFAULT_LABEL_COLOR", "DEFAULT_LABEL_COLOR_DARK", "DEFAULT_LABEL_COLOR_LIGHT" | |
592 | ] |
141 | 141 | if selected is not None: |
142 | 142 | self.set_active_tab(selected) |
143 | 143 | |
144 | @property | |
145 | def active_tab(self): | |
146 | return self._contents[self.active_tab_idx] | |
147 | ||
144 | 148 | @classmethod |
145 | 149 | def get_palette_entries(cls): |
146 | 150 | return { |
198 | 202 | ), |
199 | 203 | self._w.contents[1][1] |
200 | 204 | ) |
201 | self.active_tab = idx | |
205 | self.active_tab_idx = idx | |
202 | 206 | urwid.signals.emit_signal(self, "activate", self, self._contents[idx]) |
203 | 207 | |
204 | 208 | def get_tab_by_label(self, label): |
217 | 221 | |
218 | 222 | |
219 | 223 | def set_active_next(self): |
220 | if self.active_tab < (len(self._contents)-1): | |
221 | self.set_active_tab(self.active_tab+1) | |
224 | if self.active_tab_idx < (len(self._contents)-1): | |
225 | self.set_active_tab(self.active_tab_idx+1) | |
222 | 226 | else: |
223 | 227 | self.set_active_tab(0) |
224 | 228 | |
225 | 229 | def set_active_prev(self): |
226 | if self.active_tab > 0: | |
227 | self.set_active_tab(self.active_tab-1) | |
230 | if self.active_tab_idx > 0: | |
231 | self.set_active_tab(self.active_tab_idx-1) | |
228 | 232 | else: |
229 | 233 | self.set_active_tab(len(self._contents)-1) |
230 | 234 | |
231 | 235 | def close_active_tab(self): |
232 | if not self.tab_bar.contents[self.active_tab][0].locked: | |
233 | del self.tab_bar.contents[self.active_tab] | |
234 | new_idx = self.active_tab | |
235 | if len(self._contents) <= self.active_tab: | |
236 | if not self.tab_bar.contents[self.active_tab_idx][0].locked: | |
237 | del self.tab_bar.contents[self.active_tab_idx] | |
238 | new_idx = self.active_tab_idx | |
239 | if len(self._contents) <= self.active_tab_idx: | |
236 | 240 | new_idx -= 1 |
237 | del self._contents[self.active_tab] | |
241 | del self._contents[self.active_tab_idx] | |
238 | 242 | self.set_active_tab(new_idx) |
239 | 243 | |
240 | 244 | def _set_active_by_tab(self, tab): |
0 | Metadata-Version: 1.2 | |
0 | Metadata-Version: 2.1 | |
1 | 1 | Name: panwid |
2 | Version: 0.3.0.dev15 | |
2 | Version: 0.3.4 | |
3 | 3 | Summary: Useful widgets for urwid |
4 | 4 | Home-page: https://github.com/tonycpsu/panwid |
5 | 5 | Author: Tony Cebzanov |
6 | 6 | Author-email: tonycpsu@gmail.com |
7 | 7 | License: UNKNOWN |
8 | Description: UNKNOWN | |
9 | 8 | Platform: UNKNOWN |
10 | 9 | Classifier: Environment :: Console |
11 | 10 | Classifier: License :: OSI Approved :: GNU General Public License v2 (GPLv2) |
12 | 11 | Classifier: Intended Audience :: Developers |
13 | 12 | Requires-Python: >=3.6 |
13 | License-File: LICENSE | |
14 | ||
15 | UNKNOWN | |
16 |
3 | 3 | setup.cfg |
4 | 4 | setup.py |
5 | 5 | panwid/__init__.py |
6 | panwid/autocomplete.py | |
6 | 7 | panwid/dropdown.py |
8 | panwid/highlightable.py | |
7 | 9 | panwid/keymap.py |
8 | 10 | panwid/listbox.py |
11 | panwid/progressbar.py | |
9 | 12 | panwid/scroll.py |
13 | panwid/sparkwidgets.py | |
10 | 14 | panwid/tabview.py |
11 | 15 | panwid.egg-info/PKG-INFO |
12 | 16 | panwid.egg-info/SOURCES.txt |