New upstream release.
Debian Janitor
2 years ago
0 | 0 | Metadata-Version: 1.2 |
1 | 1 | Name: panwid |
2 | Version: 0.3.0.dev15 | |
2 | Version: 0.3.3 | |
3 | 3 | Summary: Useful widgets for urwid |
4 | 4 | Home-page: https://github.com/tonycpsu/panwid |
5 | 5 | Author: Tony Cebzanov |
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.3-1) UNRELEASED; urgency=low | |
1 | ||
2 | * New upstream release. | |
3 | ||
4 | -- Debian Janitor <janitor@jelmer.uk> Tue, 01 Jun 2021 17:10:54 -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.3" | |
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 | def __init__(self): | |
44 | ||
45 | self.prompt = urwid.Text(("dropdown_prompt", "> ")) | |
46 | self.text = AutoCompleteEdit("") | |
47 | # self.text.selectable = lambda x: False | |
48 | self.cols = urwid.Columns([ | |
49 | (2, self.prompt), | |
50 | ("weight", 1, self.text) | |
51 | ], dividechars=0) | |
52 | self.cols.focus_position = 1 | |
53 | self.filler = urwid.Filler(self.cols, valign="bottom") | |
54 | urwid.connect_signal(self.text, "postchange", self.text_changed) | |
55 | urwid.connect_signal(self.text, "complete_prev", lambda source: self._emit("complete_prev")) | |
56 | urwid.connect_signal(self.text, "complete_next", lambda source: self._emit("complete_next")) | |
57 | urwid.connect_signal(self.text, "select", lambda source: self._emit("select")) | |
58 | urwid.connect_signal(self.text, "close", lambda source: self._emit("close")) | |
59 | super(AutoCompleteBar, self).__init__(self.filler) | |
60 | ||
61 | def set_prompt(self, text): | |
62 | ||
63 | self.prompt.set_text(("dropdown_prompt", text)) | |
64 | ||
65 | def set_text(self, text): | |
66 | ||
67 | self.text.set_edit_text(text) | |
68 | ||
69 | def text_changed(self, source, text): | |
70 | self._emit("change", text) | |
71 | ||
72 | def confirm(self): | |
73 | self._emit("select") | |
74 | self._emit("close") | |
75 | ||
76 | def cancel(self): | |
77 | self._emit("close") | |
78 | ||
79 | def __len__(self): | |
80 | return len(self.body) | |
81 | ||
82 | def keypress(self, size, key): | |
83 | return super().keypress(size, key) | |
84 | ||
85 | @keymapped() | |
86 | class AutoCompleteMixin(object): | |
87 | ||
88 | auto_complete = None | |
89 | ||
90 | def __init__(self, auto_complete, *args, **kwargs): | |
91 | super().__init__(self.complete_container, *args, **kwargs) | |
92 | if auto_complete is not None: self.auto_complete = auto_complete | |
93 | self.auto_complete_bar = None | |
94 | self.completing = False | |
95 | self.complete_anywhere = False | |
96 | self.case_sensitive = False | |
97 | self.last_complete_pos = None | |
98 | self.complete_string_location = None | |
99 | self.last_filter_text = None | |
100 | ||
101 | if self.auto_complete: | |
102 | self.auto_complete_bar = AutoCompleteBar() | |
103 | ||
104 | ||
105 | urwid.connect_signal( | |
106 | self.auto_complete_bar, "change", | |
107 | lambda source, text: self.complete() | |
108 | ) | |
109 | urwid.connect_signal( | |
110 | self.auto_complete_bar, "complete_prev", | |
111 | lambda source: self.complete_prev() | |
112 | ) | |
113 | urwid.connect_signal( | |
114 | self.auto_complete_bar, "complete_next", | |
115 | lambda source: self.complete_next() | |
116 | ) | |
117 | ||
118 | urwid.connect_signal( | |
119 | self.auto_complete_bar, "select", self.on_complete_select | |
120 | ) | |
121 | urwid.connect_signal( | |
122 | self.auto_complete_bar, "close", self.on_complete_close | |
123 | ) | |
124 | ||
125 | def keypress(self, size, key): | |
126 | return super().keypress(size, key) | |
127 | # key = super().keypress(size, key) | |
128 | # if self.completing and key == "enter": | |
129 | # self.on_complete_select(self) | |
130 | # else: | |
131 | # return key | |
132 | ||
133 | @property | |
134 | def complete_container(self): | |
135 | raise NotImplementedError | |
136 | ||
137 | @property | |
138 | def complete_body(self): | |
139 | raise NotImplementedError | |
140 | ||
141 | @property | |
142 | def complete_items(self): | |
143 | raise NotImplementedError | |
144 | ||
145 | def complete_widget_at_pos(self, pos): | |
146 | return self.complete_body[pos] | |
147 | ||
148 | def complete_set_focus(self, pos): | |
149 | self.focus_position = pos | |
150 | ||
151 | @keymap_command() | |
152 | def complete_prefix(self): | |
153 | self.complete_on() | |
154 | ||
155 | @keymap_command() | |
156 | def complete_substring(self): | |
157 | self.complete_on(anywhere=True) | |
158 | ||
159 | def complete_prev(self): | |
160 | self.complete(step=-1) | |
161 | ||
162 | def complete_next(self): | |
163 | self.complete(step=1) | |
164 | ||
165 | def complete_on(self, anywhere=False, case_sensitive=False): | |
166 | ||
167 | if self.completing: | |
168 | return | |
169 | self.completing = True | |
170 | self.show_bar() | |
171 | if anywhere: | |
172 | self.complete_anywhere = True | |
173 | else: | |
174 | self.complete_anywhere = False | |
175 | ||
176 | if case_sensitive: | |
177 | self.case_sensitive = True | |
178 | else: | |
179 | self.case_sensitive = False | |
180 | ||
181 | def complete_compare_substring(self, search, candidate): | |
182 | try: | |
183 | return candidate.index(search) | |
184 | except ValueError: | |
185 | return None | |
186 | ||
187 | def complete_compare_fn(self, search, candidate): | |
188 | ||
189 | if self.case_sensitive: | |
190 | f = lambda x: str(x) | |
191 | else: | |
192 | f = lambda x: str(x.lower()) | |
193 | ||
194 | if self.complete_anywhere: | |
195 | return self.complete_compare_substring(f(search), f(candidate)) | |
196 | else: | |
197 | return 0 if self.complete_compare_substring(f(search), f(candidate))==0 else None | |
198 | # return f(candidate) | |
199 | ||
200 | ||
201 | @keymap_command() | |
202 | def complete_off(self): | |
203 | ||
204 | if not self.completing: | |
205 | return | |
206 | self.filter_text = "" | |
207 | ||
208 | self.hide_bar() | |
209 | self.completing = False | |
210 | ||
211 | @keymap_command | |
212 | def complete(self, step=None, no_wrap=False): | |
213 | ||
214 | if not self.filter_text: | |
215 | return | |
216 | ||
217 | # if not step and self.filter_text == self.last_filter_text: | |
218 | # return | |
219 | ||
220 | logger.info(f"complete: {self.filter_text}") | |
221 | ||
222 | if self.last_complete_pos: | |
223 | widget = self.complete_widget_at_pos(self.last_complete_pos) | |
224 | if isinstance(widget, HighlightableTextMixin): | |
225 | widget.unhighlight() | |
226 | ||
227 | self.initial_pos = self.complete_body.get_focus()[1] | |
228 | positions = itertools.cycle( | |
229 | self.complete_body.positions(reverse=(step and step < 0)) | |
230 | ) | |
231 | pos = next(positions) | |
232 | while pos != self.initial_pos: | |
233 | logger.info(pos) | |
234 | pos = next(positions) | |
235 | for i in range(abs(step or 0)): | |
236 | pos = next(positions) | |
237 | ||
238 | while True: | |
239 | widget = self.complete_widget_at_pos(pos) | |
240 | complete_index = self.complete_compare_fn(self.filter_text, str(widget)) | |
241 | if complete_index is not None: | |
242 | self.last_complete_pos = pos | |
243 | if isinstance(widget, HighlightableTextMixin): | |
244 | widget.highlight(complete_index, complete_index+len(self.filter_text)) | |
245 | self.complete_set_focus(pos) | |
246 | break | |
247 | pos = next(positions) | |
248 | if pos == self.initial_pos: | |
249 | break | |
250 | ||
251 | logger.info("done") | |
252 | self.last_filter_text = self.filter_text | |
253 | ||
254 | @keymap_command() | |
255 | def cancel(self): | |
256 | logger.debug("cancel") | |
257 | self.complete_container.focus_position = self.selected_button | |
258 | self.close() | |
259 | ||
260 | def close(self): | |
261 | self._emit("close") | |
262 | ||
263 | def show_bar(self): | |
264 | self.complete_container.contents.append( | |
265 | (self.auto_complete_bar, self.complete_container.options("given", 1)) | |
266 | ) | |
267 | # self.box.height -= 1 | |
268 | self.complete_container.focus_position = 1 | |
269 | ||
270 | def hide_bar(self): | |
271 | widget = self.complete_widget_at_pos(self.complete_body.get_focus()[1]) | |
272 | if isinstance(widget, HighlightableTextMixin): | |
273 | widget.unhighlight() | |
274 | self.complete_container.focus_position = 0 | |
275 | del self.complete_container.contents[1] | |
276 | # self.box.height += 1 | |
277 | ||
278 | @property | |
279 | def filter_text(self): | |
280 | return self.auto_complete_bar.text.get_text()[0] | |
281 | ||
282 | @filter_text.setter | |
283 | def filter_text(self, value): | |
284 | return self.auto_complete_bar.set_text(value) | |
285 | ||
286 | def on_complete_select(self, source): | |
287 | widget = self.complete_widget_at_pos(self.complete_body.get_focus()[1]) | |
288 | self.complete_off() | |
289 | self._emit("select", self.last_complete_pos, widget) | |
290 | self._emit("close") | |
291 | ||
292 | def on_complete_close(self, source): | |
293 | self.complete_off() | |
294 | ||
295 | __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 |
26 | 26 | self.padding = padding |
27 | 27 | self.cell_selection = cell_selection |
28 | 28 | self.style = style |
29 | ||
29 | # self.details = None | |
30 | 30 | self.sort = self.table.sort_by |
31 | 31 | self.attr = self.ATTR |
32 | 32 | self.attr_focused = "%s focused" %(self.attr) |
214 | 214 | def __init__(self, row, content, indent=None): |
215 | 215 | |
216 | 216 | self.row = row |
217 | ||
217 | self.contents = content | |
218 | 218 | self.columns = urwid.Columns([ |
219 | 219 | ("weight", 1, content) |
220 | 220 | ]) |
237 | 237 | |
238 | 238 | DIVIDER_CLASS = DataTableDividerBodyCell |
239 | 239 | |
240 | ||
240 | 241 | @property |
241 | 242 | def index(self): |
242 | 243 | return self.content |
243 | 244 | |
244 | 245 | @property |
245 | 246 | def data(self): |
246 | return self.table.get_dataframe_row(self.index) | |
247 | return AttrDict(self.table.get_dataframe_row(self.index)) | |
248 | ||
249 | @property | |
250 | def data_source(self): | |
251 | return self.table.get_dataframe_row_object(self.index) | |
247 | 252 | |
248 | 253 | def __getitem__(self, column): |
249 | 254 | cls = self.table.df[self.index, "_cls"] |
250 | 255 | # 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) | |
256 | if column in self.table.df.columns: | |
257 | # logger.info(f"__getitem__: {column}={self.table.df.get(self.index, column)}") | |
258 | return self.table.df[self.index, column] | |
259 | 259 | 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) | |
260 | raise KeyError | |
261 | # raise Exception(column, self.table.df.columns) | |
265 | 262 | |
266 | 263 | |
267 | 264 | def __setitem__(self, column, value): |
269 | 266 | # logger.info(f"__setitem__: {column}, {value}, {self.table.df[self.index, column]}") |
270 | 267 | |
271 | 268 | def get(self, key, default=None): |
272 | ||
273 | 269 | try: |
274 | 270 | return self[key] |
275 | 271 | except KeyError: |
278 | 274 | @property |
279 | 275 | def details_open(self): |
280 | 276 | # logger.info(f"{self['_details']}") |
281 | return (self.get("_details") or {}).get("open") | |
277 | # raise Exception(self.get([self.index, "_details"], {})) | |
278 | return self.get("_details", {}).get("open", False) | |
282 | 279 | |
283 | 280 | @details_open.setter |
284 | 281 | def details_open(self, value): |
288 | 285 | |
289 | 286 | @property |
290 | 287 | def details_disabled(self): |
291 | return (self.get("_details") or {}).get("disabled") | |
288 | return (not self.table.detail_selectable) or self.get([self.index, "_details"], {}).get("disabled", False) | |
292 | 289 | |
293 | 290 | @details_disabled.setter |
294 | 291 | def details_disabled(self, value): |
300 | 297 | |
301 | 298 | @property |
302 | 299 | def details_focused(self): |
303 | return self.details_open and (self.pile.focus_position > 0) | |
300 | return self.details_open and ( | |
301 | len(self.pile.contents) == 0 | |
302 | or self.pile.focus_position > 0 | |
303 | ) | |
304 | 304 | |
305 | 305 | @details_focused.setter |
306 | 306 | def details_focused(self, value): |
307 | 307 | if value: |
308 | self.pile.focus_position = 1 | |
308 | self.pile.focus_position = len(self.pile.contents)-1 | |
309 | 309 | else: |
310 | 310 | self.pile.focus_position = 0 |
311 | 311 | |
312 | @property | |
313 | def details(self): | |
314 | if not getattr(self, "_details", None): | |
315 | ||
316 | content = self.table.detail_fn((self.data_source)) | |
317 | logger.debug(f"open_details: {type(content)}") | |
318 | if not content: | |
319 | return | |
320 | ||
321 | # self.table.header.render( (self.table.width,) ) | |
322 | indent_width = 0 | |
323 | visible_count = itertools.count() | |
324 | ||
325 | def should_indent(x): | |
326 | if (isinstance(self.table.detail_hanging_indent, int) | |
327 | and (x[2] is None or x[2] <= self.table.detail_hanging_indent)): | |
328 | return True | |
329 | elif (isinstance(self.table.detail_hanging_indent, str) | |
330 | and x[1].name != self.table.detail_hanging_indent): | |
331 | return True | |
332 | return False | |
333 | ||
334 | if self.table.detail_hanging_indent: | |
335 | indent_width = sum([ | |
336 | x[1].width if not x[1].hide else 0 | |
337 | for x in itertools.takewhile( | |
338 | should_indent, | |
339 | [ (i, c, next(visible_count) if not c.hide else None) | |
340 | for i, c in enumerate(self.table._columns) ] | |
341 | ) | |
342 | ]) | |
343 | ||
344 | self._details = DataTableDetails(self, content, indent_width) | |
345 | return self._details | |
346 | ||
347 | ||
312 | 348 | def open_details(self): |
313 | 349 | |
314 | if not self.table.detail_fn or len(self.pile.contents) > 1: | |
350 | if not self.table.detail_fn or not self.details or self.details_open: | |
315 | 351 | 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) | |
352 | ||
353 | if len(self.pile.contents) > 1: | |
354 | return | |
355 | ||
356 | if self.table.detail_replace: | |
357 | self.pile.contents[0] = (urwid.Filler(urwid.Text("")), self.pile.options("given", 0)) | |
358 | ||
342 | 359 | self.pile.contents.append( |
343 | 360 | (self.details, self.pile.options("pack")) |
344 | 361 | ) |
362 | ||
363 | self.details_focused = True | |
364 | if not self["_details"]: | |
365 | self["_details"] = AttrDict() | |
345 | 366 | self["_details"]["open"] = True |
346 | 367 | |
347 | 368 | |
348 | 369 | def close_details(self): |
349 | 370 | if not self.table.detail_fn or not self.details_open: |
350 | 371 | return |
372 | # raise Exception | |
351 | 373 | 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] | |
374 | ||
375 | if self.table.detail_replace: | |
376 | self.pile.contents[0] = (self.box, self.pile.options("pack")) | |
377 | ||
378 | # del self.pile.contents[:] | |
379 | # self.pile.contents.append( | |
380 | # (self.box, self.pile.options("pack")) | |
381 | # ) | |
382 | if len(self.pile.contents) >= 2: | |
383 | del self.pile.contents[1] | |
356 | 384 | |
357 | 385 | def toggle_details(self): |
358 | 386 | |
360 | 388 | self.close_details() |
361 | 389 | else: |
362 | 390 | 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 | 391 | |
376 | 392 | |
377 | 393 | def set_attr(self, attr): |
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): |
20 | 22 | |
50 | 52 | return self.decoration_width + len(self.label_text) |
51 | 53 | |
52 | 54 | |
53 | class DropdownItem(urwid.WidgetWrap): | |
55 | class DropdownItem(HighlightableTextMixin, urwid.WidgetWrap): | |
54 | 56 | |
55 | 57 | signals = ["click"] |
56 | 58 | |
60 | 62 | self.label_text = label |
61 | 63 | self.value = value |
62 | 64 | self.margin = margin |
63 | # self.button = urwid.Button(("dropdown_text", self.label_text)) | |
64 | 65 | self.button = DropdownButton( |
65 | 66 | self.label_text, |
66 | 67 | left_chars=left_chars, right_chars=right_chars |
67 | 68 | ) |
68 | 69 | self.padding = urwid.Padding(self.button, width=("relative", 100), |
69 | 70 | left=self.margin, right=self.margin) |
70 | # self.padding = self.button | |
71 | 71 | |
72 | 72 | |
73 | 73 | self.attr = urwid.AttrMap(self.padding, {None: "dropdown_text"}) |
83 | 83 | ) |
84 | 84 | |
85 | 85 | @property |
86 | def highlight_source(self): | |
87 | return self.label_text | |
88 | ||
89 | @property | |
90 | def highlightable_attr_normal(self): | |
91 | return "dropdown_text" | |
92 | ||
93 | @property | |
94 | def highlightable_attr_highlight(self): | |
95 | return "dropdown_highlight" | |
96 | ||
97 | def on_highlight(self): | |
98 | self.set_text(self.highlight_content) | |
99 | ||
100 | def on_unhighlight(self): | |
101 | self.set_text(self.highlight_source) | |
102 | ||
103 | @property | |
86 | 104 | def width(self): |
87 | 105 | return self.button.width + 2*self.margin |
88 | 106 | |
103 | 121 | def label(self): |
104 | 122 | return self.button.label |
105 | 123 | |
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): | |
124 | def set_text(self, text): | |
125 | self.button.set_label(text) | |
126 | ||
129 | 127 | @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): | |
128 | class DropdownDialog(AutoCompleteMixin, urwid.WidgetWrap, KeymapMovementMixin): | |
176 | 129 | |
177 | 130 | signals = ["select", "close"] |
178 | 131 | |
181 | 134 | label = None |
182 | 135 | border = None |
183 | 136 | scrollbar = False |
184 | auto_complete = False | |
185 | 137 | margin = 0 |
186 | 138 | max_height = None |
187 | 139 | |
194 | 146 | border=False, |
195 | 147 | margin = None, |
196 | 148 | scrollbar=None, |
197 | auto_complete=None, | |
198 | 149 | left_chars=None, |
199 | 150 | right_chars=None, |
200 | 151 | left_chars_top=None, |
201 | 152 | rigth_chars_top=None, |
202 | 153 | max_height=None, |
203 | keymap = {} | |
154 | keymap = {}, | |
155 | **kwargs | |
204 | 156 | ): |
205 | 157 | self.drop_down = drop_down |
206 | 158 | self.items = items |
208 | 160 | if border is not None: self.border = border |
209 | 161 | if margin is not None: self.margin = margin |
210 | 162 | if scrollbar is not None: self.scrollbar = scrollbar |
211 | if auto_complete is not None: self.auto_complete = auto_complete | |
212 | 163 | 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 | 164 | |
221 | 165 | self.selected_button = 0 |
222 | 166 | buttons = [] |
236 | 180 | urwid.connect_signal( |
237 | 181 | self.dropdown_buttons, |
238 | 182 | '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) | |
183 | lambda source, selection: self.on_complete_select(source) | |
184 | ) | |
185 | ||
245 | 186 | kwargs = {} |
246 | 187 | if self.label is not None: |
247 | 188 | kwargs["title"] = self.label |
248 | 189 | kwargs["tlcorner"] = u"\N{BOX DRAWINGS LIGHT DOWN AND HORIZONTAL}" |
249 | 190 | kwargs["trcorner"] = u"\N{BOX DRAWINGS LIGHT DOWN AND LEFT}" |
250 | 191 | |
251 | w = self.fill | |
192 | w = self.dropdown_buttons | |
252 | 193 | if self.border: |
253 | 194 | 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 | 195 | |
266 | 196 | self.pile = urwid.Pile([ |
267 | 197 | ("weight", 1, w), |
268 | 198 | ]) |
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) | |
199 | super().__init__(self.pile) | |
200 | ||
201 | @property | |
202 | def complete_container(self): | |
203 | return self.pile | |
204 | ||
205 | @property | |
206 | def complete_body(self): | |
207 | return self.body | |
208 | ||
209 | @property | |
210 | def complete_items(self): | |
211 | return self.body | |
283 | 212 | |
284 | 213 | @property |
285 | 214 | def max_item_width(self): |
324 | 253 | def selection(self): |
325 | 254 | return self.dropdown_buttons.selection |
326 | 255 | |
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") | |
256 | # def on_complete_select(self, pos, widget): | |
257 | ||
258 | # # logger.debug("select_button: %s" %(button)) | |
259 | # label = widget.label | |
260 | # value = widget.value | |
261 | # self.selected_button = self.focus_position | |
262 | # self.complete_off() | |
263 | # self._emit("select", widget) | |
264 | # self._emit("close") | |
336 | 265 | |
337 | 266 | # 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) | |
267 | # return super(DropdownDialog, self).keypress(size, key) | |
348 | 268 | |
349 | 269 | |
350 | 270 | @property |
353 | 273 | return None |
354 | 274 | return self.body[self.focus_position].value |
355 | 275 | |
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 | 276 | @keymapped() |
471 | 277 | class Dropdown(urwid.PopUpLauncher): |
472 | 278 | # Based in part on SelectOne widget from |
474 | 280 | |
475 | 281 | signals = ["change"] |
476 | 282 | |
283 | auto_complete = None | |
477 | 284 | label = None |
478 | 285 | empty_label = u"\N{EMPTY SET}" |
479 | 286 | margin = 0 |
487 | 294 | margin = None, |
488 | 295 | left_chars = None, right_chars = None, |
489 | 296 | left_chars_top = None, right_chars_top = None, |
490 | auto_complete = False, | |
297 | auto_complete = None, | |
491 | 298 | max_height = 10, |
492 | 299 | # keymap = {} |
493 | 300 | ): |
500 | 307 | |
501 | 308 | self.border = border |
502 | 309 | self.scrollbar = scrollbar |
503 | self.auto_complete = auto_complete | |
310 | if auto_complete is not None: self.auto_complete = auto_complete | |
311 | ||
504 | 312 | # self.keymap = keymap |
505 | 313 | |
506 | 314 | if margin: |
544 | 352 | urwid.connect_signal( |
545 | 353 | self.pop_up, |
546 | 354 | "select", |
547 | lambda souce, selection: self.select(selection) | |
355 | lambda souce, pos, selection: self.select(selection) | |
548 | 356 | ) |
549 | 357 | |
550 | 358 | urwid.connect_signal( |
551 | 359 | self.pop_up, |
552 | 360 | "close", |
553 | lambda button: self.close_pop_up() | |
361 | lambda source: self.close_pop_up() | |
554 | 362 | ) |
555 | 363 | |
556 | 364 | if self.default is not None: |
557 | 365 | try: |
558 | 366 | if isinstance(self.default, str): |
559 | self.select_label(self.default) | |
367 | try: | |
368 | self.select_label(self.default) | |
369 | except ValueError: | |
370 | pass | |
560 | 371 | else: |
561 | 372 | raise StopIteration |
562 | 373 | except StopIteration: |
568 | 379 | if len(self): |
569 | 380 | self.select(self.selection) |
570 | 381 | else: |
571 | self.button.set_label(("dropdown_text", self.empty_label)) | |
382 | self.button.set_text(("dropdown_text", self.empty_label)) | |
572 | 383 | |
573 | 384 | cols = [ (self.button_width, self.button) ] |
574 | 385 | |
683 | 494 | super(Dropdown, self).open_pop_up() |
684 | 495 | |
685 | 496 | def close_pop_up(self): |
686 | super(Dropdown, self).close_pop_up() | |
497 | super().close_pop_up() | |
687 | 498 | |
688 | 499 | def get_pop_up_parameters(self): |
689 | 500 | return {'left': (len(self.label) + 2 if self.label else 0), |
698 | 509 | |
699 | 510 | @focus_position.setter |
700 | 511 | def focus_position(self, pos): |
512 | if pos == self.focus_position: | |
513 | return | |
701 | 514 | # self.select_index(pos) |
702 | 515 | old_pos = self.focus_position |
703 | 516 | self.pop_up.selected_button = self.pop_up.focus_position = pos |
743 | 556 | |
744 | 557 | f = lambda x: x |
745 | 558 | 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] | |
559 | f = lambda x: x.lower() if isinstance(x, str) else x | |
560 | ||
561 | try: | |
562 | index = next(itertools.dropwhile( | |
563 | lambda x: f(x[1]) != f(label), | |
564 | enumerate((self._items.keys()) | |
565 | ) | |
566 | ))[0] | |
567 | except StopIteration: | |
568 | raise ValueError | |
753 | 569 | self.focus_position = index |
754 | 570 | |
755 | 571 | |
819 | 635 | |
820 | 636 | def select(self, button): |
821 | 637 | logger.debug("select: %s" %(button)) |
822 | self.button.set_label(("dropdown_text", button.label)) | |
638 | self.button.set_text(("dropdown_text", button.label)) | |
823 | 639 | self.pop_up.dropdown_buttons.listbox.set_focus_valign("top") |
824 | 640 | # if old_pos != pos: |
825 | 641 | self._emit("change", self.selected_label, self.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 | if isinstance(cmd, tuple): | |
80 | if len(cmd) == 3: | |
81 | (cmd, args, kwargs) = cmd | |
82 | elif len(cmd) == 2: | |
83 | (cmd, args) = cmd | |
84 | else: | |
85 | raise Exception | |
86 | elif isinstance(cmd, str): | |
87 | cmd = cmd.replace(" ", "_") | |
88 | else: | |
89 | return None | |
90 | ||
91 | if hasattr(self, cmd): | |
92 | fn_name = cmd | |
93 | else: | |
94 | try: | |
95 | fn_name = self.KEYMAP_MAPPING[cmd] | |
96 | except KeyError: | |
97 | raise KeyError(cmd, self.KEYMAP_MAPPING, type(self)) | |
98 | ||
99 | f = getattr(self, fn_name) | |
100 | ret = f(*args, **kwargs) | |
101 | if asyncio.iscoroutine(ret): | |
102 | asyncio.get_event_loop().create_task(ret) | |
103 | return None | |
104 | ||
105 | cls._keymap_command = keymap_command | |
106 | ||
107 | def keypress_decorator(func): | |
108 | ||
109 | def keypress(self, size, key): | |
110 | logger.debug(f"{cls} wrapped keypress: {key}, {cls.KEYMAP_SCOPE()}, {self.KEYMAP_MERGED.get(cls.KEYMAP_SCOPE(), {}).keys()}") | |
111 | ||
112 | if key and callable(func): | |
113 | logger.debug(f"{cls} wrapped keypress, key: {key}, calling orig: {func}") | |
114 | key = func(self, size, key) | |
115 | if key: | |
116 | logger.debug(f"{cls} wrapped keypress, key: {key}, calling super: {super(cls, self).keypress}") | |
117 | key = super(cls, self).keypress(size, key) | |
118 | keymap_combined = dict(self.KEYMAP_MERGED, **KEYMAP_GLOBAL) | |
119 | if key and keymap_combined.get(cls.KEYMAP_SCOPE(), {}).get(key, None): | |
120 | cmd = keymap_combined[cls.KEYMAP_SCOPE()][key] | |
121 | if isinstance(cmd, str) and cmd.startswith("keypress "): | |
122 | new_key = cmd.replace("keypress ", "").strip() | |
123 | logger.debug(f"{cls} remap {key} => {new_key}") | |
124 | key = new_key | |
125 | else: | |
126 | logger.debug(f"{cls} wrapped keypress, key: {key}, calling keymap command") | |
127 | key = self._keymap_command(cmd) | |
128 | return key | |
129 | ||
130 | return keypress | |
131 | ||
132 | cls.keypress = keypress_decorator(getattr(cls, "keypress", None)) | |
117 | 133 | return cls |
134 | ||
118 | 135 | return wrapper |
136 | ||
137 | ||
119 | 138 | |
120 | 139 | |
121 | 140 | @keymapped() |
122 | 141 | class KeymapMovementMixin(object): |
142 | ||
143 | @classmethod | |
144 | def KEYMAP_SCOPE(cls): | |
145 | return "movement" | |
123 | 146 | |
124 | 147 | def cycle_position(self, n): |
125 | 148 |
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 | 0 | Metadata-Version: 1.2 |
1 | 1 | Name: panwid |
2 | Version: 0.3.0.dev15 | |
2 | Version: 0.3.3 | |
3 | 3 | Summary: Useful widgets for urwid |
4 | 4 | Home-page: https://github.com/tonycpsu/panwid |
5 | 5 | Author: Tony Cebzanov |
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 |