Codebase list python-panwid / 075ff6e
New upstream release. Debian Janitor 2 years ago
22 changed file(s) with 1922 addition(s) and 644 deletion(s). Raw diff Collapse all Expand all
00 Metadata-Version: 1.2
11 Name: panwid
2 Version: 0.3.0.dev15
2 Version: 0.3.3
33 Summary: Useful widgets for urwid
44 Home-page: https://github.com/tonycpsu/panwid
55 Author: Tony Cebzanov
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.3-1) UNRELEASED; urgency=low
1
2 * New upstream release.
3
4 -- Debian Janitor <janitor@jelmer.uk> Tue, 01 Jun 2021 17:10:54 -0000
5
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.3"
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 def __init__(self):
44
45 self.prompt = urwid.Text(("dropdown_prompt", "> "))
46 self.text = AutoCompleteEdit("")
47 # self.text.selectable = lambda x: False
48 self.cols = urwid.Columns([
49 (2, self.prompt),
50 ("weight", 1, self.text)
51 ], dividechars=0)
52 self.cols.focus_position = 1
53 self.filler = urwid.Filler(self.cols, valign="bottom")
54 urwid.connect_signal(self.text, "postchange", self.text_changed)
55 urwid.connect_signal(self.text, "complete_prev", lambda source: self._emit("complete_prev"))
56 urwid.connect_signal(self.text, "complete_next", lambda source: self._emit("complete_next"))
57 urwid.connect_signal(self.text, "select", lambda source: self._emit("select"))
58 urwid.connect_signal(self.text, "close", lambda source: self._emit("close"))
59 super(AutoCompleteBar, self).__init__(self.filler)
60
61 def set_prompt(self, text):
62
63 self.prompt.set_text(("dropdown_prompt", text))
64
65 def set_text(self, text):
66
67 self.text.set_edit_text(text)
68
69 def text_changed(self, source, text):
70 self._emit("change", text)
71
72 def confirm(self):
73 self._emit("select")
74 self._emit("close")
75
76 def cancel(self):
77 self._emit("close")
78
79 def __len__(self):
80 return len(self.body)
81
82 def keypress(self, size, key):
83 return super().keypress(size, key)
84
85 @keymapped()
86 class AutoCompleteMixin(object):
87
88 auto_complete = None
89
90 def __init__(self, auto_complete, *args, **kwargs):
91 super().__init__(self.complete_container, *args, **kwargs)
92 if auto_complete is not None: self.auto_complete = auto_complete
93 self.auto_complete_bar = None
94 self.completing = False
95 self.complete_anywhere = False
96 self.case_sensitive = False
97 self.last_complete_pos = None
98 self.complete_string_location = None
99 self.last_filter_text = None
100
101 if self.auto_complete:
102 self.auto_complete_bar = AutoCompleteBar()
103
104
105 urwid.connect_signal(
106 self.auto_complete_bar, "change",
107 lambda source, text: self.complete()
108 )
109 urwid.connect_signal(
110 self.auto_complete_bar, "complete_prev",
111 lambda source: self.complete_prev()
112 )
113 urwid.connect_signal(
114 self.auto_complete_bar, "complete_next",
115 lambda source: self.complete_next()
116 )
117
118 urwid.connect_signal(
119 self.auto_complete_bar, "select", self.on_complete_select
120 )
121 urwid.connect_signal(
122 self.auto_complete_bar, "close", self.on_complete_close
123 )
124
125 def keypress(self, size, key):
126 return super().keypress(size, key)
127 # key = super().keypress(size, key)
128 # if self.completing and key == "enter":
129 # self.on_complete_select(self)
130 # else:
131 # return key
132
133 @property
134 def complete_container(self):
135 raise NotImplementedError
136
137 @property
138 def complete_body(self):
139 raise NotImplementedError
140
141 @property
142 def complete_items(self):
143 raise NotImplementedError
144
145 def complete_widget_at_pos(self, pos):
146 return self.complete_body[pos]
147
148 def complete_set_focus(self, pos):
149 self.focus_position = pos
150
151 @keymap_command()
152 def complete_prefix(self):
153 self.complete_on()
154
155 @keymap_command()
156 def complete_substring(self):
157 self.complete_on(anywhere=True)
158
159 def complete_prev(self):
160 self.complete(step=-1)
161
162 def complete_next(self):
163 self.complete(step=1)
164
165 def complete_on(self, anywhere=False, case_sensitive=False):
166
167 if self.completing:
168 return
169 self.completing = True
170 self.show_bar()
171 if anywhere:
172 self.complete_anywhere = True
173 else:
174 self.complete_anywhere = False
175
176 if case_sensitive:
177 self.case_sensitive = True
178 else:
179 self.case_sensitive = False
180
181 def complete_compare_substring(self, search, candidate):
182 try:
183 return candidate.index(search)
184 except ValueError:
185 return None
186
187 def complete_compare_fn(self, search, candidate):
188
189 if self.case_sensitive:
190 f = lambda x: str(x)
191 else:
192 f = lambda x: str(x.lower())
193
194 if self.complete_anywhere:
195 return self.complete_compare_substring(f(search), f(candidate))
196 else:
197 return 0 if self.complete_compare_substring(f(search), f(candidate))==0 else None
198 # return f(candidate)
199
200
201 @keymap_command()
202 def complete_off(self):
203
204 if not self.completing:
205 return
206 self.filter_text = ""
207
208 self.hide_bar()
209 self.completing = False
210
211 @keymap_command
212 def complete(self, step=None, no_wrap=False):
213
214 if not self.filter_text:
215 return
216
217 # if not step and self.filter_text == self.last_filter_text:
218 # return
219
220 logger.info(f"complete: {self.filter_text}")
221
222 if self.last_complete_pos:
223 widget = self.complete_widget_at_pos(self.last_complete_pos)
224 if isinstance(widget, HighlightableTextMixin):
225 widget.unhighlight()
226
227 self.initial_pos = self.complete_body.get_focus()[1]
228 positions = itertools.cycle(
229 self.complete_body.positions(reverse=(step and step < 0))
230 )
231 pos = next(positions)
232 while pos != self.initial_pos:
233 logger.info(pos)
234 pos = next(positions)
235 for i in range(abs(step or 0)):
236 pos = next(positions)
237
238 while True:
239 widget = self.complete_widget_at_pos(pos)
240 complete_index = self.complete_compare_fn(self.filter_text, str(widget))
241 if complete_index is not None:
242 self.last_complete_pos = pos
243 if isinstance(widget, HighlightableTextMixin):
244 widget.highlight(complete_index, complete_index+len(self.filter_text))
245 self.complete_set_focus(pos)
246 break
247 pos = next(positions)
248 if pos == self.initial_pos:
249 break
250
251 logger.info("done")
252 self.last_filter_text = self.filter_text
253
254 @keymap_command()
255 def cancel(self):
256 logger.debug("cancel")
257 self.complete_container.focus_position = self.selected_button
258 self.close()
259
260 def close(self):
261 self._emit("close")
262
263 def show_bar(self):
264 self.complete_container.contents.append(
265 (self.auto_complete_bar, self.complete_container.options("given", 1))
266 )
267 # self.box.height -= 1
268 self.complete_container.focus_position = 1
269
270 def hide_bar(self):
271 widget = self.complete_widget_at_pos(self.complete_body.get_focus()[1])
272 if isinstance(widget, HighlightableTextMixin):
273 widget.unhighlight()
274 self.complete_container.focus_position = 0
275 del self.complete_container.contents[1]
276 # self.box.height += 1
277
278 @property
279 def filter_text(self):
280 return self.auto_complete_bar.text.get_text()[0]
281
282 @filter_text.setter
283 def filter_text(self, value):
284 return self.auto_complete_bar.set_text(value)
285
286 def on_complete_select(self, source):
287 widget = self.complete_widget_at_pos(self.complete_body.get_focus()[1])
288 self.complete_off()
289 self._emit("select", self.last_complete_pos, widget)
290 self._emit("close")
291
292 def on_complete_close(self, source):
293 self.complete_off()
294
295 __all__ = ["AutoCompleteMixin"]
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
2626 self.padding = padding
2727 self.cell_selection = cell_selection
2828 self.style = style
29
29 # self.details = None
3030 self.sort = self.table.sort_by
3131 self.attr = self.ATTR
3232 self.attr_focused = "%s focused" %(self.attr)
214214 def __init__(self, row, content, indent=None):
215215
216216 self.row = row
217
217 self.contents = content
218218 self.columns = urwid.Columns([
219219 ("weight", 1, content)
220220 ])
237237
238238 DIVIDER_CLASS = DataTableDividerBodyCell
239239
240
240241 @property
241242 def index(self):
242243 return self.content
243244
244245 @property
245246 def data(self):
246 return self.table.get_dataframe_row(self.index)
247 return AttrDict(self.table.get_dataframe_row(self.index))
248
249 @property
250 def data_source(self):
251 return self.table.get_dataframe_row_object(self.index)
247252
248253 def __getitem__(self, column):
249254 cls = self.table.df[self.index, "_cls"]
250255 # row = self.data
251 if (
252 column not in self.table.df.columns
253 and
254 hasattr(cls, "__dataclass_fields__")
255 and
256 type(getattr(cls, column, None)) == property):
257 # logger.info(f"__getitem__ property: {column}={getattr(self.data, column)}")
258 return getattr(self.data, column)
256 if column in self.table.df.columns:
257 # logger.info(f"__getitem__: {column}={self.table.df.get(self.index, column)}")
258 return self.table.df[self.index, column]
259259 else:
260 if column in self.table.df.columns:
261 # logger.info(f"__getitem__: {column}={self.table.df.get(self.index, column)}")
262 return self.table.df[self.index, column]
263 else:
264 raise Exception(column, self.table.df.columns)
260 raise KeyError
261 # raise Exception(column, self.table.df.columns)
265262
266263
267264 def __setitem__(self, column, value):
269266 # logger.info(f"__setitem__: {column}, {value}, {self.table.df[self.index, column]}")
270267
271268 def get(self, key, default=None):
272
273269 try:
274270 return self[key]
275271 except KeyError:
278274 @property
279275 def details_open(self):
280276 # logger.info(f"{self['_details']}")
281 return (self.get("_details") or {}).get("open")
277 # raise Exception(self.get([self.index, "_details"], {}))
278 return self.get("_details", {}).get("open", False)
282279
283280 @details_open.setter
284281 def details_open(self, value):
288285
289286 @property
290287 def details_disabled(self):
291 return (self.get("_details") or {}).get("disabled")
288 return (not self.table.detail_selectable) or self.get([self.index, "_details"], {}).get("disabled", False)
292289
293290 @details_disabled.setter
294291 def details_disabled(self, value):
300297
301298 @property
302299 def details_focused(self):
303 return self.details_open and (self.pile.focus_position > 0)
300 return self.details_open and (
301 len(self.pile.contents) == 0
302 or self.pile.focus_position > 0
303 )
304304
305305 @details_focused.setter
306306 def details_focused(self, value):
307307 if value:
308 self.pile.focus_position = 1
308 self.pile.focus_position = len(self.pile.contents)-1
309309 else:
310310 self.pile.focus_position = 0
311311
312 @property
313 def details(self):
314 if not getattr(self, "_details", None):
315
316 content = self.table.detail_fn((self.data_source))
317 logger.debug(f"open_details: {type(content)}")
318 if not content:
319 return
320
321 # self.table.header.render( (self.table.width,) )
322 indent_width = 0
323 visible_count = itertools.count()
324
325 def should_indent(x):
326 if (isinstance(self.table.detail_hanging_indent, int)
327 and (x[2] is None or x[2] <= self.table.detail_hanging_indent)):
328 return True
329 elif (isinstance(self.table.detail_hanging_indent, str)
330 and x[1].name != self.table.detail_hanging_indent):
331 return True
332 return False
333
334 if self.table.detail_hanging_indent:
335 indent_width = sum([
336 x[1].width if not x[1].hide else 0
337 for x in itertools.takewhile(
338 should_indent,
339 [ (i, c, next(visible_count) if not c.hide else None)
340 for i, c in enumerate(self.table._columns) ]
341 )
342 ])
343
344 self._details = DataTableDetails(self, content, indent_width)
345 return self._details
346
347
312348 def open_details(self):
313349
314 if not self.table.detail_fn or len(self.pile.contents) > 1:
350 if not self.table.detail_fn or not self.details or self.details_open:
315351 return
316 content = self.table.detail_fn(self.data)
317
318 self.table.header.render( (self.table.width,) )
319 indent_width = 0
320 visible_count = itertools.count()
321
322 def should_indent(x):
323 if (isinstance(self.table.detail_hanging_indent, int)
324 and (x[2] is None or x[2] <= self.table.detail_hanging_indent)):
325 return True
326 elif (isinstance(self.table.detail_hanging_indent, str)
327 and x[1].name != self.table.detail_hanging_indent):
328 return True
329 return False
330
331 if self.table.detail_hanging_indent:
332 indent_width = sum([
333 x[1].width if not x[1].hide else 0
334 for x in itertools.takewhile(
335 should_indent,
336 [ (i, c, next(visible_count) if not c.hide else None)
337 for i, c in enumerate(self.table._columns) ]
338 )
339 ])
340
341 self.details = DataTableDetails(self, content, indent_width)
352
353 if len(self.pile.contents) > 1:
354 return
355
356 if self.table.detail_replace:
357 self.pile.contents[0] = (urwid.Filler(urwid.Text("")), self.pile.options("given", 0))
358
342359 self.pile.contents.append(
343360 (self.details, self.pile.options("pack"))
344361 )
362
363 self.details_focused = True
364 if not self["_details"]:
365 self["_details"] = AttrDict()
345366 self["_details"]["open"] = True
346367
347368
348369 def close_details(self):
349370 if not self.table.detail_fn or not self.details_open:
350371 return
372 # raise Exception
351373 self["_details"]["open"] = False
352 # del self.contents.contents[0]
353
354 # self.box.height -= self.pile.contents[1][0].rows( (self.table.width,) )
355 del self.pile.contents[1]
374
375 if self.table.detail_replace:
376 self.pile.contents[0] = (self.box, self.pile.options("pack"))
377
378 # del self.pile.contents[:]
379 # self.pile.contents.append(
380 # (self.box, self.pile.options("pack"))
381 # )
382 if len(self.pile.contents) >= 2:
383 del self.pile.contents[1]
356384
357385 def toggle_details(self):
358386
360388 self.close_details()
361389 else:
362390 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
375391
376392
377393 def set_attr(self, attr):
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):
2022
5052 return self.decoration_width + len(self.label_text)
5153
5254
53 class DropdownItem(urwid.WidgetWrap):
55 class DropdownItem(HighlightableTextMixin, urwid.WidgetWrap):
5456
5557 signals = ["click"]
5658
6062 self.label_text = label
6163 self.value = value
6264 self.margin = margin
63 # self.button = urwid.Button(("dropdown_text", self.label_text))
6465 self.button = DropdownButton(
6566 self.label_text,
6667 left_chars=left_chars, right_chars=right_chars
6768 )
6869 self.padding = urwid.Padding(self.button, width=("relative", 100),
6970 left=self.margin, right=self.margin)
70 # self.padding = self.button
7171
7272
7373 self.attr = urwid.AttrMap(self.padding, {None: "dropdown_text"})
8383 )
8484
8585 @property
86 def highlight_source(self):
87 return self.label_text
88
89 @property
90 def highlightable_attr_normal(self):
91 return "dropdown_text"
92
93 @property
94 def highlightable_attr_highlight(self):
95 return "dropdown_highlight"
96
97 def on_highlight(self):
98 self.set_text(self.highlight_content)
99
100 def on_unhighlight(self):
101 self.set_text(self.highlight_source)
102
103 @property
86104 def width(self):
87105 return self.button.width + 2*self.margin
88106
103121 def label(self):
104122 return self.button.label
105123
106 def set_label(self, label):
107 logger.debug("set_label: " + repr(label) )
108 self.button.set_label(label)
109
110 def highlight_text(self, s, case_sensitive=False):
111
112 (a, b, c) = re.search(
113 r"(.*?)(%s)(.*)" %(s),
114 self.label_text,
115 re.IGNORECASE if not case_sensitive else 0
116 ).groups()
117
118 self.set_label([
119 ("dropdown_text", a),
120 ("dropdown_highlight", b),
121 ("dropdown_text", c),
122 ])
123
124 def unhighlight(self):
125 self.set_label(("dropdown_text", self.label_text))
126
127
128 # class AutoCompleteEdit(urwid_readline.ReadlineEdit):
124 def set_text(self, text):
125 self.button.set_label(text)
126
129127 @keymapped()
130 class AutoCompleteEdit(urwid.Edit):
131
132 signals = ["close"]
133
134 @keymap_command()
135 def clear(self):
136 raise Exception
137 self.set_edit_text("")
138
139 def keypress(self, size, key):
140 if key == "enter":
141 self._emit("close")
142 return super(AutoCompleteEdit, self).keypress(size, key)
143
144 class AutoCompleteBar(urwid.WidgetWrap):
145
146 signals = ["change", "close"]
147 def __init__(self):
148
149 self.prompt = urwid.Text(("dropdown_prompt", "> "))
150 self.text = AutoCompleteEdit("")
151 # self.text.selectable = lambda x: False
152 self.cols = urwid.Columns([
153 (2, self.prompt),
154 ("weight", 1, self.text)
155 ], dividechars=0)
156 self.cols.focus_position = 1
157 self.filler = urwid.Filler(self.cols, valign="bottom")
158 urwid.connect_signal(self.text, "postchange", self.text_changed)
159 urwid.connect_signal(self.text, "close", lambda source: self._emit("close"))
160 super(AutoCompleteBar, self).__init__(self.filler)
161
162 def set_prompt(self, text):
163
164 self.prompt.set_text(("dropdown_prompt", text))
165
166 def set_text(self, text):
167
168 self.text.set_edit_text(text)
169
170 def text_changed(self, source, text):
171 self._emit("change", text)
172
173
174 @keymapped()
175 class DropdownDialog(urwid.WidgetWrap, KeymapMovementMixin):
128 class DropdownDialog(AutoCompleteMixin, urwid.WidgetWrap, KeymapMovementMixin):
176129
177130 signals = ["select", "close"]
178131
181134 label = None
182135 border = None
183136 scrollbar = False
184 auto_complete = False
185137 margin = 0
186138 max_height = None
187139
194146 border=False,
195147 margin = None,
196148 scrollbar=None,
197 auto_complete=None,
198149 left_chars=None,
199150 right_chars=None,
200151 left_chars_top=None,
201152 rigth_chars_top=None,
202153 max_height=None,
203 keymap = {}
154 keymap = {},
155 **kwargs
204156 ):
205157 self.drop_down = drop_down
206158 self.items = items
208160 if border is not None: self.border = border
209161 if margin is not None: self.margin = margin
210162 if scrollbar is not None: self.scrollbar = scrollbar
211 if auto_complete is not None: self.auto_complete = auto_complete
212163 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
220164
221165 self.selected_button = 0
222166 buttons = []
236180 urwid.connect_signal(
237181 self.dropdown_buttons,
238182 'select',
239 lambda source, selection: self.select_button(selection)
240 )
241
242 box_height = self.height -2 if self.border else self.height
243 self.box = urwid.BoxAdapter(self.dropdown_buttons, box_height)
244 self.fill = urwid.Filler(self.box)
183 lambda source, selection: self.on_complete_select(source)
184 )
185
245186 kwargs = {}
246187 if self.label is not None:
247188 kwargs["title"] = self.label
248189 kwargs["tlcorner"] = u"\N{BOX DRAWINGS LIGHT DOWN AND HORIZONTAL}"
249190 kwargs["trcorner"] = u"\N{BOX DRAWINGS LIGHT DOWN AND LEFT}"
250191
251 w = self.fill
192 w = self.dropdown_buttons
252193 if self.border:
253194 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())
265195
266196 self.pile = urwid.Pile([
267197 ("weight", 1, w),
268198 ])
269 self.__super.__init__(self.pile)
270
271
272 @property
273 def KEYMAP(self):
274 return self.drop_down.KEYMAP
275
276 @property
277 def filter_text(self):
278 return self.auto_complete_bar.text.get_text()[0]
279
280 @filter_text.setter
281 def filter_text(self, value):
282 return self.auto_complete_bar.set_text(value)
199 super().__init__(self.pile)
200
201 @property
202 def complete_container(self):
203 return self.pile
204
205 @property
206 def complete_body(self):
207 return self.body
208
209 @property
210 def complete_items(self):
211 return self.body
283212
284213 @property
285214 def max_item_width(self):
324253 def selection(self):
325254 return self.dropdown_buttons.selection
326255
327 def select_button(self, button):
328
329 # logger.debug("select_button: %s" %(button))
330 label = button.label
331 value = button.value
332 self.selected_button = self.focus_position
333 self.complete_off()
334 self._emit("select", button)
335 self._emit("close")
256 # def on_complete_select(self, pos, widget):
257
258 # # logger.debug("select_button: %s" %(button))
259 # label = widget.label
260 # value = widget.value
261 # self.selected_button = self.focus_position
262 # self.complete_off()
263 # self._emit("select", widget)
264 # self._emit("close")
336265
337266 # def keypress(self, size, key):
338
339 # raise Exception
340 # logger.debug("DropdownDialog.keypress: %s" %(key))
341 # if self.completing:
342 # if key in ["enter", "up", "down"]:
343 # self.complete_off()
344 # else:
345 # return key
346 # else:
347 # return super(DropdownDialog, self).keypress(size, key)
267 # return super(DropdownDialog, self).keypress(size, key)
348268
349269
350270 @property
353273 return None
354274 return self.body[self.focus_position].value
355275
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
470276 @keymapped()
471277 class Dropdown(urwid.PopUpLauncher):
472278 # Based in part on SelectOne widget from
474280
475281 signals = ["change"]
476282
283 auto_complete = None
477284 label = None
478285 empty_label = u"\N{EMPTY SET}"
479286 margin = 0
487294 margin = None,
488295 left_chars = None, right_chars = None,
489296 left_chars_top = None, right_chars_top = None,
490 auto_complete = False,
297 auto_complete = None,
491298 max_height = 10,
492299 # keymap = {}
493300 ):
500307
501308 self.border = border
502309 self.scrollbar = scrollbar
503 self.auto_complete = auto_complete
310 if auto_complete is not None: self.auto_complete = auto_complete
311
504312 # self.keymap = keymap
505313
506314 if margin:
544352 urwid.connect_signal(
545353 self.pop_up,
546354 "select",
547 lambda souce, selection: self.select(selection)
355 lambda souce, pos, selection: self.select(selection)
548356 )
549357
550358 urwid.connect_signal(
551359 self.pop_up,
552360 "close",
553 lambda button: self.close_pop_up()
361 lambda source: self.close_pop_up()
554362 )
555363
556364 if self.default is not None:
557365 try:
558366 if isinstance(self.default, str):
559 self.select_label(self.default)
367 try:
368 self.select_label(self.default)
369 except ValueError:
370 pass
560371 else:
561372 raise StopIteration
562373 except StopIteration:
568379 if len(self):
569380 self.select(self.selection)
570381 else:
571 self.button.set_label(("dropdown_text", self.empty_label))
382 self.button.set_text(("dropdown_text", self.empty_label))
572383
573384 cols = [ (self.button_width, self.button) ]
574385
683494 super(Dropdown, self).open_pop_up()
684495
685496 def close_pop_up(self):
686 super(Dropdown, self).close_pop_up()
497 super().close_pop_up()
687498
688499 def get_pop_up_parameters(self):
689500 return {'left': (len(self.label) + 2 if self.label else 0),
698509
699510 @focus_position.setter
700511 def focus_position(self, pos):
512 if pos == self.focus_position:
513 return
701514 # self.select_index(pos)
702515 old_pos = self.focus_position
703516 self.pop_up.selected_button = self.pop_up.focus_position = pos
743556
744557 f = lambda x: x
745558 if not case_sensitive:
746 f = lambda x: x.lower()
747
748 index = next(itertools.dropwhile(
749 lambda x: f(x[1]) != f(label),
750 enumerate((self._items.keys())
751 )
752 ))[0]
559 f = lambda x: x.lower() if isinstance(x, str) else x
560
561 try:
562 index = next(itertools.dropwhile(
563 lambda x: f(x[1]) != f(label),
564 enumerate((self._items.keys())
565 )
566 ))[0]
567 except StopIteration:
568 raise ValueError
753569 self.focus_position = index
754570
755571
819635
820636 def select(self, button):
821637 logger.debug("select: %s" %(button))
822 self.button.set_label(("dropdown_text", button.label))
638 self.button.set_text(("dropdown_text", button.label))
823639 self.pop_up.dropdown_buttons.listbox.set_focus_valign("top")
824640 # if old_pos != pos:
825641 self._emit("change", self.selected_label, self.selected_value)
0 import logging
1 logger = logging.getLogger(__name__)
2
3 class HighlightableTextMixin(object):
4
5 @property
6 def highlight_state(self):
7 if not getattr(self, "_highlight_state", False):
8 self._highlight_state = False
9 self._highlight_case_sensitive = False
10 self._highlight_string = None
11 return self._highlight_state
12
13 @property
14 def highlight_content(self):
15 if self.highlight_state:
16 return self.get_highlight_text()
17 else:
18 return self.highlight_source
19
20
21 def highlight(self, start, end):
22 self._highlight_state = True
23 self._highlight_location = (start, end)
24 self.on_highlight()
25
26 def unhighlight(self):
27 self._highlight_state = False
28 self._highlight_location = None
29 self.on_unhighlight()
30
31 def get_highlight_text(self):
32
33 if not self._highlight_location:
34 return None
35
36 return [
37 (self.highlightable_attr_normal, self.highlight_source[:self._highlight_location[0]]),
38 (self.highlightable_attr_highlight, self.highlight_source[self._highlight_location[0]:self._highlight_location[1]]),
39 (self.highlightable_attr_normal, self.highlight_source[self._highlight_location[1]:]),
40 ]
41
42 @property
43 def highlight_source(self):
44 raise NotImplementedError
45
46 @property
47 def highlightable_attr_normal(self):
48 raise NotImplementedError
49
50 @property
51 def highlightable_attr_highlight(self):
52 raise NotImplementedError
53
54 def on_highlight(self):
55 pass
56
57 def on_unhighlight(self):
58 pass
59
60 __all__ = ["HighlightableTextMixin"]
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 if isinstance(cmd, tuple):
80 if len(cmd) == 3:
81 (cmd, args, kwargs) = cmd
82 elif len(cmd) == 2:
83 (cmd, args) = cmd
84 else:
85 raise Exception
86 elif isinstance(cmd, str):
87 cmd = cmd.replace(" ", "_")
88 else:
89 return None
90
91 if hasattr(self, cmd):
92 fn_name = cmd
93 else:
94 try:
95 fn_name = self.KEYMAP_MAPPING[cmd]
96 except KeyError:
97 raise KeyError(cmd, self.KEYMAP_MAPPING, type(self))
98
99 f = getattr(self, fn_name)
100 ret = f(*args, **kwargs)
101 if asyncio.iscoroutine(ret):
102 asyncio.get_event_loop().create_task(ret)
103 return None
104
105 cls._keymap_command = keymap_command
106
107 def keypress_decorator(func):
108
109 def keypress(self, size, key):
110 logger.debug(f"{cls} wrapped keypress: {key}, {cls.KEYMAP_SCOPE()}, {self.KEYMAP_MERGED.get(cls.KEYMAP_SCOPE(), {}).keys()}")
111
112 if key and callable(func):
113 logger.debug(f"{cls} wrapped keypress, key: {key}, calling orig: {func}")
114 key = func(self, size, key)
115 if key:
116 logger.debug(f"{cls} wrapped keypress, key: {key}, calling super: {super(cls, self).keypress}")
117 key = super(cls, self).keypress(size, key)
118 keymap_combined = dict(self.KEYMAP_MERGED, **KEYMAP_GLOBAL)
119 if key and keymap_combined.get(cls.KEYMAP_SCOPE(), {}).get(key, None):
120 cmd = keymap_combined[cls.KEYMAP_SCOPE()][key]
121 if isinstance(cmd, str) and cmd.startswith("keypress "):
122 new_key = cmd.replace("keypress ", "").strip()
123 logger.debug(f"{cls} remap {key} => {new_key}")
124 key = new_key
125 else:
126 logger.debug(f"{cls} wrapped keypress, key: {key}, calling keymap command")
127 key = self._keymap_command(cmd)
128 return key
129
130 return keypress
131
132 cls.keypress = keypress_decorator(getattr(cls, "keypress", None))
117133 return cls
134
118135 return wrapper
136
137
119138
120139
121140 @keymapped()
122141 class KeymapMovementMixin(object):
142
143 @classmethod
144 def KEYMAP_SCOPE(cls):
145 return "movement"
123146
124147 def cycle_position(self, n):
125148
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):
00 Metadata-Version: 1.2
11 Name: panwid
2 Version: 0.3.0.dev15
2 Version: 0.3.3
33 Summary: Useful widgets for urwid
44 Home-page: https://github.com/tonycpsu/panwid
55 Author: Tony Cebzanov
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.3',
1111 description='Useful widgets for urwid',
1212 author='Tony Cebzanov',
1313 author_email='tonycpsu@gmail.com',