Codebase list python-panwid / upstream/0.3.3.dev3
Import upstream version 0.3.3.dev3 Debian Janitor 2 years ago
20 changed file(s) with 1895 addition(s) and 649 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.dev3
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 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.dev3"
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 ]
5791 data_columns += ["_cls", "_details"]
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)
74 # data["_details"] = [{"open": False, "disabled": False}] * len(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)
116 data["_details"] = [{"open": False, "disabled": False}] * len(rows)
117 data["_cls"] = [type(rows[0][0] if with_sidecar else rows[0])] * len(rows) # all rows assumed to have same class
118 # raise Exception(data["_cls"])
75119 # if not "_details" in data:
76120 # data["_details"] = [{"open": False, "disabled": False}] * len(rows)
77121
78 if not limit:
122 if replace:
79123 if len(rows):
80124 indexes = [x for x in self.index if x not in data.get(self.index_name, [])]
81125 if len(indexes):
85129
86130 # logger.info(f"update_rowGs: {self.index}, {data[self.index_name]}")
87131
88 if not len(rows):
89 return []
90
91132 if self.index_name not in data:
92133 index = list(range(len(self), len(self) + len(rows)))
93134 data[self.index_name] = index
95136 index = data[self.index_name]
96137
97138 for c in data.keys():
98 try:
99 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]))
139 # try:
140 # raise Exception(data[self.index_name], c, data[c])
141 self.set(data[self.index_name], c, data[c])
142 # except ValueError as e:
143 # logger.error(e)
144 # logger.info(f"update_rows: {self.index}, {data}")
104145 return data.get(self.index_name, [])
105146
106147 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 return cls(**d)
719 elif hasattr(cls, "__dataclass_fields__"):
720 # Python dataclasses
678721 # klass = type(f"DataTableRow_{cls.__name__}", [cls],
679722 klass = make_dataclass(
680723 f"DataTableRow_{cls.__name__}",
688731 for k in set(
689732 cls.__dataclass_fields__.keys())
690733 })
691
692734 return k
735 elif HAVE_PONY and issubclass(cls, pony.orm.core.Entity):
736 keys = {
737 k.name: d.get(k.name, None)
738 for k in (cls._pk_ if isinstance(cls._pk_, tuple) else (cls._pk_,))
739 }
740 # raise Exception(keys)
741 with db_session:
742 return cls.get(**keys)
693743 else:
694 return cls(**d)
744 return AttrDict(**d)
695745 else:
696746 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)
747
706748
707749 def get_row(self, index):
708750 row = self.df.get(index, "_rendered_row")
709
751 details_open = False
710752 if self.df.get(index, "_dirty") or row is None:
711753 self.refresh_calculated_fields([index])
712754 # vals = self[index]
713 vals = self.get_dataframe_row(index)
755
756 pos = self.index_to_position(index)
757 vals = self.get_dataframe_row_object(index)
714758 row = self.render_item(index)
759 position = self.index_to_position(index)
715760 if self.row_attr_fn:
716 attr = self.row_attr_fn(vals)
761 attr = self.row_attr_fn(position, row.data_source, row)
717762 if attr:
718763 row.set_attr(attr)
719764 focus = self.df.get(index, "_focus_position")
720765 if focus is not None:
721766 row.set_focus_column(focus)
767 if details_open:
768 row.open_details()
722769 self.df.set(index, "_rendered_row", row)
723770 self.df.set(index, "_dirty", False)
771
724772 return row
725773
726774 def get_row_by_position(self, position):
727 index = self.position_to_index(self.filtered_rows[position])
775 # index = self.position_to_index(self.filtered_rows[position])
776 index = self.filtered_rows[position]
728777 return self.get_row(index)
729778
730779 def get_value(self, row, column):
737786 def selection(self):
738787 if len(self.body) and self.focus_position is not None:
739788 # FIXME: make helpers to map positions to indexes
740 return self[self.focus_position]
741
789 try:
790 return self[self.focus_position]
791 except IndexError:
792 return None
793
794 @property
795 def selection_data(self):
796 return AttrDict(self.df.get_columns(self.position_to_index(self.focus_position), as_dict=True))
742797
743798 def render_item(self, index):
744799 row = DataTableBodyRow(self, index,
759814 if not col.value_fn: continue
760815 for index in indexes:
761816 if self.df[index, "_dirty"]:
762 self.df.set(index, col.name, col.value_fn(self, self.get_dataframe_row(index)))
817 self.df.set(index, col.name, col.value_fn(self, self.get_dataframe_row_object(index)))
763818
764819 def visible_data_column_index(self, column_name):
765820 try:
766821 return next(i for i, c in enumerate(self.visible_data_columns)
767822 if c.name == column_name)
768823
769 except StopIteration:
824 except Exception as e:
825 logger.error(f"column not found in visible_data_column_index: {column_name}")
826 logger.exception(e)
770827 raise IndexError
771828
772829 def sort_by_column(self, col=None, reverse=None, toggle=False):
779836
780837 elif col is None:
781838 col = self.sort_column
839
782840
783841 if isinstance(col, int):
784842 try:
791849 column_name = col
792850 try:
793851 column_number = self.visible_data_column_index(column_name)
852 column_name = col
794853 except:
795 raise
854
855 column_name = self.initial_sort[0] or self.visible_data_columns[0].name
856 if column_name is None:
857 return
858 column_number = self.visible_data_column_index(column_name)
859
796860
797861 self.sort_column = column_number
798862
804868 except:
805869 return # FIXME
806870
807 if reverse is None and column.sort_reverse is not None:
808 reverse = column.sort_reverse
809
810871 if toggle and column_name == self.sort_by[0]:
811872 reverse = not self.sort_by[1]
873
874 elif reverse is None and column.sort_reverse is not None:
875 reverse = column.sort_reverse
876
812877 sort_by = (column_name, reverse)
813878 # if not self.query_sort:
814879
847912 if not isinstance(c, DataTableDivider)
848913 ][index]
849914
850 logger.info(f"{index}, {idx}")
851915 if self.with_header:
852916 self.header.set_focus_column(idx)
853917
884948
885949 self._columns += columns
886950 for i, column in enumerate(columns):
951 # FIXME: pass reference
952 column.table = self
887953 self.df[column.name] = data=data[i] if data else None
888954
889955 self.invalidate()
903969 self.invalidate()
904970
905971 def set_columns(self, columns):
972 # logger.info(self._columns)
906973 self.remove_columns([c.name for c in self._columns])
907974 self.add_columns(columns)
975 # logger.info(self._columns)
908976 self.reset()
909977
910978 def toggle_columns(self, columns, show=None):
11311199 self.header.update()
11321200 if self.with_footer:
11331201 self.footer.update()
1134 self._modified()
1202 self.invalidate()
1203
1204 # self._modified()
11351205
11361206 def invalidate_rows(self, indexes):
11371207 if not isinstance(indexes, list):
11431213 self._modified()
11441214 # FIXME: update header / footer if dynamic
11451215
1216 def invalidate_selection(self):
1217 self.invalidate_rows(self.focus_position)
1218
11461219 def swap_rows_by_field(self, p0, p1, field=None):
11471220
11481221 if not field:
11701243
11711244 def row_count(self):
11721245
1173 if not self.limit:
1174 return None
1246 # if not self.limit:
1247 # return None
11751248
11761249 if self.limit:
11771250 return self.query_result_count()
11861259 filters = [filters]
11871260
11881261 self.filtered_rows = list(
1189 i
1262 row[self.df.index_name]
11901263 for i, row in enumerate(self.df.iterrows())
11911264 if not filters or all(
11921265 f(row)
12221295 # logger.debug("load_more")
12231296 if position is not None and position > len(self):
12241297 return False
1225 self.page = len(self) // self.limit
1298 self.page += 1
1299 # self.page = len(self) // self.limit
12261300 offset = (self.page)*self.limit
12271301 # 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
1302 # if (self.row_count() is not None
1303 # and len(self) >= self.row_count()):
1304 # self._emit("end", self.row_count())
1305 # return False
1306
1307 updated = self.requery(offset=offset)
1308 # try:
1309 # updated = self.requery(offset=offset)
1310 # except Exception as e:
1311 # raise Exception(f"{position}, {len(self)}, {self.row_count()}, {offset}, {self.limit}, {str(e)}")
1312
1313 return updated
12391314
12401315 def requery(self, offset=None, limit=None, load_all=False, **kwargs):
12411316 logger.debug(f"requery: {offset}, {limit}")
12581333 kwargs["offset"] = offset
12591334 kwargs["limit"] = limit
12601335
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)
1336 kwargs["cursor"] = self.pagination_cursor
1337
1338 rows = list(self.query(**kwargs)) if self.data is None else self.data
1339
1340 if len(rows) and self.sort_by[0]:
1341 self.pagination_cursor = getattr(rows[-1], self.sort_by[0])
1342
1343 updated = self.df.update_rows(rows, replace=self.limit is None, with_sidecar = self.with_sidecar)
1344
12701345 self.df["_focus_position"] = self.sort_column
12711346
12721347 self.refresh_calculated_fields()
12741349
12751350 if len(updated):
12761351 for i in updated:
1352 if not i in self.filtered_rows:
1353 continue
12771354 pos = self.index_to_position(i)
12781355 self[pos].update()
1279 self.sort_by_column(*self.sort_by)
1356 # self.sort_by_column(*self.sort_by)
12801357
12811358 self._modified()
1359 self._emit("requery", self.row_count())
12821360
12831361 if not len(self) and self.empty_message:
12841362 self.show_message(self.empty_message)
12851363 else:
12861364 self.hide_message()
1365 return len(updated)
12871366 # self.invalidate()
12881367
12891368
12931372 idx = None
12941373 pos = 0
12951374 # limit = len(self)-1
1375 self.df.delete_all_rows()
12961376 if reset:
12971377 self.page = 0
12981378 offset = 0
12991379 limit = self.limit
1300 self.df.delete_all_rows()
13011380 else:
13021381 try:
13031382 idx = getattr(self.selection.data, self.index)
1304 except (AttributeError, IndexError):
1305 pass
1306 pos = self.focus_position
1383 pos = self.focus_position
1384 except (AttributeError, IndexError, ValueError):
1385 pos = None
13071386 limit = len(self)
13081387 # del self[:]
13091388 self.requery(offset=offset, limit=limit)
13141393 try:
13151394 pos = self.index_to_position(idx)
13161395 except:
1317 pass
1318 self.focus_position = pos
1396 return
1397 if pos is not None:
1398 self.focus_position = pos
13191399
13201400 # self.focus_position = 0
13211401
13301410
13311411
13321412 def reset(self, reset_sort=False):
1413
1414 self.pagination_cursor = None
13331415 self.refresh(reset=True)
13341416
13351417 if reset_sort and self.initial_sort is not None:
13391421 # if r.details_open:
13401422 # r.open_details()
13411423 self._modified()
1424 if len(self):
1425 self.set_focus(0)
13421426 # self._invalidate()
13431427
13441428 def pack_columns(self):
13681452 available -= w
13691453
13701454 self.resize_body_rows()
1455
13711456
13721457 def show_message(self, message):
13731458
13891474 self.listbox,
13901475 "center", ("relative", 100), "top", ("relative", 100)
13911476 )
1477 overlay.selectable = lambda: True
13921478 self.listbox_placeholder.original_widget = overlay
13931479 self._message_showing = True
13941480
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
279 return self.get("_details", {}).get("open", False)
282280
283281 @details_open.setter
284282 def details_open(self, value):
288286
289287 @property
290288 def details_disabled(self):
291 return (self.get("_details") or {}).get("disabled")
289 return (not self.table.detail_selectable) or self.get([self.index, "_details"], {}).get("disabled", False)
292290
293291 @details_disabled.setter
294292 def details_disabled(self, value):
300298
301299 @property
302300 def details_focused(self):
303 return self.details_open and (self.pile.focus_position > 0)
301 return self.details_open and (
302 len(self.pile.contents) == 0
303 or self.pile.focus_position > 0
304 )
304305
305306 @details_focused.setter
306307 def details_focused(self, value):
307308 if value:
308 self.pile.focus_position = 1
309 self.pile.focus_position = len(self.pile.contents)-1
309310 else:
310311 self.pile.focus_position = 0
311312
313 @property
314 def details(self):
315 if not getattr(self, "_details", None):
316
317 content = self.table.detail_fn((self.data_source))
318 logger.debug(f"open_details: {type(content)}")
319 if not content:
320 return
321
322 # self.table.header.render( (self.table.width,) )
323 indent_width = 0
324 visible_count = itertools.count()
325
326 def should_indent(x):
327 if (isinstance(self.table.detail_hanging_indent, int)
328 and (x[2] is None or x[2] <= self.table.detail_hanging_indent)):
329 return True
330 elif (isinstance(self.table.detail_hanging_indent, str)
331 and x[1].name != self.table.detail_hanging_indent):
332 return True
333 return False
334
335 if self.table.detail_hanging_indent:
336 indent_width = sum([
337 x[1].width if not x[1].hide else 0
338 for x in itertools.takewhile(
339 should_indent,
340 [ (i, c, next(visible_count) if not c.hide else None)
341 for i, c in enumerate(self.table._columns) ]
342 )
343 ])
344
345 self._details = DataTableDetails(self, content, indent_width)
346 return self._details
347
348
312349 def open_details(self):
313350
314 if not self.table.detail_fn or len(self.pile.contents) > 1:
351 if not self.table.detail_fn or not self.details or self.details_open:
315352 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)
353
354 if self.table.detail_replace:
355 self.pile.contents[0] = (urwid.Filler(urwid.Text("")), self.pile.options("given", 0))
356
342357 self.pile.contents.append(
343358 (self.details, self.pile.options("pack"))
344359 )
360
361 self.details_focused = True
362 if not self["_details"]:
363 self["_details"] = AttrDict()
345364 self["_details"]["open"] = True
346365
347366
348367 def close_details(self):
349368 if not self.table.detail_fn or not self.details_open:
350369 return
370 # raise Exception
351371 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]
372
373 if self.table.detail_replace:
374 self.pile.contents[0] = (self.box, self.pile.options("pack"))
375
376 # del self.pile.contents[:]
377 # self.pile.contents.append(
378 # (self.box, self.pile.options("pack"))
379 # )
380 if len(self.pile.contents) >= 2:
381 del self.pile.contents[1]
356382
357383 def toggle_details(self):
358384
360386 self.close_details()
361387 else:
362388 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
375389
376390
377391 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)
108 self.columns = urwid.Columns([
109 ('weight', 1, self.listbox)
110 ])
113 w = self.listbox = urwid.ListBox(body)
114
111115 if self.with_scrollbar:
112 self.scroll_bar = ListBoxScrollBar(self)
113 self.columns.contents.append(
114 (self.scroll_bar, self.columns.options("given", 1))
115 )
116 super(ScrollingListBox, self).__init__(self.columns)
116 if isinstance(self.with_scrollbar, type):
117 self.scrollbar_class = self.with_scrollbar
118 self.listbox.rows_max = self.rows_max
119 w = self.scrollbar_class(self.listbox)
120 super().__init__(w)
121
122 # def on_modified(self):
123 # if self.with_scrollbar and len(self.body):
124 # self.scroll_bar.update(self.size)
125
126 def rows_max(self, size, focus=False):
127 return urwid.ListBox.rows_max(self, size, focus)
128
117129
118130 @classmethod
119131 def get_palette_entries(cls):
154166 def mouse_event(self, size, event, button, col, row, focus):
155167
156168 SCROLL_WHEEL_HEIGHT_RATIO = 0.5
157 if row < 0 or row >= self.height:
169 if row < 0 or row >= self._height or not len(self.listbox.body):
158170 return
159171 if event == 'mouse press':
160172 if button == 1:
161173 self.mouse_state = 1
162174 self.drag_from = self.drag_last = (col, row)
163175 elif button == 4:
164 pos = self.listbox.focus_position - int(self.height * SCROLL_WHEEL_HEIGHT_RATIO)
176 pos = self.listbox.focus_position - int(self._height * SCROLL_WHEEL_HEIGHT_RATIO)
165177 if pos < 0:
166178 pos = 0
167179 self.listbox.focus_position = pos
168180 self.listbox.make_cursor_visible(size)
169181 self._invalidate()
170182 elif button == 5:
171 pos = self.listbox.focus_position + int(self.height * SCROLL_WHEEL_HEIGHT_RATIO)
183 pos = self.listbox.focus_position + int(self._height * SCROLL_WHEEL_HEIGHT_RATIO)
172184 if pos > len(self.listbox.body) - 1:
173185 if self.infinite:
174186 self.load_more = True
234246 if len(self.body):
235247 return self.body[self.focus_position]
236248
249 @property
250 def size(self):
251 return (self._width, self._height)
237252
238253 def render(self, size, focus=False):
239254
240255 maxcol = size[0]
241 self.width = maxcol
256 self._width = maxcol
242257 if len(size) > 1:
243258 maxrow = size[1]
244 self.height = maxrow
259 modified = self._height == 0
260 self._height = maxrow
261 # if modified:
262 # self.on_modified()
263 else:
264 self._height = 0
245265
246266 # print
247267 # print
262282 urwid.signals.emit_signal(
263283 self, "load_more", focus)
264284 if (self.queued_keypress
265 and focus
266 and focus < len(self.body)
285 and focus is not None
286 # and focus < len(self.body)-1
267287 ):
268 # logger.info("send queued keypress")
288 # logger.info(f"send queued keypress: {focus}, {len(self.body)}")
269289 self.keypress(size, self.queued_keypress)
270290 self.queued_keypress = None
271291 # self.listbox._invalidate()
272292 # self._invalidate()
273293
274 if self.with_scrollbar and len(self.body):
275 self.scroll_bar.update(size)
276
277294 return super(ScrollingListBox, self).render(size, focus)
278295
279296
295312 def focus_position(self):
296313 if not len(self.listbox.body):
297314 raise IndexError
298 if len(self.listbox.body):
315 try:
299316 return self.listbox.focus_position
317 except IndexError:
318 pass
300319 return None
301320
302321 @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 @property
20 def value_label(self):
21 label_text = str(self.value)
22 bar_len = self.spark_bar.bar_width(0)
23 attr1 = f"{DEFAULT_LABEL_COLOR}:{self.progress_color}"
24 content = [(attr1, label_text[:bar_len])]
25 if len(label_text) > bar_len-1:
26 attr2 = f"{DEFAULT_LABEL_COLOR}:{self.remaining_color}"
27 content.append((attr2, label_text[bar_len:]))
28 return urwid.Text(content)
29
30 @property
31 def maximum_label(self):
32 label_text = str(self.maximum)
33 bar_len = self.spark_bar.bar_width(1)
34 attr1 = f"{DEFAULT_LABEL_COLOR}:{self.remaining_color}"
35 content = []
36 if bar_len:
37 content.append((attr1, label_text[-bar_len:]))
38 if len(label_text) > bar_len:
39 attr2 = f"{DEFAULT_LABEL_COLOR}:{self.progress_color}"
40 content.insert(0, (attr2, label_text[:-bar_len or None]))
41 return urwid.Text(content)
42
43 def update(self):
44 value_label = None
45 maximum_label = None
46
47 self.spark_bar = SparkBarWidget(
48 [
49 SparkBarItem(self.value, bcolor=self.progress_color),
50 SparkBarItem(self.maximum-self.value, bcolor=self.remaining_color),
51 ], width=self.width
52 )
53 overlay1 = urwid.Overlay(
54 urwid.Filler(self.value_label),
55 urwid.Filler(self.spark_bar),
56 "left",
57 len(self.value_label.get_text()[0]),
58 "top",
59 1
60 )
61 label_len = len(self.maximum_label.get_text()[0])
62 overlay2 = urwid.Overlay(
63 urwid.Filler(self.maximum_label),
64 overlay1,
65 "left",
66 label_len,
67 "top",
68 1,
69 left=self.width - label_len
70 )
71 self.placeholder.original_widget = urwid.BoxAdapter(overlay2, 1)
72
73 def set_value(self, value):
74 self.value = value
75 self.update()
76
77 @property
78 def items(self):
79 return self.spark_bar.items
80
81 __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.dev3
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
77
88 name = 'panwid'
99 setup(name=name,
10 version='0.3.0.dev15',
10 version='0.3.3.dev3',
1111 description='Useful widgets for urwid',
1212 author='Tony Cebzanov',
1313 author_email='tonycpsu@gmail.com',