Codebase list python-panwid / fresh-snapshots/main
New upstream release. Debian Janitor 2 years ago
23 changed file(s) with 2282 addition(s) and 700 deletion(s). Raw diff Collapse all Expand all
0 Metadata-Version: 1.2
0 Metadata-Version: 2.1
11 Name: panwid
2 Version: 0.3.0.dev15
2 Version: 0.3.4
33 Summary: Useful widgets for urwid
44 Home-page: https://github.com/tonycpsu/panwid
55 Author: Tony Cebzanov
66 Author-email: tonycpsu@gmail.com
77 License: UNKNOWN
8 Description: UNKNOWN
98 Platform: UNKNOWN
109 Classifier: Environment :: Console
1110 Classifier: License :: OSI Approved :: GNU General Public License v2 (GPLv2)
1211 Classifier: Intended Audience :: Developers
1312 Requires-Python: >=3.6
13 License-File: LICENSE
14
15 UNKNOWN
16
22
33 A collection of widgets for [Urwid](https://urwid.org/).
44
5 Currently consists of the following:
5 Currently consists of the following sub-modules:
66
7 ## Dropdown ##
7 ## autocomplete ##
88
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.
1011
11 [![asciicast](https://asciinema.org/a/m23L8xPJsTQRxzOCwvc1SuduN.png)](https://asciinema.org/a/m23L8xPJsTQRxzOCwvc1SuduN?autoplay=1)
12
13 ## DataTable ##
12 ## datatable ##
1413
1514 Widget for displaying tabular data.
1615
2120
2221 [![asciicast](https://asciinema.org/a/iRbvnuv7DERhZrdKKBfpGtXqw.png)](https://asciinema.org/a/iRbvnuv7DERhZrdKKBfpGtXqw?autoplay=1)
2322
24 ## ScrollingListbox ##
23 ## dialog ##
2524
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.
2926
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 ##
3161
3262 A container widget that allows selection of content via tab handles.
3363
0 python-panwid (0.3.4-1) UNRELEASED; urgency=low
1
2 * New upstream release.
3
4 -- Debian Janitor <janitor@jelmer.uk> Sat, 16 Oct 2021 05:18:29 -0000
5
06 python-panwid (0.3.0.dev15-2) unstable; urgency=medium
17
28 * 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 *
22 from . import datatable
33 from .datatable import *
44 from . import dialog
55 from .dialog import *
66 from . import dropdown
77 from .dropdown import *
8 from . import highlightable
9 from .highlightable import *
810 from . import keymap
911 from .keymap import *
12 from . import listbox
13 from .listbox import *
1014 from . import scroll
1115 from .scroll import *
16 from . import sparkwidgets
17 from .sparkwidgets import *
1218 from . import tabview
1319 from .tabview import *
1420
15 __version__ = "0.3.0.dev15"
21 __version__ = "0.3.4"
1622
1723 __all__ = (
18 listbox.__all__
24 autocomplete.__all__
1925 + datatable.__all__
2026 + dialog.__all__
2127 + dropdown.__all__
28 + highlightable.__all__
2229 + keymap.__all__
30 + listbox.__all__
2331 + scroll.__all__
32 + sparkwidgets.__all__
2433 + tabview.__all__
2534 )
0 import logging
1 logger = logging.getLogger(__name__)
2 import itertools
3
4 import urwid
5
6 from .highlightable import HighlightableTextMixin
7 from .keymap import *
8
9 @keymapped()
10 class AutoCompleteEdit(urwid.Edit):
11
12 signals = ["select", "close", "complete_next", "complete_prev"]
13
14 KEYMAP = {
15 "enter": "confirm",
16 "esc": "cancel"
17 }
18
19 def clear(self):
20 self.set_edit_text("")
21
22 def confirm(self):
23 self._emit("select")
24 self._emit("close")
25
26 def cancel(self):
27 self._emit("close")
28
29 def complete_next(self):
30 self._emit("complete_next")
31
32 def complete_prev(self):
33 self._emit("complete_prev")
34
35 def keypress(self, size, key):
36 return super().keypress(size, key)
37
38 @keymapped()
39 class AutoCompleteBar(urwid.WidgetWrap):
40
41 signals = ["change", "complete_prev", "complete_next", "select", "close"]
42
43 prompt_attr = "dropdown_prompt"
44
45 def __init__(self, prompt_attr=None):
46
47 self.prompt_attr = prompt_attr or self.prompt_attr
48 self.prompt = urwid.Text((self.prompt_attr, "> "))
49 self.text = AutoCompleteEdit("")
50 # self.text.selectable = lambda x: False
51 self.cols = urwid.Columns([
52 (2, self.prompt),
53 ("weight", 1, self.text)
54 ], dividechars=0)
55 self.cols.focus_position = 1
56 self.filler = urwid.Filler(self.cols, valign="bottom")
57 urwid.connect_signal(self.text, "postchange", self.text_changed)
58 urwid.connect_signal(self.text, "complete_prev", lambda source: self._emit("complete_prev"))
59 urwid.connect_signal(self.text, "complete_next", lambda source: self._emit("complete_next"))
60 urwid.connect_signal(self.text, "select", lambda source: self._emit("select"))
61 urwid.connect_signal(self.text, "close", lambda source: self._emit("close"))
62 super(AutoCompleteBar, self).__init__(self.filler)
63
64 def set_prompt(self, text):
65
66 self.prompt.set_text((self.prompt_attr, text))
67
68 def set_text(self, text):
69
70 self.text.set_edit_text(text)
71
72 def text_changed(self, source, text):
73 self._emit("change", text)
74
75 def confirm(self):
76 self._emit("select")
77 self._emit("close")
78
79 def cancel(self):
80 self._emit("close")
81
82 def __len__(self):
83 return len(self.body)
84
85 def keypress(self, size, key):
86 return super().keypress(size, key)
87
88 @keymapped()
89 class AutoCompleteMixin(object):
90
91 auto_complete = None
92 prompt_attr = "dropdown_prompt"
93
94 def __init__(self, auto_complete, prompt_attr=None, *args, **kwargs):
95 super().__init__(self.complete_container, *args, **kwargs)
96 if auto_complete is not None: self.auto_complete = auto_complete
97 if prompt_attr is not None:
98 self.prompt_attr = prompt_attr
99 self.auto_complete_bar = None
100 self.completing = False
101 self.complete_anywhere = False
102 self.case_sensitive = False
103 self.last_complete_pos = None
104 self.complete_string_location = None
105 self.last_filter_text = None
106
107 if self.auto_complete:
108 self.auto_complete_bar = AutoCompleteBar(prompt_attr=self.prompt_attr)
109
110
111 urwid.connect_signal(
112 self.auto_complete_bar, "change",
113 lambda source, text: self.complete()
114 )
115 urwid.connect_signal(
116 self.auto_complete_bar, "complete_prev",
117 lambda source: self.complete_prev()
118 )
119 urwid.connect_signal(
120 self.auto_complete_bar, "complete_next",
121 lambda source: self.complete_next()
122 )
123
124 urwid.connect_signal(
125 self.auto_complete_bar, "select", self.on_complete_select
126 )
127 urwid.connect_signal(
128 self.auto_complete_bar, "close", self.on_complete_close
129 )
130
131 def keypress(self, size, key):
132 return super().keypress(size, key)
133 # key = super().keypress(size, key)
134 # if self.completing and key == "enter":
135 # self.on_complete_select(self)
136 # else:
137 # return key
138
139 @property
140 def complete_container(self):
141 raise NotImplementedError
142
143 @property
144 def complete_container_position(self):
145 return 1
146
147 @property
148 def complete_body_position(self):
149 return 0
150
151 @property
152 def complete_body(self):
153 raise NotImplementedError
154
155 @property
156 def complete_items(self):
157 raise NotImplementedError
158
159
160 def complete_widget_at_pos(self, pos):
161 return self.complete_body[pos]
162
163 def complete_set_focus(self, pos):
164 self.focus_position = pos
165
166 @keymap_command()
167 def complete_prefix(self):
168 self.complete_on()
169
170 @keymap_command()
171 def complete_substring(self):
172 self.complete_on(anywhere=True)
173
174 def complete_prev(self):
175 self.complete(step=-1)
176
177 def complete_next(self):
178 self.complete(step=1)
179
180 def complete_on(self, anywhere=False, case_sensitive=False):
181
182 if self.completing:
183 return
184 self.completing = True
185 self.show_bar()
186 if anywhere:
187 self.complete_anywhere = True
188 else:
189 self.complete_anywhere = False
190
191 if case_sensitive:
192 self.case_sensitive = True
193 else:
194 self.case_sensitive = False
195
196 def complete_compare_substring(self, search, candidate):
197 try:
198 return candidate.index(search)
199 except ValueError:
200 return None
201
202 def complete_compare_fn(self, search, candidate):
203
204 if self.case_sensitive:
205 f = lambda x: str(x)
206 else:
207 f = lambda x: str(x.lower())
208
209 if self.complete_anywhere:
210 return self.complete_compare_substring(f(search), f(candidate))
211 else:
212 return 0 if self.complete_compare_substring(f(search), f(candidate))==0 else None
213 # return f(candidate)
214
215
216 @keymap_command()
217 def complete_off(self):
218
219 if not self.completing:
220 return
221 self.filter_text = ""
222
223 self.hide_bar()
224 self.completing = False
225
226 @keymap_command
227 def complete(self, step=None, no_wrap=False):
228
229 if not self.filter_text:
230 return
231
232 # if not step and self.filter_text == self.last_filter_text:
233 # return
234
235 logger.info(f"complete: {self.filter_text}")
236
237 if self.last_complete_pos:
238 widget = self.complete_widget_at_pos(self.last_complete_pos)
239 if isinstance(widget, HighlightableTextMixin):
240 widget.unhighlight()
241
242 self.initial_pos = self.complete_body.get_focus()[1]
243 positions = itertools.cycle(
244 self.complete_body.positions(reverse=(step and step < 0))
245 )
246 pos = next(positions)
247 while pos != self.initial_pos:
248 logger.info(pos)
249 pos = next(positions)
250 for i in range(abs(step or 0)):
251 pos = next(positions)
252
253 while True:
254 widget = self.complete_widget_at_pos(pos)
255 complete_index = self.complete_compare_fn(self.filter_text, str(widget))
256 if complete_index is not None:
257 self.last_complete_pos = pos
258 if isinstance(widget, HighlightableTextMixin):
259 widget.highlight(complete_index, complete_index+len(self.filter_text))
260 self.complete_set_focus(pos)
261 break
262 pos = next(positions)
263 if pos == self.initial_pos:
264 break
265
266 logger.info("done")
267 self.last_filter_text = self.filter_text
268
269 @keymap_command()
270 def cancel(self):
271 logger.debug("cancel")
272 self.complete_container.focus_position = self.selected_button
273 self.close()
274
275 def close(self):
276 self._emit("close")
277
278 def show_bar(self):
279 pos = self.complete_container_pos
280 self.complete_container.contents[pos:pos+1] += [(
281 self.auto_complete_bar,
282 self.complete_container.options("given", 1)
283 )]
284 # self.box.height -= 1
285 self.complete_container.focus_position = pos
286
287 def hide_bar(self):
288 pos = self.complete_container_pos
289 widget = self.complete_widget_at_pos(self.complete_body.get_focus()[1])
290 if isinstance(widget, HighlightableTextMixin):
291 widget.unhighlight()
292 self.complete_container.focus_position = self.complete_body_position
293 del self.complete_container.contents[pos]
294 # self.box.height += 1
295
296 @property
297 def filter_text(self):
298 return self.auto_complete_bar.text.get_text()[0]
299
300 @filter_text.setter
301 def filter_text(self, value):
302 return self.auto_complete_bar.set_text(value)
303
304 def on_complete_select(self, source):
305 widget = self.complete_widget_at_pos(self.complete_body.get_focus()[1])
306 self.complete_off()
307 self._emit("select", self.last_complete_pos, widget)
308 self._emit("close")
309
310 def on_complete_close(self, source):
311 self.complete_off()
312
313 __all__ = ["AutoCompleteMixin"]
3434
3535 self._width = None
3636 self._height = None
37 self.contents_rows = None
3738 # self.width = None
3839
3940 if column.padding:
4647 # logger.info(f"{self.column.name}, {self.column.width}, {self.column.align}")
4748 # if self.row.row_height is not None:
4849
49 self.filler = urwid.Filler(self.contents)
50 # self.filler = urwid.Filler(self.contents)
5051
5152 self.normal_attr_map = {}
5253 self.highlight_attr_map = {}
6162 self.highlight_focus_map.update(self.table.highlight_focus_map)
6263
6364 self.attrmap = urwid.AttrMap(
64 self.filler,
65 # self.filler,
66 urwid.Filler(self.contents) if "flow" in self.contents.sizing() else self.contents,
6567 attr_map = self.normal_attr_map,
6668 focus_map = self.normal_focus_map
6769 )
171173 maxrow = size[1]
172174 self._height = maxrow
173175 else:
174 contents_rows = self.contents.rows(size, focus)
176 self.contents_rows = self.contents.rows(size, focus)
175177 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()
183183 return super().render(size, focus)
184184
185185 @property
190190 def height(self):
191191 return self._height
192192
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
202200
203201 class DataTableDividerCell(DataTableCell):
204202
231229
232230 def update_contents(self):
233231
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
239264 )
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)
251266
252267 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
253280
254281
255282 class DataTableDividerBodyCell(DataTableDividerCell, DataTableBodyCell):
280307 def update_contents(self):
281308
282309 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
284314
285315 label = (self.label
286316 if isinstance(self.label, urwid.Widget)
368398 if sort and sort[0] == self.column.name:
369399 direction = self.DESCENDING_SORT_MARKER if sort[1] else self.ASCENDING_SORT_MARKER
370400 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("")
373403
374404
375405 class DataTableDividerHeaderCell(DataTableDividerCell, DataTableHeaderCell):
6464 self.width = self.initial_width
6565
6666 def __repr__(self):
67 return f"<{self.__class__.__name__}: {self.name}>"
67 return f"<{self.__class__.__name__}: {self.name} ({self.width}, {self.sizing})>"
6868
6969 def width_with_padding(self, table_padding=None):
7070 padding = 0
9696 truncate=False,
9797 format_fn=None,
9898 decoration_fn=None,
99 format_record = None, # format_fn is passed full row data
10099 sort_key = None, sort_reverse=False,
101100 sort_icon = None,
102101 footer_fn = None, footer_arg = "values", **kwargs):
118117 self.truncate = truncate
119118 self.format_fn = format_fn
120119 self.decoration_fn = decoration_fn
121 self.format_record = format_record
122120 self.sort_key = sort_key
123121 self.sort_reverse = sort_reverse
124122 self.sort_icon = sort_icon
161159
162160 # First, call the format function for the column, if there is one
163161 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
170169 return self.format(v)
171170
172171
88
99 def __init__(self, data=None, columns=None, index=None, index_name="index", sort=None):
1010
11 self.sidecar_columns = []
1112 if columns and not index_name in columns:
1213 columns.insert(0, index_name)
1314 columns += self.DATA_TABLE_COLUMNS
4647 "..." if len(self.index) > n else "",
4748 df.head(n)))
4849
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
5184 data_columns += [
5285 c for c in self.columns
5386 if c not in data_columns
87 and c not in self.sidecar_columns
5488 and c != self.index_name
5589 and c not in self.DATA_TABLE_COLUMNS
5690 ]
57 data_columns += ["_cls", "_details"]
91 data_columns += ["_cls"]
5892
5993 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
69107 return data
70108
71 def update_rows(self, rows, limit=None):
72109
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)
74116 # 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"])
75120 # if not "_details" in data:
76121 # data["_details"] = [{"open": False, "disabled": False}] * len(rows)
77122
78 if not limit:
123 if replace:
79124 if len(rows):
80125 indexes = [x for x in self.index if x not in data.get(self.index_name, [])]
81126 if len(indexes):
85130
86131 # logger.info(f"update_rowGs: {self.index}, {data[self.index_name]}")
87132
88 if not len(rows):
89 return []
90
91133 if self.index_name not in data:
92134 index = list(range(len(self), len(self) + len(rows)))
93135 data[self.index_name] = index
95137 index = data[self.index_name]
96138
97139 for c in data.keys():
98 try:
140 # try:
141 # raise Exception(data[self.index_name], c, data[c])
99142 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
104151 return data.get(self.index_name, [])
105152
106153 def append_rows(self, rows):
22 import urwid
33 import urwid_utils.palette
44 from ..listbox import ScrollingListBox
5 from orderedattrdict import OrderedDict
5 from orderedattrdict import AttrDict
66 from collections.abc import MutableMapping
77 import itertools
88 import copy
1111 from dataclasses import *
1212 import typing
1313
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
1428 from .dataframe import *
1529 from .rows import *
1630 from .columns import *
2943 class DataTable(urwid.WidgetWrap, urwid.listbox.ListWalker):
3044
3145
32 signals = ["select", "refresh", "focus", "blur",
33 # "focus", "unfocus", "row_focus", "row_unfocus",
46 signals = ["select", "refresh", "focus", "blur", "end", "requery",
3447 "drag_start", "drag_continue", "drag_stop"]
3548
3649 ATTR = "table"
6174
6275 detail_fn = None
6376 detail_selectable = False
77 detail_replace = None
78 detail_auto_open = False
6479 detail_hanging_indent = None
6580
6681 ui_sort = True
6782 ui_resize = True
68 row_attr_fn = None
83 row_attr_fn = lambda self, position, data, row: ""
84
85 with_sidecar = False
6986
7087 attr_map = {}
7188 focus_map = {}
7592 highlight_focus_map2 = {}
7693
7794 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):
96114
97115 self._focus = 0
98116 self.page = 0
160178
161179 if detail_fn is not None: self.detail_fn = detail_fn
162180 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
163183 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
165190 if limit:
166191 self.limit = limit
167192
170195 self._height = None
171196 self._initialized = False
172197 self._message_showing = False
173
198 self.pagination_cursor = None
174199 self.filters = None
175200 self.filtered_rows = list()
176201
178203 self._columns = list(intersperse_divider(self._columns, self.divider))
179204 # self._columns = intersperse(self.divider, self._columns)
180205
206 # FIXME: pass reference
181207 for c in self._columns:
182208 c.table = self
183209
187213 index_name = self.index or None
188214 # sorted=True,
189215 )
190 # if self.index:
191 # kwargs["index_name"] = self.index
192
193 # self.df = DataTableDataFrame(**kwargs)
216
194217 self.df = DataTableDataFrame(
195218 columns = self.column_names,
196219 sort=False,
197220 index_name = self.index or None
198221 )
199
200222 self.pile = urwid.Pile([])
201223 self.listbox = ScrollingListBox(
202224 self, infinite=self.limit,
203225 with_scrollbar = self.with_scrollbar,
204226 row_count_fn = self.row_count
205227 )
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 # )
214228 urwid.connect_signal(
215229 self.listbox, "drag_start",
216230 lambda source, drag_from: urwid.signals.emit_signal(
288302 self.attr = urwid.AttrMap(
289303 self.pile,
290304 attr_map = self.attr_map,
291 # focus_map = self.focus_map
292305 )
293306 super(DataTable, self).__init__(self.attr)
294307
521534 return index
522535
523536 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()
525542 self._emit("blur", self._focus)
526543 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()
527547 self._emit("focus", position)
528548 self._modified()
529549
540560 # logger.debug("walker get: %d" %(position))
541561 if isinstance(position, slice):
542562 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
544565 try:
545566 r = self.get_row_by_position(position)
546567 return r
547 except IndexError:
548 logger.error(traceback.format_exc())
568 except IndexError as e:
569 logger.debug(traceback.format_exc())
549570 raise
550571 # logger.debug("row: %s, position: %s, len: %d" %(r, position, len(self)))
551572
579600 return getattr(self.df, attr)
580601 elif attr in ["body"]:
581602 return getattr(self.listbox, attr)
582 raise AttributeError(attr)
603 else:
604 return object.__getattribute__(self, attr)
605
606 # raise AttributeError(attr)
583607 # else:
584608 # return object.__getattribute__(self, attr)
585609 # elif attr == "body":
591615 self._width = size[0]
592616 if len(size) > 1:
593617 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:
595631 self._initialized = True
596632 self._invalidate()
597 self.reset(reset_sort=True)
633 if not self.no_load_on_init:
634 self.reset(reset_sort=True)
635
598636 return super().render(size, focus)
599637
600638 @property
613651
614652 def keypress(self, size, key):
615653 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:
617655 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
621659 # if key == "enter":
622660 # self._emit("select", self, self.selection)
623661 # else:
654692 def position_to_index(self, position):
655693 # if not self.query_sort and self.sort_by[1]:
656694 # 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())
659701 def index_to_position(self, index):
660702 # 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)
662705
663706 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
671707 try:
672 d = self.df.get_columns(index, as_dict=True)
708 return self.df.get_columns(index, as_dict=True)
673709 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)
675715 cls = d.get("_cls")
676716 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
678728 # klass = type(f"DataTableRow_{cls.__name__}", [cls],
679729 klass = make_dataclass(
680730 f"DataTableRow_{cls.__name__}",
688738 for k in set(
689739 cls.__dataclass_fields__.keys())
690740 })
691
692741 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)
693750 else:
694 return cls(**d)
751 return AttrDict(**d)
695752 else:
696753 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
706755
707756 def get_row(self, index):
708757 row = self.df.get(index, "_rendered_row")
709
758 details_open = False
710759 if self.df.get(index, "_dirty") or row is None:
711760 self.refresh_calculated_fields([index])
712761 # 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)
714765 row = self.render_item(index)
766 position = self.index_to_position(index)
715767 if self.row_attr_fn:
716 attr = self.row_attr_fn(vals)
768 attr = self.row_attr_fn(position, row.data_source, row)
717769 if attr:
718770 row.set_attr(attr)
719771 focus = self.df.get(index, "_focus_position")
720772 if focus is not None:
721773 row.set_focus_column(focus)
774 if details_open:
775 row.open_details()
722776 self.df.set(index, "_rendered_row", row)
723777 self.df.set(index, "_dirty", False)
778
724779 return row
725780
726781 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]
728784 return self.get_row(index)
729785
730786 def get_value(self, row, column):
737793 def selection(self):
738794 if len(self.body) and self.focus_position is not None:
739795 # 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))
742804
743805 def render_item(self, index):
744806 row = DataTableBodyRow(self, index,
759821 if not col.value_fn: continue
760822 for index in indexes:
761823 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)))
763825
764826 def visible_data_column_index(self, column_name):
765827 try:
766828 return next(i for i, c in enumerate(self.visible_data_columns)
767829 if c.name == column_name)
768830
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)
770834 raise IndexError
771835
772836 def sort_by_column(self, col=None, reverse=None, toggle=False):
779843
780844 elif col is None:
781845 col = self.sort_column
846
782847
783848 if isinstance(col, int):
784849 try:
791856 column_name = col
792857 try:
793858 column_number = self.visible_data_column_index(column_name)
859 column_name = col
794860 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
796867
797868 self.sort_column = column_number
798869
804875 except:
805876 return # FIXME
806877
807 if reverse is None and column.sort_reverse is not None:
808 reverse = column.sort_reverse
809
810878 if toggle and column_name == self.sort_by[0]:
811879 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
812884 sort_by = (column_name, reverse)
813885 # if not self.query_sort:
814886
831903 self.focus_position = self.index_to_position(row_index)
832904
833905 def sort(self, column, key=None):
834 import functools
835906 logger.debug(column)
836907 if not key:
837908 key = lambda x: (x is None, x)
847918 if not isinstance(c, DataTableDivider)
848919 ][index]
849920
850 logger.info(f"{index}, {idx}")
851921 if self.with_header:
852922 self.header.set_focus_column(idx)
853923
884954
885955 self._columns += columns
886956 for i, column in enumerate(columns):
957 # FIXME: pass reference
958 column.table = self
887959 self.df[column.name] = data=data[i] if data else None
888960
889961 self.invalidate()
903975 self.invalidate()
904976
905977 def set_columns(self, columns):
978 # logger.info(self._columns)
906979 self.remove_columns([c.name for c in self._columns])
907980 self.add_columns(columns)
981 # logger.info(self._columns)
908982 self.reset()
909983
910984 def toggle_columns(self, columns, show=None):
11311205 self.header.update()
11321206 if self.with_footer:
11331207 self.footer.update()
1134 self._modified()
1208 self.invalidate()
1209
1210 # self._modified()
11351211
11361212 def invalidate_rows(self, indexes):
11371213 if not isinstance(indexes, list):
11431219 self._modified()
11441220 # FIXME: update header / footer if dynamic
11451221
1222 def invalidate_selection(self):
1223 self.invalidate_rows(self.focus_position)
1224
11461225 def swap_rows_by_field(self, p0, p1, field=None):
11471226
11481227 if not field:
11701249
11711250 def row_count(self):
11721251
1173 if not self.limit:
1174 return None
1252 # if not self.limit:
1253 # return None
11751254
11761255 if self.limit:
11771256 return self.query_result_count()
11861265 filters = [filters]
11871266
11881267 self.filtered_rows = list(
1189 i
1268 row[self.df.index_name]
11901269 for i, row in enumerate(self.df.iterrows())
11911270 if not filters or all(
11921271 f(row)
12221301 # logger.debug("load_more")
12231302 if position is not None and position > len(self):
12241303 return False
1225 self.page = len(self) // self.limit
1304 self.page += 1
1305 # self.page = len(self) // self.limit
12261306 offset = (self.page)*self.limit
12271307 # 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
12391320
12401321 def requery(self, offset=None, limit=None, load_all=False, **kwargs):
12411322 logger.debug(f"requery: {offset}, {limit}")
12581339 kwargs["offset"] = offset
12591340 kwargs["limit"] = limit
12601341
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
12701351 self.df["_focus_position"] = self.sort_column
12711352
12721353 self.refresh_calculated_fields()
12731354 self.apply_filters()
1355 if not self.query_sort:
1356 self.sort_by_column(self.initial_sort)
1357
12741358
12751359 if len(updated):
12761360 for i in updated:
1361 if not i in self.filtered_rows:
1362 continue
12771363 pos = self.index_to_position(i)
12781364 self[pos].update()
1279 self.sort_by_column(*self.sort_by)
1365 # self.sort_by_column(*self.sort_by)
12801366
12811367 self._modified()
1368 self._emit("requery", self.row_count())
12821369
12831370 if not len(self) and self.empty_message:
12841371 self.show_message(self.empty_message)
12851372 else:
12861373 self.hide_message()
1374 return len(updated)
12871375 # self.invalidate()
12881376
12891377
12931381 idx = None
12941382 pos = 0
12951383 # limit = len(self)-1
1384 self.df.delete_all_rows()
12961385 if reset:
12971386 self.page = 0
12981387 offset = 0
12991388 limit = self.limit
1300 self.df.delete_all_rows()
13011389 else:
13021390 try:
13031391 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
13071395 limit = len(self)
13081396 # del self[:]
13091397 self.requery(offset=offset, limit=limit)
1398
1399 # self.sort_by_column(self.sort_by[0], key=column.sort_key)
13101400 if self._initialized:
13111401 self.pack_columns()
13121402
13141404 try:
13151405 pos = self.index_to_position(idx)
13161406 except:
1317 pass
1318 self.focus_position = pos
1407 return
1408 if pos is not None:
1409 self.focus_position = pos
13191410
13201411 # self.focus_position = 0
13211412
13301421
13311422
13321423 def reset(self, reset_sort=False):
1424
1425 self.pagination_cursor = None
13331426 self.refresh(reset=True)
13341427
13351428 if reset_sort and self.initial_sort is not None:
13391432 # if r.details_open:
13401433 # r.open_details()
13411434 self._modified()
1435 if len(self):
1436 self.set_focus(0)
13421437 # self._invalidate()
13431438
13441439 def pack_columns(self):
13681463 available -= w
13691464
13701465 self.resize_body_rows()
1466
13711467
13721468 def show_message(self, message):
13731469
13891485 self.listbox,
13901486 "center", ("relative", 100), "top", ("relative", 100)
13911487 )
1488 overlay.selectable = lambda: True
13921489 self.listbox_placeholder.original_widget = overlay
13931490 self._message_showing = True
13941491
00 import functools
1 from collections import MutableMapping
21 import itertools
32
43 import urwid
2625 self.padding = padding
2726 self.cell_selection = cell_selection
2827 self.style = style
29
28 # self.details = None
3029 self.sort = self.table.sort_by
3130 self.attr = self.ATTR
3231 self.attr_focused = "%s focused" %(self.attr)
214213 def __init__(self, row, content, indent=None):
215214
216215 self.row = row
217
216 self.contents = content
218217 self.columns = urwid.Columns([
219218 ("weight", 1, content)
220219 ])
237236
238237 DIVIDER_CLASS = DataTableDividerBodyCell
239238
239
240240 @property
241241 def index(self):
242242 return self.content
243243
244244 @property
245245 def data(self):
246 return self.table.get_dataframe_row(self.index)
246 return AttrDict(self.table.get_dataframe_row(self.index))
247
248 @property
249 def data_source(self):
250 return self.table.get_dataframe_row_object(self.index)
247251
248252 def __getitem__(self, column):
249253 cls = self.table.df[self.index, "_cls"]
250254 # row = self.data
251 if (
252 column not in self.table.df.columns
253 and
254 hasattr(cls, "__dataclass_fields__")
255 and
256 type(getattr(cls, column, None)) == property):
257 # logger.info(f"__getitem__ property: {column}={getattr(self.data, column)}")
258 return getattr(self.data, column)
255 if column in self.table.df.columns:
256 # logger.info(f"__getitem__: {column}={self.table.df.get(self.index, column)}")
257 return self.table.df[self.index, column]
259258 else:
260 if column in self.table.df.columns:
261 # logger.info(f"__getitem__: {column}={self.table.df.get(self.index, column)}")
262 return self.table.df[self.index, column]
263 else:
264 raise Exception(column, self.table.df.columns)
259 raise KeyError
260 # raise Exception(column, self.table.df.columns)
265261
266262
267263 def __setitem__(self, column, value):
269265 # logger.info(f"__setitem__: {column}, {value}, {self.table.df[self.index, column]}")
270266
271267 def get(self, key, default=None):
272
273268 try:
274269 return self[key]
275270 except KeyError:
278273 @property
279274 def details_open(self):
280275 # logger.info(f"{self['_details']}")
281 return (self.get("_details") or {}).get("open")
276 # raise Exception(self.get([self.index, "_details"], {}))
277 return self.get("_details", {}).get("open", False)
282278
283279 @details_open.setter
284280 def details_open(self, value):
288284
289285 @property
290286 def details_disabled(self):
291 return (self.get("_details") or {}).get("disabled")
287 return (not self.table.detail_selectable) or self.get([self.index, "_details"], {}).get("disabled", False)
292288
293289 @details_disabled.setter
294290 def details_disabled(self, value):
300296
301297 @property
302298 def details_focused(self):
303 return self.details_open and (self.pile.focus_position > 0)
299 return self.details_open and (
300 len(self.pile.contents) == 0
301 or self.pile.focus_position > 0
302 )
304303
305304 @details_focused.setter
306305 def details_focused(self, value):
307306 if value:
308 self.pile.focus_position = 1
307 self.pile.focus_position = len(self.pile.contents)-1
309308 else:
310309 self.pile.focus_position = 0
311310
311 @property
312 def details(self):
313 if not getattr(self, "_details", None):
314
315 content = self.table.detail_fn((self.data_source))
316 logger.debug(f"open_details: {type(content)}")
317 if not content:
318 return
319
320 # self.table.header.render( (self.table.width,) )
321 indent_width = 0
322 visible_count = itertools.count()
323
324 def should_indent(x):
325 if (isinstance(self.table.detail_hanging_indent, int)
326 and (x[2] is None or x[2] <= self.table.detail_hanging_indent)):
327 return True
328 elif (isinstance(self.table.detail_hanging_indent, str)
329 and x[1].name != self.table.detail_hanging_indent):
330 return True
331 return False
332
333 if self.table.detail_hanging_indent:
334 indent_width = sum([
335 x[1].width if not x[1].hide else 0
336 for x in itertools.takewhile(
337 should_indent,
338 [ (i, c, next(visible_count) if not c.hide else None)
339 for i, c in enumerate(self.table._columns) ]
340 )
341 ])
342
343 self._details = DataTableDetails(self, content, indent_width)
344 return self._details
345
346
312347 def open_details(self):
313348
314 if not self.table.detail_fn or len(self.pile.contents) > 1:
349 if not self.table.detail_fn or not self.details or self.details_open:
315350 return
316 content = self.table.detail_fn(self.data)
317
318 self.table.header.render( (self.table.width,) )
319 indent_width = 0
320 visible_count = itertools.count()
321
322 def should_indent(x):
323 if (isinstance(self.table.detail_hanging_indent, int)
324 and (x[2] is None or x[2] <= self.table.detail_hanging_indent)):
325 return True
326 elif (isinstance(self.table.detail_hanging_indent, str)
327 and x[1].name != self.table.detail_hanging_indent):
328 return True
329 return False
330
331 if self.table.detail_hanging_indent:
332 indent_width = sum([
333 x[1].width if not x[1].hide else 0
334 for x in itertools.takewhile(
335 should_indent,
336 [ (i, c, next(visible_count) if not c.hide else None)
337 for i, c in enumerate(self.table._columns) ]
338 )
339 ])
340
341 self.details = DataTableDetails(self, content, indent_width)
351
352 if len(self.pile.contents) > 1:
353 return
354
355 if self.table.detail_replace:
356 self.pile.contents[0] = (urwid.Filler(urwid.Text("")), self.pile.options("given", 0))
357
342358 self.pile.contents.append(
343359 (self.details, self.pile.options("pack"))
344360 )
361
362 self.details_focused = True
363 if not self["_details"]:
364 self["_details"] = AttrDict()
345365 self["_details"]["open"] = True
346366
347367
348368 def close_details(self):
349369 if not self.table.detail_fn or not self.details_open:
350370 return
371 # raise Exception
351372 self["_details"]["open"] = False
352 # del self.contents.contents[0]
353
354 # self.box.height -= self.pile.contents[1][0].rows( (self.table.width,) )
355 del self.pile.contents[1]
373
374 if self.table.detail_replace:
375 self.pile.contents[0] = (self.box, self.pile.options("pack"))
376
377 # del self.pile.contents[:]
378 # self.pile.contents.append(
379 # (self.box, self.pile.options("pack"))
380 # )
381 if len(self.pile.contents) >= 2:
382 del self.pile.contents[1]
356383
357384 def toggle_details(self):
358385
360387 self.close_details()
361388 else:
362389 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
375390
376391
377392 def set_attr(self, attr):
0 import logging
1 logger = logging.getLogger(__name__)
2
03 import urwid
4 import asyncio
15
26 class PopUpMixin(object):
37
3741 super(PopUpOverlay,self).__init__(*args, **kwargs)
3842
3943 def keypress(self, size, key):
44 key = super().keypress(size, key)
4045 if key in [ "esc", "q" ]:
4146 self.parent.close_popup()
4247 else:
43 return super(PopUpOverlay, self).keypress(size, key)
48 return key
4449
4550 class BasePopUp(urwid.WidgetWrap):
4651
4954 def selectable(self):
5055 return True
5156
52 class BaseDialog(BasePopUp):
57 class ChoiceDialog(BasePopUp):
5358
5459 choices = []
5560 signals = ["select"]
5863 self.parent = parent
5964 if prompt: self.prompt = prompt
6065 self.text = urwid.Text(
61 self.prompt + "[%s]" %("".join(list(self.choices.keys()))), align="center"
62 )
63 super(BaseDialog, self).__init__(
66 self.prompt + " [%s]" %("".join(list(self.choices.keys()))), align="center"
67 )
68 super(ChoiceDialog, self).__init__(
6469 urwid.Filler(urwid.Padding(self.text))
6570 )
6671
67 # def selectable(self):
68 # return True
72 @property
73 def choices(self):
74 raise NotImplementedError
6975
7076 def keypress(self, size, key):
7177 if key in list(self.choices.keys()):
7278 self.choices[key]()
79 self._emit("select", key)
7380 else:
7481 return key
82
83
84 class SquareButton(urwid.Button):
85
86 button_left = urwid.Text("[")
87 button_right = urwid.Text("]")
88
89 def pack(self, size, focus=False):
90 cols = sum(
91 [ w.pack()[0] for w in [
92 self.button_left,
93 self._label,
94 self.button_right
95 ]]) + self._w.dividechars*2
96
97 return ( cols, )
98
99 class OKCancelDialog(BasePopUp):
100
101 def __init__(self, parent, *args, **kwargs):
102
103 self.parent = parent
104
105 self.ok_button = SquareButton(("bold", "OK"))
106
107 urwid.connect_signal(
108 self.ok_button, "click",
109 lambda s: self.confirm()
110 )
111
112 self.cancel_button = SquareButton(("bold", "Cancel"))
113
114 urwid.connect_signal(
115 self.cancel_button, "click",
116 lambda s: self.cancel()
117 )
118
119
120 self.body = urwid.Pile([])
121 for name, widget in self.widgets.items():
122 setattr(self, name, widget)
123 self.body.contents.append(
124 (widget, self.body.options("weight", 1))
125 )
126
127 self.pile = urwid.Pile(
128 [
129 (2, urwid.Filler(urwid.Padding(self.body), valign="top")),
130 ("weight", 1, urwid.Padding(
131 urwid.Columns([
132 ("weight", 1,
133 urwid.Padding(
134 self.ok_button, align="center", width=12)
135 ),
136 ("weight", 1,
137 urwid.Padding(
138 self.cancel_button, align="center", width=12)
139 )
140 ]),
141 align="center"
142 )),
143 ]
144 )
145 self.body_position = 0
146 if self.title:
147 self.pile.contents.insert(
148 0,
149 (urwid.Filler(
150 urwid.AttrMap(
151 urwid.Padding(
152 urwid.Text(self.title)
153 ),
154 "header"
155 )
156 ), self.pile.options("given", 2))
157 )
158 self.body_position += 1
159
160 self.pile.selectable = lambda: True
161 self.pile.focus_position = self.body_position
162 super(OKCancelDialog, self).__init__(
163 urwid.Filler(self.pile, valign="top")
164 )
165
166 @property
167 def title(self):
168 return None
169
170 @property
171 def widgets(self):
172 raise RuntimeError("must set widgets property")
173
174 def action(self):
175 raise RuntimeError("must override action method")
176
177 @property
178 def ok_focus_path(self):
179 return [self.body_position+1,0]
180
181 @property
182 def cancel_focus_path(self):
183 return [self.body_position+1,1]
184
185 @property
186 def focus_paths(self):
187 return [
188 [self.body_position, i]
189 for i in range(len(self.body.contents))
190 ] + [
191 self.ok_focus_path,
192 self.cancel_focus_path
193 ]
194
195 def cycle_focus(self, step):
196 path = self.pile.get_focus_path()[:2]
197 logger.info(f"{path}, {self.focus_paths}")
198 self.pile.set_focus_path(
199 self.focus_paths[
200 (self.focus_paths.index(path) + step) % len(self.focus_paths)
201 ]
202 )
203
204 def confirm(self):
205 rv = self.action()
206 if asyncio.iscoroutine(rv):
207 asyncio.get_event_loop().create_task(rv)
208
209 self.close()
210
211 def cancel(self):
212 self.close()
213
214 def close(self):
215 self._emit("close_popup")
216
217 def selectable(self):
218 return True
219
220 def keypress(self, size, key):
221 if key in ["tab", "shift tab"]:
222 self.cycle_focus(1 if key == "tab" else -1)
223 return
224 else:
225 key = super().keypress(size, key)
226 if key == "enter":
227 self.confirm()
228 else:
229 return key
230
231
232
233 class ConfirmDialog(ChoiceDialog):
234
235 def __init__(self, parent, *args, **kwargs):
236 super(ConfirmDialog, self).__init__(parent, *args, **kwargs)
237
238 def action(self, value):
239 raise RuntimeError("must override action method")
240
241 @property
242 def prompt(self):
243 return "Are you sure?"
244
245 def confirm(self):
246 self.action()
247 self.close()
248
249 def cancel(self):
250 self.close()
251
252 def close(self):
253 self.parent.close_popup()
254
255 @property
256 def choices(self):
257 return {
258 "y": self.confirm,
259 "n": self.cancel
260 }
75261
76262 class BaseView(urwid.WidgetWrap):
77263
107293 __all__ = [
108294 "BaseView",
109295 "BasePopUp",
110 "BaseDialog",
296 "ChoiceDialog",
111297 ]
1212 # import urwid_readline
1313 from orderedattrdict import AttrDict
1414
15 from .datatable import *
15 # from .datatable import *
16 from .listbox import ScrollingListBox
1617 from .keymap import *
17 from .listbox import ScrollingListBox
18 from .highlightable import HighlightableTextMixin
19 from .autocomplete import AutoCompleteMixin
1820
1921 class DropdownButton(urwid.Button):
22
23 text_attr = "dropdown_text"
2024
2125 left_chars = u""
2226 right_chars = u""
2327
24 def __init__(self, label, left_chars=None, right_chars=None):
28
29 def __init__(
30 self, label,
31 text_attr=None,
32 left_chars=None, right_chars=None
33 ):
2534
2635 self.label_text = label
36 if text_attr:
37 self.text_attr = text_attr
2738 if left_chars:
2839 self.left_chars = left_chars
2940 if right_chars:
3849 ('weight', 1, self._label),
3950 (len(self.right_chars), self.button_right)
4051 ], dividechars=0)
41 self.set_label(("dropdown_text", self.label_text))
52 self.set_label((self.text_attr, self.label_text))
4253 super(urwid.Button, self).__init__(self.cols)
4354
4455 @property
5061 return self.decoration_width + len(self.label_text)
5162
5263
53 class DropdownItem(urwid.WidgetWrap):
64 class DropdownItem(HighlightableTextMixin, urwid.WidgetWrap):
5465
5566 signals = ["click"]
5667
68 text_attr = "dropdown_text"
69 highlight_attr = "dropdown_highlight"
70 focused_attr = "dropdown_focused"
71
5772 def __init__(self, label, value,
58 margin=0, left_chars=None, right_chars=None):
73 margin=0,
74 text_attr=None,
75 focused_attr=None,
76 highlight_attr=None,
77 left_chars=None, right_chars=None):
5978
6079 self.label_text = label
6180 self.value = value
6281 self.margin = margin
63 # self.button = urwid.Button(("dropdown_text", self.label_text))
82 if text_attr:
83 self.text_attr = text_attr
84 if focused_attr:
85 self.focused_attr = focused_attr
86 if highlight_attr:
87 self.highlight_attr = highlight_attr
6488 self.button = DropdownButton(
6589 self.label_text,
90 text_attr=self.text_attr,
6691 left_chars=left_chars, right_chars=right_chars
6792 )
93
6894 self.padding = urwid.Padding(self.button, width=("relative", 100),
6995 left=self.margin, right=self.margin)
70 # self.padding = self.button
71
72
73 self.attr = urwid.AttrMap(self.padding, {None: "dropdown_text"})
96
97
98 self.attr = urwid.AttrMap(self.padding, {None: self.text_attr})
7499 self.attr.set_focus_map({
75 None: "dropdown_focused",
76 "dropdown_text": "dropdown_focused"
100 None: self.focused_attr,
101 self.text_attr: self.focused_attr
77102 })
78103 super(DropdownItem, self).__init__(self.attr)
79104 urwid.connect_signal(
83108 )
84109
85110 @property
111 def highlight_source(self):
112 return self.label_text
113
114 @property
115 def highlightable_attr_normal(self):
116 return self.text_attr
117
118 @property
119 def highlightable_attr_highlight(self):
120 return self.highlight_attr
121
122 def on_highlight(self):
123 self.set_text(self.highlight_content)
124
125 def on_unhighlight(self):
126 self.set_text(self.highlight_source)
127
128 @property
86129 def width(self):
87130 return self.button.width + 2*self.margin
88131
103146 def label(self):
104147 return self.button.label
105148
106 def set_label(self, label):
107 logger.debug("set_label: " + repr(label) )
108 self.button.set_label(label)
109
110 def highlight_text(self, s, case_sensitive=False):
111
112 (a, b, c) = re.search(
113 r"(.*?)(%s)(.*)" %(s),
114 self.label_text,
115 re.IGNORECASE if not case_sensitive else 0
116 ).groups()
117
118 self.set_label([
119 ("dropdown_text", a),
120 ("dropdown_highlight", b),
121 ("dropdown_text", c),
122 ])
123
124 def unhighlight(self):
125 self.set_label(("dropdown_text", self.label_text))
126
127
128 # class AutoCompleteEdit(urwid_readline.ReadlineEdit):
149 def set_text(self, text):
150 self.button.set_label(text)
151
129152 @keymapped()
130 class AutoCompleteEdit(urwid.Edit):
131
132 signals = ["close"]
133
134 @keymap_command()
135 def clear(self):
136 raise Exception
137 self.set_edit_text("")
138
139 def keypress(self, size, key):
140 if key == "enter":
141 self._emit("close")
142 return super(AutoCompleteEdit, self).keypress(size, key)
143
144 class AutoCompleteBar(urwid.WidgetWrap):
145
146 signals = ["change", "close"]
147 def __init__(self):
148
149 self.prompt = urwid.Text(("dropdown_prompt", "> "))
150 self.text = AutoCompleteEdit("")
151 # self.text.selectable = lambda x: False
152 self.cols = urwid.Columns([
153 (2, self.prompt),
154 ("weight", 1, self.text)
155 ], dividechars=0)
156 self.cols.focus_position = 1
157 self.filler = urwid.Filler(self.cols, valign="bottom")
158 urwid.connect_signal(self.text, "postchange", self.text_changed)
159 urwid.connect_signal(self.text, "close", lambda source: self._emit("close"))
160 super(AutoCompleteBar, self).__init__(self.filler)
161
162 def set_prompt(self, text):
163
164 self.prompt.set_text(("dropdown_prompt", text))
165
166 def set_text(self, text):
167
168 self.text.set_edit_text(text)
169
170 def text_changed(self, source, text):
171 self._emit("change", text)
172
173
174 @keymapped()
175 class DropdownDialog(urwid.WidgetWrap, KeymapMovementMixin):
153 class DropdownDialog(AutoCompleteMixin, urwid.WidgetWrap, KeymapMovementMixin):
176154
177155 signals = ["select", "close"]
156
157 text_attr = "dropdown_text"
178158
179159 min_width = 4
180160
181161 label = None
182162 border = None
183163 scrollbar = False
184 auto_complete = False
185164 margin = 0
186165 max_height = None
187166
194173 border=False,
195174 margin = None,
196175 scrollbar=None,
197 auto_complete=None,
176 text_attr=None,
177 focused_attr=None,
178 prompt_attr=None,
198179 left_chars=None,
199180 right_chars=None,
200181 left_chars_top=None,
201182 rigth_chars_top=None,
202183 max_height=None,
203 keymap = {}
184 keymap = {},
185 **kwargs
204186 ):
187
205188 self.drop_down = drop_down
206189 self.items = items
207190 if label is not None: self.label = label
208191 if border is not None: self.border = border
209192 if margin is not None: self.margin = margin
210193 if scrollbar is not None: self.scrollbar = scrollbar
211 if auto_complete is not None: self.auto_complete = auto_complete
194 if text_attr:
195 self.text_attr = text_attr
196 if focused_attr:
197 self.focused_attr = focused_attr
198 if prompt_attr:
199 self.prompt_attr = prompt_attr
212200 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
221201 self.selected_button = 0
222202 buttons = []
223203
224204 buttons = [
225205 DropdownItem(
226206 label=l, value=v, margin=self.margin,
207 text_attr=self.text_attr,
208 focused_attr=self.focused_attr,
227209 left_chars=left_chars,
228210 right_chars=right_chars,
229211 )
236218 urwid.connect_signal(
237219 self.dropdown_buttons,
238220 'select',
239 lambda source, selection: self.select_button(selection)
240 )
241
242 box_height = self.height -2 if self.border else self.height
243 self.box = urwid.BoxAdapter(self.dropdown_buttons, box_height)
244 self.fill = urwid.Filler(self.box)
221 lambda source, selection: self.on_complete_select(source)
222 )
223
245224 kwargs = {}
246225 if self.label is not None:
247226 kwargs["title"] = self.label
248227 kwargs["tlcorner"] = u"\N{BOX DRAWINGS LIGHT DOWN AND HORIZONTAL}"
249228 kwargs["trcorner"] = u"\N{BOX DRAWINGS LIGHT DOWN AND LEFT}"
250229
251 w = self.fill
230 w = self.dropdown_buttons
252231 if self.border:
253232 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())
265233
266234 self.pile = urwid.Pile([
267235 ("weight", 1, w),
268236 ])
269 self.__super.__init__(self.pile)
270
271
272 @property
273 def KEYMAP(self):
274 return self.drop_down.KEYMAP
275
276 @property
277 def filter_text(self):
278 return self.auto_complete_bar.text.get_text()[0]
279
280 @filter_text.setter
281 def filter_text(self, value):
282 return self.auto_complete_bar.set_text(value)
237 super().__init__(self.pile)
238
239 @property
240 def complete_container(self):
241 return self.pile
242
243 @property
244 def complete_container_pos(self):
245 return 1
246
247 @property
248 def complete_body(self):
249 return self.body
250
251 @property
252 def complete_items(self):
253 return self.body
283254
284255 @property
285256 def max_item_width(self):
324295 def selection(self):
325296 return self.dropdown_buttons.selection
326297
327 def select_button(self, button):
328
329 # logger.debug("select_button: %s" %(button))
330 label = button.label
331 value = button.value
332 self.selected_button = self.focus_position
333 self.complete_off()
334 self._emit("select", button)
335 self._emit("close")
298 # def on_complete_select(self, pos, widget):
299
300 # # logger.debug("select_button: %s" %(button))
301 # label = widget.label
302 # value = widget.value
303 # self.selected_button = self.focus_position
304 # self.complete_off()
305 # self._emit("select", widget)
306 # self._emit("close")
336307
337308 # def keypress(self, size, key):
338
339 # raise Exception
340 # logger.debug("DropdownDialog.keypress: %s" %(key))
341 # if self.completing:
342 # if key in ["enter", "up", "down"]:
343 # self.complete_off()
344 # else:
345 # return key
346 # else:
347 # return super(DropdownDialog, self).keypress(size, key)
309 # return super(DropdownDialog, self).keypress(size, key)
348310
349311
350312 @property
353315 return None
354316 return self.body[self.focus_position].value
355317
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
470318 @keymapped()
471319 class Dropdown(urwid.PopUpLauncher):
472320 # Based in part on SelectOne widget from
474322
475323 signals = ["change"]
476324
325 text_attr = "dropdown_text"
326 label_attr = "dropdown_label"
327 focused_attr = "dropdown_focused"
328 highlight_attr = "dropdown_highlight"
329 prompt_attr = "dropdown_prompt"
330
331 auto_complete = None
477332 label = None
478333 empty_label = u"\N{EMPTY SET}"
334 expanded = False
479335 margin = 0
480336
481337 def __init__(
482338 self,
483 items = None,
484 label = None,
485 default = None,
486 border = False, scrollbar = False,
487 margin = None,
488 left_chars = None, right_chars = None,
489 left_chars_top = None, right_chars_top = None,
490 auto_complete = False,
491 max_height = 10,
339 items=None,
340 label=None,
341 default=None,
342 expanded=None,
343 border=False, scrollbar=False,
344 margin=None,
345 text_attr=None,
346 label_attr=None,
347 focused_attr=None,
348 highlight_attr=None,
349 prompt_attr=None,
350 left_chars=None, right_chars=None,
351 left_chars_top=None, right_chars_top=None,
352 auto_complete=None,
353 max_height=10,
492354 # keymap = {}
493355 ):
494356
496358 self._items = items
497359 if label is not None:
498360 self.label = label
361 if expanded is not None:
362 self.expanded = expanded
499363 self.default = default
500364
501365 self.border = border
502366 self.scrollbar = scrollbar
503 self.auto_complete = auto_complete
367 if auto_complete is not None: self.auto_complete = auto_complete
368
504369 # self.keymap = keymap
505370
506371 if margin:
507372 self.margin = margin
373
374 if text_attr:
375 self.text_attr = text_attr
376 if label_attr:
377 self.label_attr = label_attr
378 if focused_attr:
379 self.focused_attr = focused_attr
380 if highlight_attr:
381 self.highlight_attr = highlight_attr
382 if prompt_attr:
383 self.prompt_attr = prompt_attr
508384
509385 if isinstance(self.items, list):
510386 if len(self.items):
522398 self.button = DropdownItem(
523399 u"", None,
524400 margin=self.margin,
401 text_attr=self.text_attr,
402 highlight_attr=self.highlight_attr,
403 focused_attr=self.focused_attr,
525404 left_chars = left_chars_top if left_chars_top else left_chars,
526405 right_chars = right_chars_top if right_chars_top else right_chars
527406 )
530409 self,
531410 self._items,
532411 self.default,
533 label = self.label,
534 border = self.border,
535 margin = self.margin,
536 left_chars = left_chars,
537 right_chars = right_chars,
538 auto_complete = self.auto_complete,
539 scrollbar = scrollbar,
540 max_height = max_height,
541 # keymap = self.KEYMAP
412 label=self.label,
413 border=self.border,
414 margin=self.margin,
415 text_attr=self.text_attr,
416 focused_attr=self.focused_attr,
417 prompt_attr=self.prompt_attr,
418 left_chars=left_chars,
419 right_chars=right_chars,
420 auto_complete=self.auto_complete,
421 scrollbar=scrollbar,
422 max_height=max_height,
423 # keymap=self.KEYMAP
542424 )
543425
544426 urwid.connect_signal(
545427 self.pop_up,
546428 "select",
547 lambda souce, selection: self.select(selection)
429 lambda souce, pos, selection: self.select(selection)
548430 )
549431
550432 urwid.connect_signal(
551433 self.pop_up,
552434 "close",
553 lambda button: self.close_pop_up()
435 lambda source: self.close_pop_up()
554436 )
555437
556438 if self.default is not None:
557439 try:
558440 if isinstance(self.default, str):
559 self.select_label(self.default)
441 try:
442 self.select_label(self.default)
443 except ValueError:
444 pass
560445 else:
561446 raise StopIteration
562447 except StopIteration:
568453 if len(self):
569454 self.select(self.selection)
570455 else:
571 self.button.set_label(("dropdown_text", self.empty_label))
456 self.button.set_text((self.text_attr, self.empty_label))
572457
573458 cols = [ (self.button_width, self.button) ]
574459
575460 if self.label:
576461 cols[0:0] = [
577 ("pack", urwid.Text([("dropdown_label", "%s: " %(self.label))])),
462 ("pack", urwid.Text([(self.label_attr, "%s: " %(self.label))])),
578463 ]
579464 self.columns = urwid.Columns(cols, dividechars=0)
580465
589474 'click',
590475 lambda button: self.open_pop_up()
591476 )
477 if self.expanded:
478 self.open_pop_up()
592479
593480 @classmethod
594481 def get_palette_entries(cls):
595482 return {
596483 "dropdown_text": PaletteEntry(
597 foreground = "light gray",
598 background = "dark blue",
599 foreground_high = "light gray",
600 background_high = "#003",
484 foreground="light gray",
485 background="dark blue",
486 foreground_high="light gray",
487 background_high="#003",
601488 ),
602489 "dropdown_focused": PaletteEntry(
603 foreground = "white",
604 background = "light blue",
605 foreground_high = "white",
606 background_high = "#009",
490 foreground="white",
491 background="light blue",
492 foreground_high="white",
493 background_high="#009",
607494 ),
608495 "dropdown_highlight": PaletteEntry(
609 foreground = "yellow",
610 background = "light blue",
611 foreground_high = "yellow",
612 background_high = "#009",
496 foreground="yellow",
497 background="light blue",
498 foreground_high="yellow",
499 background_high="#009",
613500 ),
614501 "dropdown_label": PaletteEntry(
615 foreground = "white",
616 background = "black"
502 foreground="white",
503 background="black"
617504 ),
618505 "dropdown_prompt": PaletteEntry(
619 foreground = "light blue",
620 background = "black"
506 foreground="light blue",
507 background="black"
621508 )
622509 }
623510
683570 super(Dropdown, self).open_pop_up()
684571
685572 def close_pop_up(self):
686 super(Dropdown, self).close_pop_up()
573 super().close_pop_up()
687574
688575 def get_pop_up_parameters(self):
689576 return {'left': (len(self.label) + 2 if self.label else 0),
698585
699586 @focus_position.setter
700587 def focus_position(self, pos):
588 if pos == self.focus_position:
589 return
701590 # self.select_index(pos)
702591 old_pos = self.focus_position
703592 self.pop_up.selected_button = self.pop_up.focus_position = pos
743632
744633 f = lambda x: x
745634 if not case_sensitive:
746 f = lambda x: x.lower()
747
748 index = next(itertools.dropwhile(
749 lambda x: f(x[1]) != f(label),
750 enumerate((self._items.keys())
751 )
752 ))[0]
635 f = lambda x: x.lower() if isinstance(x, str) else x
636
637 try:
638 index = next(itertools.dropwhile(
639 lambda x: f(x[1]) != f(label),
640 enumerate((self._items.keys())
641 )
642 ))[0]
643 except StopIteration:
644 raise ValueError
753645 self.focus_position = index
754646
755647
806698
807699 def cycle_prev(self):
808700 self.cycle(-1)
701
702 def action(self):
703 pass
809704
810705 @keymap_command("cycle")
811706 def cycle(self, n):
819714
820715 def select(self, button):
821716 logger.debug("select: %s" %(button))
822 self.button.set_label(("dropdown_text", button.label))
717 self.button.set_text((self.text_attr, button.label))
823718 self.pop_up.dropdown_buttons.listbox.set_focus_valign("top")
824719 # if old_pos != pos:
720 self.action()
825721 self._emit("change", self.selected_label, self.selected_value)
826722
827723 # def set_items(self, items, selected_value):
0 import logging
1 logger = logging.getLogger(__name__)
2
3 class HighlightableTextMixin(object):
4
5 @property
6 def highlight_state(self):
7 if not getattr(self, "_highlight_state", False):
8 self._highlight_state = False
9 self._highlight_case_sensitive = False
10 self._highlight_string = None
11 return self._highlight_state
12
13 @property
14 def highlight_content(self):
15 if self.highlight_state:
16 return self.get_highlight_text()
17 else:
18 return self.highlight_source
19
20
21 def highlight(self, start, end):
22 self._highlight_state = True
23 self._highlight_location = (start, end)
24 self.on_highlight()
25
26 def unhighlight(self):
27 self._highlight_state = False
28 self._highlight_location = None
29 self.on_unhighlight()
30
31 def get_highlight_text(self):
32
33 if not self._highlight_location:
34 return None
35
36 return [
37 (self.highlightable_attr_normal, self.highlight_source[:self._highlight_location[0]]),
38 (self.highlightable_attr_highlight, self.highlight_source[self._highlight_location[0]:self._highlight_location[1]]),
39 (self.highlightable_attr_normal, self.highlight_source[self._highlight_location[1]:]),
40 ]
41
42 @property
43 def highlight_source(self):
44 raise NotImplementedError
45
46 @property
47 def highlightable_attr_normal(self):
48 raise NotImplementedError
49
50 @property
51 def highlightable_attr_highlight(self):
52 raise NotImplementedError
53
54 def on_highlight(self):
55 pass
56
57 def on_unhighlight(self):
58 pass
59
60 __all__ = ["HighlightableTextMixin"]
11
22 import logging
33 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())
144
155 import six
6 import asyncio
167 import urwid
178 import re
9
10 KEYMAP_GLOBAL = {}
1811
1912 _camel_snake_re_1 = re.compile(r'(.)([A-Z][a-z]+)')
2013 _camel_snake_re_2 = re.compile('([a-z0-9])([A-Z])')
4841
4942 def wrapper(cls):
5043
51 def keypress_decorator(func):
44 cls.KEYMAP_MERGED = {}
5245
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)
8950
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)
9155
92 return keypress
9356
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)
9862
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)
11064 if not hasattr(cls, "KEYMAP_MAPPING"):
11165 cls.KEYMAP_MAPPING = {}
66
67 cls.KEYMAP_MAPPING.update(**getattr(cls.__base__, "KEYMAP_MAPPING", {}))
68
11269 cls.KEYMAP_MAPPING.update({
11370 (getattr(getattr(cls, k), "_keymap_command", k) or k).replace(" ", "_"): k
11471 for k in cls.__dict__.keys()
11572 if hasattr(getattr(cls, k), '_keymap')
11673 })
74
75 def keymap_command(self, cmd):
76 logger.debug(f"keymap_command: {cmd}")
77 args = []
78 kwargs = {}
79
80 if callable(cmd):
81 f = cmd
82 else:
83 if isinstance(cmd, tuple):
84 if len(cmd) == 3:
85 (cmd, args, kwargs) = cmd
86 elif len(cmd) == 2:
87 if isinstance(cmd[1], dict):
88 (cmd, kwargs) = cmd
89 else:
90 (cmd, args) = cmd
91 else:
92 raise Exception
93 elif isinstance(cmd, str):
94 cmd = cmd.replace(" ", "_")
95 else:
96 logger.debug(f"keymap command {cmd} not valid")
97 return None
98
99 if hasattr(self, cmd):
100 fn_name = cmd
101 else:
102 try:
103 fn_name = self.KEYMAP_MAPPING[cmd]
104 except KeyError:
105 raise KeyError(cmd, self.KEYMAP_MAPPING, type(self))
106
107 f = getattr(self, fn_name)
108
109 ret = f(*args, **kwargs)
110 if asyncio.iscoroutine(ret):
111 asyncio.get_event_loop().create_task(ret)
112 return None
113
114 cls._keymap_command = keymap_command
115
116 def keymap_register(self, key, cmd):
117 self.KEYMAP_MERGED[cls.KEYMAP_SCOPE()][key] = cmd
118
119 cls.keymap_register = keymap_register
120
121 def keypress_decorator(func):
122
123
124 def keypress(self, size, key):
125 logger.debug(f"{cls} wrapped keypress: {key}, {cls.KEYMAP_SCOPE()}, {self.KEYMAP_MERGED.get(cls.KEYMAP_SCOPE(), {}).keys()}")
126
127 if key and callable(func):
128 logger.debug(f"{cls} wrapped keypress, key: {key}, calling orig: {func}")
129 key = func(self, size, key)
130 if key:
131 logger.debug(f"{cls} wrapped keypress, key: {key}, calling super: {super(cls, self).keypress}")
132 key = super(cls, self).keypress(size, key)
133 keymap_combined = dict(self.KEYMAP_MERGED, **KEYMAP_GLOBAL)
134 if key and keymap_combined.get(cls.KEYMAP_SCOPE(), {}).get(key, None):
135 cmd = keymap_combined[cls.KEYMAP_SCOPE()][key]
136 if isinstance(cmd, str) and cmd.startswith("keypress "):
137 new_key = cmd.replace("keypress ", "").strip()
138 logger.debug(f"{cls} remap {key} => {new_key}")
139 key = new_key
140 else:
141 logger.debug(f"{cls} wrapped keypress, key: {key}, calling keymap command")
142 key = self._keymap_command(cmd)
143 return key
144
145 return keypress
146
147 cls.keypress = keypress_decorator(getattr(cls, "keypress", None))
117148 return cls
149
118150 return wrapper
151
152
119153
120154
121155 @keymapped()
122156 class KeymapMovementMixin(object):
157
158 @classmethod
159 def KEYMAP_SCOPE(cls):
160 return "movement"
123161
124162 def cycle_position(self, n):
125163
33
44 import urwid
55 from urwid_utils.palette import *
6 from .scroll import ScrollBar
67
78 class ListBoxScrollBar(urwid.WidgetWrap):
89
2425 )
2526 scroll_marker_height = max( height * (height / self.parent.row_count ), 1)
2627 else:
27 scroll_position = -1
28 scroll_position = 0
2829
2930 pos_marker = urwid.AttrMap(urwid.Text(" "),
3031 {None: "scroll_pos"}
5657 marker = begin_marker
5758 elif i+1 == height and self.parent.row_count == self.parent.focus_position+1:
5859 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:
6061 marker = down_marker
6162 else:
6263 marker = pos_marker
7677 # FIXME: mouse click/drag
7778 return False
7879
79
8080 class ScrollingListBox(urwid.WidgetWrap):
8181
8282 signals = ["select",
8383 "drag_start", "drag_continue", "drag_stop",
8484 "load_more"]
8585
86 scrollbar_class = ScrollBar
87
8688 def __init__(self, body,
8789 infinite = False,
8890 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):
9196
9297 self.infinite = infinite
9398 self.with_scrollbar = with_scrollbar
94 self.scroll_rows = scroll_rows
9599 self.row_count_fn = row_count_fn
100
101 self._width = None
102 self._height = 0
103 self._rows_max = None
96104
97105 self.mouse_state = 0
98106 self.drag_from = None
99107 self.drag_last = None
100108 self.drag_to = None
101109 self.load_more = False
102 self.height = 0
103110 self.page = 0
104111
105112 self.queued_keypress = None
106
107 self.listbox = urwid.ListBox(body)
113 w = self.listbox = urwid.ListBox(body)
114
108115 self.columns = urwid.Columns([
109116 ('weight', 1, self.listbox)
110117 ])
114121 (self.scroll_bar, self.columns.options("given", 1))
115122 )
116123 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
117133
118134 @classmethod
119135 def get_palette_entries(cls):
154170 def mouse_event(self, size, event, button, col, row, focus):
155171
156172 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):
158174 return
159175 if event == 'mouse press':
160176 if button == 1:
161177 self.mouse_state = 1
162178 self.drag_from = self.drag_last = (col, row)
163179 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)
165181 if pos < 0:
166182 pos = 0
167183 self.listbox.focus_position = pos
168184 self.listbox.make_cursor_visible(size)
169185 self._invalidate()
170186 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)
172188 if pos > len(self.listbox.body) - 1:
173189 if self.infinite:
174190 self.load_more = True
234250 if len(self.body):
235251 return self.body[self.focus_position]
236252
253 @property
254 def size(self):
255 return (self._width, self._height)
237256
238257 def render(self, size, focus=False):
239258
240259 maxcol = size[0]
241 self.width = maxcol
260 self._width = maxcol
242261 if len(size) > 1:
243262 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
245269
246270 # print
247271 # print
262286 urwid.signals.emit_signal(
263287 self, "load_more", focus)
264288 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
267291 ):
268 # logger.info("send queued keypress")
292 # logger.info(f"send queued keypress: {focus}, {len(self.body)}")
269293 self.keypress(size, self.queued_keypress)
270294 self.queued_keypress = None
271295 # self.listbox._invalidate()
272296 # self._invalidate()
273297
274 if self.with_scrollbar and len(self.body):
275 self.scroll_bar.update(size)
276
277298 return super(ScrollingListBox, self).render(size, focus)
278299
279300
295316 def focus_position(self):
296317 if not len(self.listbox.body):
297318 raise IndexError
298 if len(self.listbox.body):
319 try:
299320 return self.listbox.focus_position
321 except IndexError:
322 pass
300323 return None
301324
302325 @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"]
2525 # Scrollbar positions
2626 SCROLLBAR_LEFT = 'left'
2727 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
2879
2980 class Scrollable(urwid.WidgetDecoration):
3081
271322 return self._rows_max_cached
272323
273324
325 DEFAULT_THUMB_CHAR = '\u2588'
326 DEFAULT_TROUGH_CHAR = " "
327 DEFAULT_SIDE = SCROLLBAR_RIGHT
328
274329 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
275337 def sizing(self):
276338 return frozenset((BOX,))
277339
278340 def selectable(self):
279341 return True
280342
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):
283347 """Box widget that adds a scrollbar to `widget`
284348
285349 `widget` must be a box widget with the following methods:
298362 if BOX not in widget.sizing():
299363 raise ValueError('Not a box widget: %r' % widget)
300364 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
303374 self.scrollbar_side = side
304375 self.scrollbar_width = max(1, width)
305376 self._original_widget_size = (0, 0)
346417 # fill gaps in shard_tail!" or "cviews overflow gaps in shard_tail!"
347418 # exceptions. Stacking the same SolidCanvas is a workaround.
348419 # 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
352489 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)
356495 )
357496
358497 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 ]
141141 if selected is not None:
142142 self.set_active_tab(selected)
143143
144 @property
145 def active_tab(self):
146 return self._contents[self.active_tab_idx]
147
144148 @classmethod
145149 def get_palette_entries(cls):
146150 return {
198202 ),
199203 self._w.contents[1][1]
200204 )
201 self.active_tab = idx
205 self.active_tab_idx = idx
202206 urwid.signals.emit_signal(self, "activate", self, self._contents[idx])
203207
204208 def get_tab_by_label(self, label):
217221
218222
219223 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)
222226 else:
223227 self.set_active_tab(0)
224228
225229 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)
228232 else:
229233 self.set_active_tab(len(self._contents)-1)
230234
231235 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:
236240 new_idx -= 1
237 del self._contents[self.active_tab]
241 del self._contents[self.active_tab_idx]
238242 self.set_active_tab(new_idx)
239243
240244 def _set_active_by_tab(self, tab):
0 Metadata-Version: 1.2
0 Metadata-Version: 2.1
11 Name: panwid
2 Version: 0.3.0.dev15
2 Version: 0.3.4
33 Summary: Useful widgets for urwid
44 Home-page: https://github.com/tonycpsu/panwid
55 Author: Tony Cebzanov
66 Author-email: tonycpsu@gmail.com
77 License: UNKNOWN
8 Description: UNKNOWN
98 Platform: UNKNOWN
109 Classifier: Environment :: Console
1110 Classifier: License :: OSI Approved :: GNU General Public License v2 (GPLv2)
1211 Classifier: Intended Audience :: Developers
1312 Requires-Python: >=3.6
13 License-File: LICENSE
14
15 UNKNOWN
16
33 setup.cfg
44 setup.py
55 panwid/__init__.py
6 panwid/autocomplete.py
67 panwid/dropdown.py
8 panwid/highlightable.py
79 panwid/keymap.py
810 panwid/listbox.py
11 panwid/progressbar.py
912 panwid/scroll.py
13 panwid/sparkwidgets.py
1014 panwid/tabview.py
1115 panwid.egg-info/PKG-INFO
1216 panwid.egg-info/SOURCES.txt
0 orderedattrdict
1 raccoon>=3.0.0
2 six
03 urwid
14 urwid-utils>=0.1.2
2 six
3 raccoon>=3.0.0
4 orderedattrdict
77
88 name = 'panwid'
99 setup(name=name,
10 version='0.3.0.dev15',
10 version='0.3.4',
1111 description='Useful widgets for urwid',
1212 author='Tony Cebzanov',
1313 author_email='tonycpsu@gmail.com',