├── .bumpversion.cfg ├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── doc └── screencast.gif ├── examples ├── datatable.py ├── datatable_simple.py ├── datatable_simple_query.py ├── dialog.py ├── dropdown.py ├── progressbar.py └── sparkwidgets.py ├── panwid ├── __init__.py ├── autocomplete.py ├── datatable │ ├── __init__.py │ ├── cells.py │ ├── columns.py │ ├── common.py │ ├── dataframe.py │ ├── datatable.py │ └── rows.py ├── dialog │ └── __init__.py ├── dropdown.py ├── highlightable.py ├── keymap.py ├── listbox.py ├── progressbar.py ├── scroll.py ├── sparkwidgets.py └── tabview.py ├── setup.cfg ├── setup.py └── test ├── __init__.py ├── test_datatable.py └── test_dropdown.py /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | commit = True 3 | tag = True 4 | current_version = 0.3.5 5 | parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\.(?P[a-z]+)(?P\d+))? 6 | serialize = 7 | {major}.{minor}.{patch}.{release}{dev} 8 | {major}.{minor}.{patch} 9 | 10 | [bumpversion:part:release] 11 | optional_value = gamma 12 | values = 13 | dev 14 | gamma 15 | 16 | [bumpversion:file:setup.py] 17 | 18 | [bumpversion:file:panwid/__init__.py] 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | .static_storage/ 57 | .media/ 58 | local_settings.py 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # pyenv 77 | .python-version 78 | 79 | # celery beat schedule file 80 | celerybeat-schedule 81 | 82 | # SageMath parsed files 83 | *.sage.py 84 | 85 | # Environments 86 | .env 87 | .venv 88 | env/ 89 | venv/ 90 | ENV/ 91 | env.bak/ 92 | venv.bak/ 93 | 94 | # Spyder project settings 95 | .spyderproject 96 | .spyproject 97 | 98 | # Rope project settings 99 | .ropeproject 100 | 101 | # mkdocs documentation 102 | /site 103 | 104 | # mypy 105 | .mypy_cache/ 106 | *.py~ 107 | *.py# 108 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | install: 3 | - pip install . 4 | script: 5 | - python setup.py test 6 | matrix: 7 | include: 8 | - stage: test 9 | python: 3.7 10 | dist: xenial 11 | - stage: deploy 12 | python: 3.7 13 | dist: xenial 14 | deploy: 15 | provider: pypi 16 | user: tonycpsu 17 | password: 18 | secure: aXjV+Yc94YdW4Q0vGo2HOp+u29PyRkYTMTxhf8kjWW9eKRNfOhL0LKVF86UXPxxZYMUJUD1Ep/H212ofUqbCL3xvBoMZsMbfzvC3NQMtEfIjMX5rn8pWRloPmJvLZUnNj5j60xR+ku2PLEZMrxtIZrGtAH95BZpKPg3mU6Gn3NIgJY2MHEkrwg1ywfDAiZNhL+hhhIpVyxtwSqd8SZUsh0VmUJ+93pmqmUtqXG7cvYeBy3tPIQkLodh4TmtsGki9brcxcJ3rBRfYxIDEOOfrM83HRSH+ZBHh6x0fV2uJRq7ZdFeea3e0aEdJYdm955K9TvvFzRkZhVUWOj+a0A3++JUzaaQGkFa/jUG+ll01EuN5+LgwwKdPcsi/z3ULhyhdusZrLUqJbGA1eahaSZdZCq7vG57qa6+wReG6pz/aLduayLP/cjAp0RxOqQsyh61e+nvY4CW9F2r+njQiO2UlkqOHp4WxVe4Finlbi1Nm77k+sFIrJFEF8b/LV7teV4QoQlhz+J+ODaA/VEbwqxVx5GAE5AC5fMLMblZufVHT0nw08nwVh59SF7lEAWLkmKAN6X704kp7wSrZAkRPgC/oYHNUx6lgfED0b9YghuGRk8d454ge9nY25wayMli/WuTTng3NoI/Amh13+NaQXLfKDOhqGEPIaQcX97JQv1QuQcM= 19 | on: 20 | tags: true 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | panwid 2 | ====== 3 | 4 | A collection of widgets for [Urwid](https://urwid.org/). 5 | 6 | Currently consists of the following sub-modules: 7 | 8 | ## autocomplete ## 9 | 10 | Adds autocomplete functionality to a container widget. See `Dropdown` 11 | implementation for how it works until there's proper documentation. 12 | 13 | ## datatable ## 14 | 15 | Widget for displaying tabular data. 16 | 17 | Features include: 18 | * Flexible options for column formatting and sorting 19 | * Progressive loading / "infinite scrolling" for paginating large datasets 20 | * Scrollbar with indicator showing position within dataset 21 | 22 | [![asciicast](https://asciinema.org/a/iRbvnuv7DERhZrdKKBfpGtXqw.png)](https://asciinema.org/a/iRbvnuv7DERhZrdKKBfpGtXqw?autoplay=1) 23 | 24 | ## dialog ## 25 | 26 | A set of simple classes for implementing pop-up dialogs. 27 | 28 | ## dropdown ## 29 | 30 | Dropdown menu widget with autocomplete support. 31 | 32 | [![asciicast](https://asciinema.org/a/m23L8xPJsTQRxzOCwvc1SuduN.png)](https://asciinema.org/a/m23L8xPJsTQRxzOCwvc1SuduN?autoplay=1) 33 | 34 | ## highlightable ## 35 | 36 | Adds the ability for text widgets (or any widget with text in them) to have 37 | strings highlighted in them. See `Dropdown` implementation until there's proper 38 | documentation. 39 | 40 | ## keymap ## 41 | 42 | Adds ability to define keyboard mappings across multiple widgets in your 43 | application without having to write Urwid `keypress`` methods. See `Dropdown` 44 | implementation until there's proper documentation. 45 | 46 | ## progressbar ## 47 | 48 | A configurable horizontal progress bar that uses unicode box drawing characters 49 | for sub-character-width resolution. 50 | 51 | ## scroll ## 52 | 53 | Makes any fixed or flow widget vertically scrollable. Copied with permission 54 | from `rndusr/stig`. 55 | 56 | ## sparkwidgets ## 57 | 58 | A set of sparkline-ish widgets for displaying data visually using a small number 59 | of screen characters. 60 | 61 | ## tabview ## 62 | 63 | A container widget that allows selection of content via tab handles. 64 | 65 | **TODOs**: 66 | 67 | * Documentation 68 | * Make more 16-color and non-unicode friendly 69 | * Add combo box functionality to dropdown 70 | * Update datatable so that footer functions calculate based on the entire 71 | dataset, not just visible rows. 72 | 73 | -------------------------------------------------------------------------------- /doc/screencast.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonycpsu/panwid/e85bcfb95ef7e11aa594a996bae829f67787e1ee/doc/screencast.gif -------------------------------------------------------------------------------- /examples/datatable.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import logging 4 | logger = logging.getLogger(__name__) 5 | import urwid 6 | from panwid.datatable import * 7 | from panwid.listbox import ScrollingListBox 8 | from urwid_utils.palette import * 9 | from orderedattrdict import AttrDict 10 | import os 11 | import random 12 | import string 13 | from optparse import OptionParser 14 | from dataclasses import * 15 | import typing 16 | from collections.abc import MutableMapping 17 | 18 | screen = urwid.raw_display.Screen() 19 | # screen.set_terminal_properties(1<<24) 20 | screen.set_terminal_properties(256) 21 | 22 | NORMAL_FG_MONO = "white" 23 | NORMAL_FG_16 = "light gray" 24 | NORMAL_BG_16 = "black" 25 | NORMAL_FG_256 = "light gray" 26 | NORMAL_BG_256 = "g0" 27 | 28 | @dataclass 29 | class BaseDataClass(MutableMapping): 30 | 31 | def keys(self): 32 | return self.__dataclass_fields__.keys() 33 | 34 | def get(self, key, default=None): 35 | 36 | try: 37 | return self[key] 38 | except (KeyError, AttributeError): 39 | return default 40 | 41 | def __getitem__(self, key): 42 | return getattr(self, key) 43 | 44 | def __setitem__(self, key, value): 45 | setattr(self, key, value) 46 | 47 | def __delitem__(self, key): 48 | delattr(self, key) 49 | 50 | def __iter__(self): 51 | return iter(self.keys()) 52 | 53 | def __len__(self): 54 | return len(self.keys()) 55 | 56 | @dataclass 57 | class Foo(BaseDataClass): 58 | uniqueid: int 59 | foo: int 60 | bar: float 61 | baz: str 62 | qux: urwid.Widget 63 | xyzzy: str 64 | baz_len: typing.Any 65 | a: dict 66 | d: dict 67 | color: list 68 | # _details: dict = field(default_factory=lambda: {"open": True, "disabled": False}) 69 | # _cls: typing.Optional[type] = None 70 | @property 71 | def _details(self): 72 | return {"open": True, "disabled": False} 73 | 74 | def main(): 75 | 76 | 77 | parser = OptionParser() 78 | parser.add_option("-v", "--verbose", action="count", default=0), 79 | (options, args) = parser.parse_args() 80 | 81 | if options.verbose: 82 | formatter = logging.Formatter( 83 | "%(asctime)s [%(module)16s:%(lineno)-4d] [%(levelname)8s] %(message)s", 84 | datefmt='%Y-%m-%d %H:%M:%S' 85 | ) 86 | fh = logging.FileHandler("datatable.log") 87 | # fh.setLevel(logging.DEBUG) 88 | fh.setFormatter(formatter) 89 | if options.verbose > 0: 90 | logger.setLevel(logging.DEBUG) 91 | logging.getLogger("panwid.datatable").setLevel(logging.DEBUG) 92 | else: 93 | logger.setLevel(logging.INFO) 94 | logging.getLogger("panwid.datatable").setLevel(logging.INFO) 95 | logger.addHandler(fh) 96 | logging.getLogger("panwid.datatable").addHandler(fh) 97 | # logging.getLogger("raccoon.dataframe").setLevel(logging.DEBUG) 98 | # logging.getLogger("raccoon.dataframe").addHandler(fh) 99 | 100 | 101 | attr_entries = {} 102 | for attr in ["dark red", "dark green", "dark blue", "dark cyan"]: 103 | attr_entries[attr.split()[1]] = PaletteEntry( 104 | mono = "white", 105 | foreground = attr, 106 | background = "black" 107 | ) 108 | entries = ScrollingListBox.get_palette_entries() 109 | entries.update(DataTable.get_palette_entries(user_entries=attr_entries)) 110 | # entries.update(attr_entries) 111 | palette = Palette("default", **entries) 112 | 113 | 114 | COLUMNS = [ 115 | DataTableColumn("foo", label="Foo", align="right", 116 | # width=("weight", 1), 117 | width=3, 118 | sort_key = lambda v: (v is None, v), 119 | pack=True, 120 | attr="color", padding=0, 121 | footer_fn = lambda column, values: sum(v for v in values if v is not None) 122 | ), 123 | DataTableDivider(u"\N{DOUBLE VERTICAL LINE}"), 124 | DataTableColumn("bar", label="Bar", width=10, align="right", 125 | format_fn = lambda v: round(v, 2) if v is not None else v, 126 | decoration_fn = lambda v: ("cyan", v), 127 | sort_reverse=True, sort_icon=False, padding=0),# margin=5), 128 | DataTableColumn("baz", label="Baz!", 129 | width=("weight", 5), 130 | # pack=True, 131 | min_width=5, 132 | align="right", 133 | truncate=True), 134 | DataTableColumn( 135 | "qux", 136 | label=urwid.Text([("red", "q"), ("green", "u"), ("blue", "x")]), 137 | width=5, hide=True), 138 | ] 139 | 140 | class BazColumns(urwid.WidgetWrap): 141 | def __init__(self, value): 142 | self.text = DataTableText(value) 143 | super().__init__(urwid.Columns([ 144 | (1, urwid.Text("[")), 145 | ("weight", 1, self.text), 146 | (1, urwid.Text("]")), 147 | ])) 148 | 149 | def truncate(self, width, end_char=None): 150 | self.text.truncate(width-2, end_char=end_char) 151 | 152 | 153 | class ExampleDataTable(DataTable): 154 | 155 | columns = COLUMNS[:] 156 | 157 | index="index" 158 | 159 | with_sidecar = True 160 | 161 | def __init__(self, num_rows = 10, random=False, *args, **kwargs): 162 | self.num_rows = num_rows 163 | # indexes = random.sample(range(self.num_rows*2), num_rows) 164 | if random: 165 | self.randomize_query_data() 166 | else: 167 | self.fixed_query_data() 168 | 169 | self.last_rec = len(self.query_data) 170 | super(ExampleDataTable, self).__init__(*args, **kwargs) 171 | 172 | def fixed_query_data(self): 173 | self.query_data = [ 174 | self.fixed_row(i) for i in range(self.num_rows) 175 | # self.random_row(i) for i in range(self.num_rows) 176 | ] 177 | 178 | def randomize_query_data(self): 179 | indexes = list(range(self.num_rows)) 180 | self.query_data = [ 181 | self.random_row(indexes[i]) for i in range(self.num_rows) 182 | # self.random_row(i) for i in range(self.num_rows) 183 | ] 184 | random.shuffle(self.query_data) 185 | 186 | def fixed_row(self, uniqueid): 187 | # return AttrDict(uniqueid=uniqueid, 188 | f = Foo(uniqueid=uniqueid, 189 | foo=uniqueid, 190 | bar = (random.uniform(0, 1000) 191 | if random.randint(0, 5) 192 | else None), 193 | baz =(''.join(random.choice( 194 | string.ascii_uppercase 195 | + string.ascii_lowercase 196 | + string.digits + ' ' * 10 197 | ) for _ in range(random.randint(20, 80))) 198 | if random.randint(0, 5) 199 | else None), 200 | qux = urwid.Text([("red", "1"),("green", "2"), ("blue", "3")]), 201 | xyzzy = ( "%0.1f" %(random.uniform(0, 100)) 202 | if random.randint(0, 5) 203 | else None), 204 | baz_len = lambda r: len(r["baz"]) if r.get("baz") else 0, 205 | # xyzzy = random.randint(10, 100), 206 | a = dict(b=dict(c=random.randint(0, 100))), 207 | d = dict(e=dict(f=random.randint(0, 100))), 208 | color = ["red", "green", "blue"][random.randrange(3)], 209 | ) 210 | return f 211 | 212 | 213 | def random_row(self, uniqueid): 214 | return AttrDict(uniqueid=uniqueid, 215 | foo=random.choice(list(range(100)) + [None]*20), 216 | bar = (random.uniform(0, 1000) 217 | if random.randint(0, 5) 218 | else None), 219 | baz =(''.join(random.choice( 220 | string.ascii_uppercase 221 | + string.ascii_lowercase 222 | + string.digits + ' ' * 10 223 | ) for _ in range(random.randint(5, 80))) 224 | if random.randint(0, 5) 225 | else None), 226 | qux = urwid.Text([("red", "1"),("green", "2"), ("blue", "3")]), 227 | xyzzy = ( "%0.1f" %(random.uniform(0, 100)) 228 | if random.randint(0, 5) 229 | else None), 230 | baz_len = lambda r: len(r["baz"]) if r.get("baz") else 0, 231 | # xyzzy = random.randint(10, 100), 232 | a = dict(b=dict(c=random.randint(0, 100))), 233 | d = dict(e=dict(f=random.randint(0, 100))), 234 | color = ["red", "green", "blue"][random.randrange(3)], 235 | 236 | ) 237 | 238 | 239 | def query(self, sort=(None, None), offset=None, limit=None, load_all=False, **kwargs): 240 | 241 | logger.info("query: offset=%s, limit=%s, sort=%s" %(offset, limit, sort)) 242 | try: 243 | sort_field, sort_reverse = sort 244 | except: 245 | sort_field = sort 246 | sort_reverse = None 247 | 248 | if sort_field: 249 | kwargs = {} 250 | kwargs["key"] = lambda x: (x.get(sort_field) is None, 251 | x.get(sort_field), 252 | x.get(self.index)) 253 | if sort_reverse: 254 | kwargs["reverse"] = sort_reverse 255 | self.query_data.sort( 256 | **kwargs 257 | ) 258 | if offset is not None: 259 | if not load_all: 260 | start = offset 261 | end = offset + limit 262 | r = self.query_data[start:end] 263 | else: 264 | r = self.query_data[offset:] 265 | else: 266 | r = self.query_data 267 | 268 | for d in r: 269 | yield (d, dict(zzzz=1)) 270 | 271 | 272 | def query_result_count(self): 273 | return self.num_rows 274 | 275 | 276 | def keypress(self, size, key): 277 | if key == "r": 278 | self.refresh() 279 | elif key == "meta r": 280 | # self.randomize_query_data() 281 | # self.reset(reset_sort=True) 282 | self.refresh() 283 | elif key == "ctrl r": 284 | self.reset(reset_sort=True) 285 | elif key == "ctrl d": 286 | logger.info(type(self.selection.data)) 287 | self.log_dump(20) 288 | elif key == "meta d": 289 | self.log_dump(20, columns=["foo", "baz"]) 290 | elif key == "ctrl f": 291 | self.focus_position = 0 292 | elif key == "ctrl t": 293 | logger.info(self.selection.data) 294 | elif key == "ctrl k": 295 | self.selection["foo"] = 123 296 | logger.info(self.selection.data["foo"]) 297 | self.selection.update() 298 | # self.selection.details_disabled = not self.selection.details_disabled 299 | # logger.info(self.selection.details_disabled) 300 | elif key == "meta i": 301 | logger.info("foo %s, baz: %s" %(self.selection.get("foo"), 302 | self.selection.get("baz"))) 303 | elif self.ui_sort and key.isdigit() and int(key)-1 in range(len(self.columns)): 304 | col = int(key)-1 305 | self.sort_by_column(col, toggle=True) 306 | elif key == "ctrl l": 307 | self.load("test.json") 308 | elif key == "ctrl s": 309 | self.save("test.json") 310 | elif key == "0": 311 | # self.sort_by_column(self.index, toggle=True) 312 | self.sort_index() 313 | elif key == "a": 314 | self.add_row(self.random_row(self.last_rec)) 315 | self.last_rec += 1 316 | elif key == "A": 317 | self.add_row(self.random_row(self.last_rec), sort=False) 318 | self.last_rec += 1 319 | elif key == "d": 320 | if len(self): 321 | self.delete_rows(self.df.index[self.focus_position]) 322 | elif key == "meta a": 323 | name = "".join( random.choice( 324 | string.ascii_uppercase 325 | + string.lowercase 326 | + string.digits 327 | ) for _ in range(5) ) 328 | data = [ "".join( random.choice( 329 | string.ascii_uppercase 330 | + string.lowercase 331 | + string.digits 332 | ) for _ in range(5)) for _ in range(len(self)) ] 333 | col = DataTableColumn(name, label=name, width=6, padding=0) 334 | self.add_columns(col, data=data) 335 | elif key == "t": 336 | self.toggle_columns("qux") 337 | elif key == ";": 338 | self.set_columns(COLUMNS) 339 | elif key == "T": 340 | self.toggle_columns(["foo", "baz"]) 341 | elif key == "D": 342 | self.remove_columns(len(self.columns)-1) 343 | elif key == "f": 344 | self.apply_filters([lambda x: x["foo"] > 20, lambda x: x["bar"] < 800]) 345 | elif key == "F": 346 | self.clear_filters() 347 | elif key == ".": 348 | self.selection.toggle_details() 349 | elif key == "s": 350 | self.selection.set_attr("red") 351 | elif key == "S": 352 | self.selection.clear_attr("red") 353 | elif key == "k": 354 | self.selection[2].set_attr("red") 355 | elif key == "K": 356 | self.selection[2].clear_attr("red") 357 | elif key == "u": 358 | logger.info(self.footer.values) 359 | elif key == "c": 360 | self.toggle_cell_selection() 361 | elif key == "z": 362 | # self.columns[0].width = 12 363 | self.resize_column("foo", ("given", 12)) 364 | # self.reset() 365 | elif key == "shift left": 366 | self.cycle_sort_column(-1) 367 | elif key == "shift right": 368 | self.cycle_sort_column(1) 369 | elif self.ui_sort and key == "shift up": 370 | self.sort_by_column(reverse=True) 371 | elif self.ui_sort and key == "shift down": 372 | self.sort_by_column(reverse=False) 373 | elif key == "shift end": 374 | self.load_all() 375 | # self.listbox.focus_position = len(self) -1 376 | elif key == "ctrl up": 377 | if self.focus_position > 0: 378 | self.swap_rows(self.focus_position, self.focus_position-1, "foo") 379 | self.focus_position -= 1 380 | elif key == "ctrl down": 381 | if self.focus_position < len(self)-1: 382 | self.swap_rows(self.focus_position, self.focus_position+1, "foo") 383 | self.focus_position += 1 384 | else: 385 | return super(ExampleDataTable, self).keypress(size, key) 386 | 387 | def decorate(self, row, column, value): 388 | # if column.name == "baz": 389 | # return BazColumns(value) 390 | return super().decorate(row, column, value) 391 | 392 | class ExampleDataTableBox(urwid.WidgetWrap): 393 | 394 | def __init__(self, *args, **kwargs): 395 | 396 | self.table = ExampleDataTable(*args, **kwargs) 397 | # urwid.connect_signal( 398 | # self.table, "select", 399 | # lambda source, selection: logger.info("selection: %s" %(selection)) 400 | # ) 401 | label = "sz:%d pgsz:%s sort:%s%s hdr:%s ftr:%s ui_sort:%s cell_sel:%s" %( 402 | self.table.query_result_count(), 403 | self.table.limit if self.table.limit else "-", 404 | "-" if self.table.sort_by[1] 405 | else "+" if self.table.sort_by[0] 406 | else "n", 407 | self.table.sort_by[0] or " ", 408 | 409 | "y" if self.table.with_header else "n", 410 | "y" if self.table.with_footer else "n", 411 | "y" if self.table.ui_sort else "n", 412 | "y" if self.table.cell_selection else "n", 413 | ) 414 | self.pile = urwid.Pile([ 415 | ("pack", urwid.Text(label)), 416 | ("pack", urwid.Divider(u"\N{HORIZONTAL BAR}")), 417 | ("weight", 1, self.table) 418 | ]) 419 | self.box = urwid.BoxAdapter(urwid.LineBox(self.pile), 25) 420 | super(ExampleDataTableBox, self).__init__(self.box) 421 | 422 | def detail_fn(data): 423 | 424 | # return urwid.Padding(urwid.Columns([ 425 | # ("weight", 1, data.get("qux")), 426 | # # ("weight", 1, urwid.Text(str(data.get("baz_len")))), 427 | # ("weight", 2, urwid.Text(str(data.get("xyzzy")))), 428 | # ])) 429 | 430 | # return urwid.Pile([ 431 | # (1, urwid.Filler(urwid.Padding(urwid.Text("adassdda")))), 432 | # (1, urwid.Filler(urwid.Padding(urwid.Text("adassdda")))), 433 | # (1, urwid.Filler(urwid.Padding(urwid.Text("adassdda")))), 434 | # (1, urwid.Filler(urwid.Padding(urwid.Text("adassdda")))), 435 | # ]) 436 | 437 | return urwid.BoxAdapter(ExampleDataTable( 438 | 100, 439 | limit=10, 440 | index="uniqueid", 441 | divider = DataTableDivider(".q", width=3), 442 | # detail_fn=detail_fn, 443 | cell_selection=True, 444 | sort_refocus = True, 445 | with_scrollbar=True, 446 | row_attr_fn = row_attr_fn, 447 | ), 20) 448 | 449 | def row_attr_fn(position, data, row): 450 | if data.baz and "R" in data.baz: 451 | return "red" 452 | elif data.baz and "G" in data.baz: 453 | return "green" 454 | elif data.baz and "B" in data.baz: 455 | return "blue" 456 | return None 457 | 458 | boxes = [ 459 | 460 | ExampleDataTableBox( 461 | 100, 462 | limit=10, 463 | index="uniqueid", 464 | divider = DataTableDivider("."), 465 | # divider = False, 466 | detail_fn=detail_fn, 467 | detail_auto_open=True, 468 | detail_replace=True, 469 | cell_selection=True, 470 | sort_refocus = True, 471 | with_scrollbar=True, 472 | row_attr_fn = row_attr_fn, 473 | detail_selectable = True, 474 | sort_icons=False, 475 | # row_height=2, 476 | # no_load_on_init = True 477 | 478 | ), 479 | 480 | # ExampleDataTableBox( 481 | # 500, 482 | # index="uniqueid", 483 | # sort_by = "foo", 484 | # query_sort=False, 485 | # ui_sort=False, 486 | # ui_resize=False, 487 | # with_footer=True, 488 | # with_scrollbar=True, 489 | # row_height=2, 490 | # ), 491 | 492 | # ExampleDataTableBox( 493 | # 500, 494 | # columns = [DataTableColumn("row", width=7, value="{row}/{rows_total}")] + ExampleDataTable.columns, 495 | # limit=25, 496 | # index="uniqueid", 497 | # sort_by = ("bar", True), 498 | # sort_icons = False, 499 | # query_sort=True, 500 | # with_footer=True, 501 | # with_scrollbar=True, 502 | # cell_selection=True, 503 | # padding=3, 504 | # row_style = "grid" 505 | # ), 506 | # ExampleDataTableBox( 507 | # 5000, 508 | # limit=500, 509 | # index="uniqueid", 510 | # sort_by = ("foo", True), 511 | # query_sort=True, 512 | # with_scrollbar=True, 513 | # with_header=False, 514 | # with_footer=False, 515 | # ), 516 | 517 | ] 518 | 519 | 520 | grid_flow = urwid.GridFlow( 521 | boxes, 60, 1, 1, "left" 522 | ) 523 | 524 | def global_input(key): 525 | if key in ('q', 'Q'): 526 | raise urwid.ExitMainLoop() 527 | else: 528 | return False 529 | 530 | old_signal_keys = screen.tty_signal_keys() 531 | l = list(old_signal_keys) 532 | l[0] = 'undefined' 533 | l[3] = 'undefined' 534 | l[4] = 'undefined' 535 | screen.tty_signal_keys(*l) 536 | 537 | grid_box = urwid.LineBox(grid_flow) 538 | 539 | table = ExampleDataTable( 540 | 100, 541 | columns = [ 542 | DataTableColumn("bar", label="Bar", width=10, align="right", 543 | format_fn = lambda v: round(v, 2) if v is not None else v, 544 | decoration_fn = lambda v: ("cyan", v), 545 | sort_reverse=True, sort_icon=False, padding=0),# margin=5), 546 | DataTableColumn("baz", label="Baz!", 547 | #width="pack", 548 | width=("weight", 5), 549 | pack=True, 550 | min_width=5, 551 | truncate=False), 552 | DataTableColumn( 553 | "qux", 554 | label=urwid.Text([("red", "q"), ("green", "u"), ("blue", "x")]), 555 | width=5, hide=True), 556 | DataTableColumn("foo", label="Foo", align="right", 557 | width=("weight", 1), 558 | sort_key = lambda v: (v is None, v), 559 | pack=True, 560 | attr="color", padding=0, 561 | footer_fn = lambda column, values: sum(v for v in values if v is not None) 562 | ), 563 | ], 564 | limit=10, 565 | index="uniqueid", 566 | divider = DataTableDivider(".", width=3), 567 | detail_fn=detail_fn, 568 | detail_hanging_indent=1, 569 | cell_selection=True, 570 | sort_refocus = True, 571 | with_scrollbar=True, 572 | row_attr_fn = row_attr_fn, 573 | ) 574 | 575 | main = urwid.MainLoop( 576 | urwid.Pile([ 577 | ("pack", grid_box), 578 | ("weight", 1, table), 579 | ("weight", 1, DataTable(columns=[DataTableColumn("a")], data={})), 580 | ]), 581 | palette = palette, 582 | screen = screen, 583 | unhandled_input=global_input 584 | 585 | ) 586 | 587 | try: 588 | main.run() 589 | finally: 590 | screen.tty_signal_keys(*old_signal_keys) 591 | 592 | if __name__ == "__main__": 593 | main() 594 | -------------------------------------------------------------------------------- /examples/datatable_simple.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import urwid 4 | from panwid.datatable import * 5 | from panwid.scroll import ScrollBar 6 | from panwid.listbox import ScrollingListBox 7 | from urwid_utils.palette import * 8 | 9 | def unhandled_input(key): 10 | if key in ("q", "Q"): 11 | raise urwid.ExitMainLoop() 12 | 13 | class ExampleScrollBar(ScrollBar): 14 | 15 | _thumb_char = ("light blue", "\u2588") 16 | _trough_char = ("dark blue", "\u2591") 17 | _thumb_indicator_top = ("white inverse", "\u234d") 18 | _thumb_indicator_bottom = ("white inverse", "\u2354") 19 | 20 | def main(): 21 | 22 | data_table = DataTable( 23 | columns = [ 24 | DataTableColumn("num"), 25 | DataTableColumn("char") 26 | ], 27 | data=[ 28 | dict(num=i, char=chr((i%58)+65)) 29 | for i in range(500) 30 | ], 31 | with_scrollbar=ExampleScrollBar 32 | ) 33 | 34 | entries = DataTable.get_palette_entries() 35 | entries = ScrollingListBox.get_palette_entries() 36 | entries["white inverse"] = PaletteEntry( 37 | mono = "black", 38 | foreground = "black", 39 | background = "white" 40 | ) 41 | entries["light blue"] = PaletteEntry( 42 | mono = "white", 43 | foreground = "light blue", 44 | background = "black" 45 | ) 46 | entries["dark blue"] = PaletteEntry( 47 | mono = "white", 48 | foreground = "dark blue", 49 | background = "black" 50 | ) 51 | palette = Palette("default", **entries) 52 | 53 | loop = urwid.MainLoop( 54 | urwid.Frame(data_table), 55 | palette = palette, 56 | unhandled_input=unhandled_input 57 | ) 58 | loop.run() 59 | 60 | 61 | if __name__ == "__main__": 62 | main() 63 | -------------------------------------------------------------------------------- /examples/datatable_simple_query.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import urwid 4 | from panwid.datatable import * 5 | 6 | 7 | def unhandled_input(key): 8 | if key in ("q", "Q"): 9 | raise urwid.ExitMainLoop() 10 | 11 | class ExampleDataTable(DataTable): 12 | 13 | columns = [ 14 | DataTableColumn("foo"), 15 | DataTableColumn("bar") 16 | ] 17 | 18 | def query(self, *args, **kwargs): 19 | for i in range(20): 20 | yield(dict(foo=i+1, bar=chr(97+i))) 21 | 22 | def main(): 23 | 24 | data_table = ExampleDataTable() 25 | 26 | loop = urwid.MainLoop( 27 | urwid.Frame(data_table), 28 | unhandled_input=unhandled_input 29 | ) 30 | loop.run() 31 | 32 | 33 | if __name__ == "__main__": 34 | main() 35 | -------------------------------------------------------------------------------- /examples/dialog.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | from panwid.dialog import * 3 | 4 | class QuitDialog(ChoiceDialog): 5 | 6 | prompt = "Test Popup" 7 | 8 | @property 9 | def choices(self): 10 | return { 11 | '1': lambda: 1, 12 | '2': lambda: 2, 13 | '3': lambda: 3, 14 | } 15 | 16 | class MainView(BaseView): 17 | 18 | def __init__(self): 19 | 20 | self.title = urwid.Text("Press 'o' to open popup.") 21 | self.text = urwid.Text("") 22 | self.pile = urwid.Pile([ 23 | ('pack', self.title), 24 | ('weight', 1, urwid.Filler(self.text)) 25 | ]) 26 | super(MainView, self).__init__(self.pile) 27 | 28 | def selectable(self): 29 | return True 30 | 31 | def open_popup_dialog(self): 32 | dialog = QuitDialog(self) 33 | urwid.connect_signal(dialog, "select", self.on_select) 34 | self.open_popup(dialog, width=20, height=10) 35 | 36 | def on_select(self, source, n): 37 | self.text.set_text("You chose %s" %(n)) 38 | 39 | def keypress(self, size, key): 40 | if key == "o": 41 | self.open_popup_dialog() 42 | 43 | return super(MainView, self).keypress(size, key) 44 | 45 | def main(): 46 | 47 | main_view = MainView() 48 | 49 | def global_input(key): 50 | if key in ["q", "Q"]: 51 | raise urwid.ExitMainLoop() 52 | return 53 | 54 | screen = urwid.raw_display.Screen() 55 | screen.set_terminal_properties(16) 56 | 57 | loop = urwid.MainLoop( 58 | main_view, 59 | screen=screen, 60 | pop_ups=True, 61 | unhandled_input=global_input 62 | ) 63 | loop.run() 64 | 65 | if __name__ == "__main__": 66 | main() 67 | -------------------------------------------------------------------------------- /examples/dropdown.py: -------------------------------------------------------------------------------- 1 | import logging 2 | logger = logging.getLogger() 3 | import os 4 | 5 | import urwid 6 | import urwid.raw_display 7 | from urwid_utils.palette import * 8 | from orderedattrdict import AttrDict 9 | 10 | import panwid.keymap 11 | 12 | panwid.keymap.KEYMAP_GLOBAL = { 13 | "movement": { 14 | "up": "up", 15 | "down": "down", 16 | }, 17 | "dropdown": { 18 | "k": "up", 19 | "j": "down", 20 | "page up": "page up", 21 | "page down": "page down", 22 | "ctrl up": ("cycle", [1]), 23 | "ctrl down": ("cycle", [-1]), 24 | "home": "home", 25 | "end": "end", 26 | "/": "complete prefix", 27 | "?": "complete substring", 28 | "ctrl p": "complete_prev", 29 | "ctrl n": "complete_next", 30 | }, 31 | "auto_complete_edit": { 32 | "enter": "confirm", 33 | "esc": "cancel", 34 | "/": "complete prefix", 35 | "?": "complete substring", 36 | "ctrl p": "complete_prev", 37 | "ctrl n": "complete_next", 38 | } 39 | } 40 | 41 | 42 | from panwid.dropdown import * 43 | from panwid.listbox import * 44 | from panwid.keymap import * 45 | 46 | class TestDropdown(KeymapMovementMixin, Dropdown): 47 | pass 48 | 49 | def main(): 50 | 51 | data = AttrDict([('Adipisci eius dolore consectetur.', 34), 52 | ('Aliquam consectetur velit dolore', 19), 53 | ('Amet ipsum quaerat numquam.', 25), 54 | ('Amet quisquam labore dolore.', 30), 55 | ('Amet velit consectetur.', 20), 56 | ('Consectetur consectetur aliquam voluptatem', 23), 57 | ('Consectetur ipsum aliquam.', 28), 58 | ('Consectetur sit neque est', 15), 59 | ('Dolore voluptatem etincidunt sit', 40), 60 | ('Dolorem porro tempora tempora.', 37), 61 | ('Eius numquam dolor ipsum', 26), 62 | ('Eius tempora etincidunt est', 12), 63 | ('Est adipisci numquam adipisci', 7), 64 | ('Est aliquam dolor.', 38), 65 | ('Etincidunt amet quisquam.', 33), 66 | ('Etincidunt consectetur velit.', 29), 67 | ('Etincidunt dolore eius.', 45), 68 | ('Etincidunt non amet.', 14), 69 | ('Etincidunt velit adipisci labore', 6), 70 | ('Ipsum magnam velit quiquia', 21), 71 | ('Ipsum modi eius.', 3), 72 | ('Labore voluptatem quiquia aliquam', 18), 73 | ('Magnam etincidunt porro magnam', 39), 74 | ('Magnam numquam amet.', 44), 75 | ('Magnam quisquam sit amet.', 27), 76 | ('Magnam voluptatem ipsum neque', 32), 77 | ('Modi est ipsum adipisci', 2), 78 | ('Neque eius voluptatem voluptatem', 42), 79 | ('Neque quisquam ipsum.', 10), 80 | ('Neque quisquam neque.', 48), 81 | ('Non dolore voluptatem.', 41), 82 | ('Non numquam consectetur voluptatem.', 35), 83 | ('Numquam eius dolorem.', 43), 84 | ('Numquam sed neque modi', 9), 85 | ('Porro voluptatem quaerat voluptatem', 11), 86 | ('Quaerat eius quiquia.', 17), 87 | ('Quiquia aliquam etincidunt consectetur.', 0), 88 | ('Quiquia ipsum sit.', 49), 89 | ('Quiquia non dolore quiquia', 8), 90 | ('Quisquam aliquam numquam dolore.', 1), 91 | ('Quisquam dolorem voluptatem adipisci.', 22), 92 | ('Sed magnam dolorem quisquam', 4), 93 | ('Sed tempora modi est.', 16), 94 | ('Sit aliquam dolorem.', 46), 95 | ('Sit modi dolor.', 31), 96 | ('Sit quiquia quiquia non.', 5), 97 | ('Sit quisquam numquam quaerat.', 36), 98 | ('Tempora etincidunt quiquia dolor', 13), 99 | ('Tempora velit etincidunt.', 24), 100 | ('Velit dolor velit.', 47)]) 101 | 102 | NORMAL_FG = 'light gray' 103 | NORMAL_BG = 'black' 104 | 105 | if os.environ.get("DEBUG"): 106 | logger.setLevel(logging.DEBUG) 107 | formatter = logging.Formatter( 108 | "%(asctime)s [%(module)16s:%(lineno)-4d] [%(levelname)8s] %(message)s", 109 | datefmt='%Y-%m-%d %H:%M:%S' 110 | ) 111 | fh = logging.FileHandler("dropdown.log") 112 | fh.setFormatter(formatter) 113 | logger.addHandler(fh) 114 | else: 115 | logger.addHandler(logging.NullHandler()) 116 | 117 | entries = Dropdown.get_palette_entries() 118 | entries.update(ScrollingListBox.get_palette_entries()) 119 | palette = Palette("default", **entries) 120 | screen = urwid.raw_display.Screen() 121 | screen.set_terminal_properties(256) 122 | 123 | boxes = [ 124 | TestDropdown( 125 | data, 126 | label="Foo", 127 | border = True, 128 | scrollbar = True, 129 | right_chars_top = u" \N{BLACK DOWN-POINTING TRIANGLE}", 130 | auto_complete = True, 131 | ), 132 | 133 | TestDropdown( 134 | data, 135 | border = False, 136 | margin = 2, 137 | left_chars = u"\N{LIGHT LEFT TORTOISE SHELL BRACKET ORNAMENT}", 138 | right_chars = u"\N{LIGHT RIGHT TORTOISE SHELL BRACKET ORNAMENT}", 139 | auto_complete = True 140 | ), 141 | TestDropdown( 142 | data, 143 | default = list(data.values())[10], 144 | label="Foo", 145 | border = True, 146 | scrollbar = False, 147 | auto_complete = False, 148 | ), 149 | TestDropdown( 150 | [], 151 | ), 152 | ] 153 | 154 | grid = urwid.GridFlow( 155 | [ urwid.Padding(b) for b in boxes], 156 | 60, 1, 1, "left" 157 | ) 158 | 159 | main = urwid.Frame(urwid.Filler(grid)) 160 | 161 | def global_input(key): 162 | if key in ('q', 'Q'): 163 | raise urwid.ExitMainLoop() 164 | else: 165 | return False 166 | 167 | 168 | loop = urwid.MainLoop(main, 169 | palette, 170 | screen=screen, 171 | unhandled_input=global_input, 172 | pop_ups=True 173 | ) 174 | loop.run() 175 | 176 | if __name__ == "__main__": 177 | main() 178 | 179 | __all__ = ["Dropdown"] 180 | -------------------------------------------------------------------------------- /examples/progressbar.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import urwid 4 | from urwid_utils.palette import * 5 | import random 6 | from itertools import chain, repeat, islice 7 | 8 | from panwid.sparkwidgets import * 9 | from panwid.progressbar import * 10 | 11 | screen = urwid.raw_display.Screen() 12 | screen.set_terminal_properties(1<<24) 13 | 14 | LABEL_COLOR_DARK = "black" 15 | LABEL_COLOR_LIGHT = "white" 16 | 17 | entries = {} 18 | 19 | 20 | all_colors = [ urwid.display_common._color_desc_256(x) 21 | for x in range(32,224) ] 22 | random_colors = [ random.choice(all_colors) for i in range(16) ] 23 | 24 | label_colors = [ LABEL_COLOR_DARK, LABEL_COLOR_LIGHT ] 25 | 26 | entries.update( 27 | get_palette_entries( 28 | label_colors = label_colors 29 | ) 30 | ) 31 | 32 | entries.update( 33 | get_palette_entries( 34 | chart_colors = random_colors, 35 | label_colors = label_colors 36 | ) 37 | ) 38 | 39 | 40 | for fcolor in random_colors + label_colors: 41 | 42 | entries.update({ 43 | fcolor: PaletteEntry( 44 | mono = "white", 45 | foreground = (fcolor 46 | if fcolor in urwid.display_common._BASIC_COLORS 47 | else "white"), 48 | background = "black", 49 | foreground_high = fcolor, 50 | background_high = "black" 51 | ), 52 | }) 53 | 54 | for bcolor in random_colors: 55 | 56 | entries.update({ 57 | "%s:%s" %(fcolor, bcolor): PaletteEntry( 58 | mono = "white", 59 | foreground = (fcolor 60 | if fcolor in urwid.display_common._BASIC_COLORS 61 | else "white"), 62 | background = (bcolor 63 | if bcolor in urwid.display_common._BASIC_COLORS 64 | else "black"), 65 | foreground_high = fcolor, 66 | background_high = bcolor 67 | ), 68 | }) 69 | 70 | palette = Palette("default", **entries) 71 | 72 | progress = None 73 | 74 | progress_text = urwid.Filler(urwid.Text("")) 75 | progress_ph = urwid.WidgetPlaceholder(urwid.Text("")) 76 | 77 | def intersperse(delimiter, seq): 78 | return islice(chain.from_iterable(zip(repeat(delimiter), seq)), 1, None) 79 | 80 | def get_random_progress(): 81 | 82 | return ProgressBar( 83 | width=random.randint(10, 100), 84 | maximum=random.randint(200, 300), 85 | value=random.randint(0, 100), 86 | # maximum=90, 87 | # value=0, 88 | progress_color="light red", 89 | remaining_color="light green" 90 | ) 91 | 92 | def randomize_progress(): 93 | global progress 94 | progress = get_random_progress() 95 | filler = urwid.Filler(progress) 96 | # values = list(intersperse(",", [(i.value, "%s" %(i.value)) for i in progress.items])) 97 | progress_text.original_widget.set_text(f"{progress.value}, {progress.maximum}") 98 | progress_ph.original_widget = filler 99 | 100 | def cycle_progress(step): 101 | global progress 102 | # values = list(intersperse(",", [(i.value, "%s" %(i.value)) for i in progress.items])) 103 | value = max(min(progress.value + step, progress.maximum), 0) 104 | progress.set_value(value) 105 | progress_text.original_widget.set_text(f"{progress.value}, {progress.maximum}") 106 | 107 | 108 | def main(): 109 | 110 | pile = urwid.Pile([ 111 | (2, progress_text), 112 | (2, progress_ph), 113 | ]) 114 | 115 | randomize_progress() 116 | 117 | def keypress(key): 118 | 119 | if key == "q": 120 | raise urwid.ExitMainLoop() 121 | elif key == " ": 122 | randomize_progress() 123 | elif key == "left": 124 | cycle_progress(-1) 125 | elif key == "right": 126 | cycle_progress(1) 127 | elif key == "down": 128 | cycle_progress(-10) 129 | elif key == "up": 130 | cycle_progress(10) 131 | else: 132 | return key 133 | 134 | 135 | loop = urwid.MainLoop( 136 | pile, 137 | palette=palette, 138 | screen=screen, 139 | unhandled_input=keypress 140 | ) 141 | 142 | loop.run() 143 | 144 | if __name__ == "__main__": 145 | main() 146 | -------------------------------------------------------------------------------- /examples/sparkwidgets.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import urwid 4 | from urwid_utils.palette import * 5 | import random 6 | from itertools import chain, repeat, islice 7 | from panwid.sparkwidgets import * 8 | 9 | 10 | screen = urwid.raw_display.Screen() 11 | screen.set_terminal_properties(1<<24) 12 | 13 | LABEL_COLOR_DARK = "black" 14 | LABEL_COLOR_LIGHT = "white" 15 | 16 | entries = {} 17 | 18 | all_colors = [ urwid.display_common._color_desc_256(x) 19 | for x in range(32,224) ] 20 | random_colors = [ random.choice(all_colors) for i in range(1,15) ] 21 | 22 | label_colors = [ LABEL_COLOR_DARK, LABEL_COLOR_LIGHT ] 23 | 24 | entries.update( 25 | get_palette_entries( 26 | label_colors = label_colors 27 | ) 28 | ) 29 | 30 | entries.update( 31 | get_palette_entries( 32 | chart_colors = random_colors, 33 | label_colors = label_colors 34 | ) 35 | ) 36 | 37 | 38 | for fcolor in random_colors + label_colors: 39 | 40 | entries.update({ 41 | fcolor: PaletteEntry( 42 | mono = "white", 43 | foreground = (fcolor 44 | if fcolor in urwid.display_common._BASIC_COLORS 45 | else "white"), 46 | background = "black", 47 | foreground_high = fcolor, 48 | background_high = "black" 49 | ), 50 | }) 51 | 52 | for bcolor in random_colors: 53 | 54 | entries.update({ 55 | "%s:%s" %(fcolor, bcolor): PaletteEntry( 56 | mono = "white", 57 | foreground = (fcolor 58 | if fcolor in urwid.display_common._BASIC_COLORS 59 | else "white"), 60 | background = (bcolor 61 | if bcolor in urwid.display_common._BASIC_COLORS 62 | else "black"), 63 | foreground_high = fcolor, 64 | background_high = bcolor 65 | ), 66 | }) 67 | 68 | 69 | def intersperse(delimiter, seq): 70 | return islice(chain.from_iterable(zip(repeat(delimiter), seq)), 1, None) 71 | 72 | 73 | # raise Exception(entries) 74 | palette = Palette("default", **entries) 75 | 76 | spark1 = urwid.Filler(SparkColumnWidget(list(range(0, random.randint(1, 20))))) 77 | spark2 = urwid.Filler(SparkColumnWidget(list(range(0, 100)), color_scheme="rotate_16", scale_min=20, scale_max=90)) 78 | spark3 = urwid.Filler(SparkColumnWidget([5*random.random() for i in range(0, 100)], color_scheme="rotate_true")) 79 | spark4 = urwid.Filler(SparkColumnWidget(list(range(-5, 100)), color_scheme="signed", underline="negative")) 80 | custom_scheme ={ "mode": "rotate", "colors": ["dark cyan", "brown", "dark magenta"]} 81 | spark5 = urwid.Filler(SparkColumnWidget(list(range(1, 20)), color_scheme=custom_scheme)) 82 | 83 | spark_random_text = urwid.Filler(urwid.Text("")) 84 | spark_random_ph = urwid.WidgetPlaceholder(urwid.Text("")) 85 | 86 | 87 | 88 | bark1 = urwid.Filler(SparkBarWidget([30, 30, 30], random.randint(10, 40), color_scheme="rotate_16")) 89 | bark2 = urwid.Filler(SparkBarWidget([40, 30, 20, 10], random.randint(10, 60), color_scheme="rotate_true")) 90 | bark3 = urwid.Filler(SparkBarWidget([0, 0, 0], random.randint(1, 10), color_scheme="rotate_true")) 91 | bark4 = urwid.Filler(SparkBarWidget([19, 42, 17], random.randint(1, 5), color_scheme="rotate_true")) 92 | bark5 = urwid.Filler(SparkBarWidget([ 93 | SparkBarItem(19, bcolor="light red", label="\u2588"*2, fcolor="yellow"), 94 | SparkBarItem(42, bcolor="light green", label="bar", align="^"), 95 | SparkBarItem(17, bcolor="light blue", label="baz", align=">") 96 | ], random.randint(1, 60), fit_label=True)) 97 | bark6 = urwid.Filler(SparkBarWidget( 98 | [ 99 | SparkBarItem(0, bcolor="light green", label=0, fcolor="dark red"), 100 | SparkBarItem(6, bcolor="light blue",label=6, fcolor="dark red", align=">") 101 | ], 102 | random.randint(10, 30), color_scheme="rotate_256", min_width=5)) 103 | bark6=urwid.Filler(SparkBarWidget( 104 | [SparkBarItem(value=1, label=' 1', fcolor='black', bcolor='dark green', align='<'), 105 | SparkBarItem(value=13, label='☼ 14', fcolor='black', bcolor='dark blue', align='>'), 106 | SparkBarItem(value=0, label='✓ 0', fcolor='black', bcolor='light blue', align='>'), 107 | SparkBarItem(value=50, label='↓ 50', fcolor='black', bcolor='dark red', align='>'), 108 | SparkBarItem(value=2802, label='🌐2852', fcolor='black', bcolor='dark gray', align='>') 109 | ], width=130, fit_label=True)) 110 | # raise Exception 111 | bark_random_text = urwid.Filler(urwid.Text("")) 112 | bark_random_ph = urwid.WidgetPlaceholder(urwid.Text("")) 113 | 114 | 115 | progress_random_text = urwid.Filler(urwid.Text("")) 116 | progress_random_ph = urwid.WidgetPlaceholder(urwid.Text("")) 117 | 118 | 119 | def get_label_color(color, 120 | dark=DEFAULT_LABEL_COLOR_DARK, 121 | light=DEFAULT_LABEL_COLOR_LIGHT): 122 | # http://jsfiddle.net/cu4z27m7/66/ 123 | (r, g, b) = urwid.AttrSpec(color, color).get_rgb_values()[:3] 124 | colors = [r / 255, g / 255, b / 255] 125 | c = [ (c / 12.92) 126 | if c < 0.03928 127 | else ((c + 0.055) / 1.055)**2.4 128 | for c in colors ] 129 | 130 | L = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2] 131 | return dark if L > 0.179 else light 132 | 133 | 134 | def get_random_spark(): 135 | return SparkColumnWidget([ 136 | (random_colors[i%len(random_colors)], 137 | random.randint(1, 100), 138 | ) 139 | for i in range(random.randint(2, 32)) 140 | ], underline="min", overline="max") 141 | 142 | def get_random_bark(): 143 | num = random.randint(1, 10) 144 | bcolors = [random_colors[i%len(random_colors)] for i in range(num)] 145 | lcolors = [ 146 | get_label_color(bcolor) 147 | for bcolor in bcolors 148 | ] 149 | # raise Exception(lcolors) 150 | # r, g, b = a.get_rgb_values()[:3] 151 | # lcolor = get_label_color(r, g, b) 152 | randos = [random.randint(50,150) for i in range(0, num)] 153 | return SparkBarWidget([ 154 | SparkBarItem( 155 | randos[i], 156 | bcolor=bcolors[i], 157 | label=("%s {value} ({pct}%%)" %(chr(65+i if i < 26 else 71 + i))), 158 | fcolor=lcolors[i] 159 | ) 160 | for i in range(0, num) 161 | ], fit_label=True, width=random.randint(10, 80), label_color="black", normalize=(1, 100), 162 | min_width=random.randint(0, 5)) 163 | 164 | def randomize_spark(): 165 | spark = get_random_spark() 166 | filler = urwid.Filler(spark) 167 | values = list(intersperse(",", [(i[0], "%s" %(i[1])) for i in spark.items])) 168 | spark_random_text.original_widget.set_text(values) 169 | spark_random_ph.original_widget = filler 170 | 171 | def randomize_bark(): 172 | bark = get_random_bark() 173 | filler = urwid.Filler(bark) 174 | values = list(intersperse(",", [(i.value, "%s" %(i.value)) for i in bark.items])) 175 | bark_random_text.original_widget.set_text(values) 176 | bark_random_ph.original_widget = filler 177 | 178 | def main(): 179 | 180 | pile = urwid.Pile([ 181 | (2, spark1), 182 | (2, spark2), 183 | (2, spark3), 184 | (2, spark4), 185 | (2, spark5), 186 | (2, spark_random_text), 187 | (2, spark_random_ph), 188 | (2, bark1), 189 | (2, bark2), 190 | (2, bark3), 191 | (2, bark4), 192 | (2, bark5), 193 | (2, bark6), 194 | (2, bark_random_text), 195 | (2, bark_random_ph) 196 | ]) 197 | 198 | randomize_bark() 199 | randomize_spark() 200 | 201 | def keypress(key): 202 | 203 | if key == "b": 204 | randomize_bark() 205 | elif key == "s": 206 | randomize_spark() 207 | elif key == " ": 208 | randomize_bark() 209 | randomize_spark() 210 | elif key == "q": 211 | raise urwid.ExitMainLoop() 212 | else: 213 | return key 214 | 215 | 216 | loop = urwid.MainLoop( 217 | pile, 218 | palette = palette, 219 | screen = screen, 220 | unhandled_input = keypress 221 | ) 222 | 223 | loop.run() 224 | 225 | if __name__ == "__main__": 226 | main() 227 | -------------------------------------------------------------------------------- /panwid/__init__.py: -------------------------------------------------------------------------------- 1 | from . import autocomplete 2 | from .autocomplete import * 3 | from . import datatable 4 | from .datatable import * 5 | from . import dialog 6 | from .dialog import * 7 | from . import dropdown 8 | from .dropdown import * 9 | from . import highlightable 10 | from .highlightable import * 11 | from . import keymap 12 | from .keymap import * 13 | from . import listbox 14 | from .listbox import * 15 | from . import scroll 16 | from .scroll import * 17 | from . import sparkwidgets 18 | from .sparkwidgets import * 19 | from . import tabview 20 | from .tabview import * 21 | 22 | __version__ = "0.3.5" 23 | 24 | __all__ = ( 25 | autocomplete.__all__ 26 | + datatable.__all__ 27 | + dialog.__all__ 28 | + dropdown.__all__ 29 | + highlightable.__all__ 30 | + keymap.__all__ 31 | + listbox.__all__ 32 | + scroll.__all__ 33 | + sparkwidgets.__all__ 34 | + tabview.__all__ 35 | ) 36 | -------------------------------------------------------------------------------- /panwid/autocomplete.py: -------------------------------------------------------------------------------- 1 | import logging 2 | logger = logging.getLogger(__name__) 3 | import itertools 4 | 5 | import urwid 6 | 7 | from .highlightable import HighlightableTextMixin 8 | from .keymap import * 9 | from urwid_readline import ReadlineEdit 10 | 11 | @keymapped() 12 | class AutoCompleteEdit(ReadlineEdit): 13 | 14 | signals = ["select", "close", "complete_next", "complete_prev"] 15 | 16 | KEYMAP = { 17 | "enter": "confirm", 18 | "esc": "cancel" 19 | } 20 | 21 | def clear(self): 22 | self.set_edit_text("") 23 | 24 | def confirm(self): 25 | self._emit("select") 26 | self._emit("close") 27 | 28 | def cancel(self): 29 | self._emit("close") 30 | 31 | def complete_next(self): 32 | self._emit("complete_next") 33 | 34 | def complete_prev(self): 35 | self._emit("complete_prev") 36 | 37 | def keypress(self, size, key): 38 | return super().keypress(size, key) 39 | 40 | @keymapped() 41 | class AutoCompleteBar(urwid.WidgetWrap): 42 | 43 | signals = ["change", "complete_prev", "complete_next", "select", "close"] 44 | 45 | prompt_attr = "dropdown_prompt" 46 | 47 | def __init__(self, prompt_attr=None, complete_fn=None): 48 | 49 | self.prompt_attr = prompt_attr or self.prompt_attr 50 | self.prompt = urwid.Text((self.prompt_attr, "> ")) 51 | self.text = AutoCompleteEdit("") 52 | if complete_fn: 53 | self.text.enable_autocomplete(complete_fn) 54 | 55 | # self.text.selectable = lambda x: False 56 | self.cols = urwid.Columns([ 57 | (2, self.prompt), 58 | ("weight", 1, self.text) 59 | ], dividechars=0) 60 | self.cols.focus_position = 1 61 | self.filler = urwid.Filler(self.cols, valign="bottom") 62 | urwid.connect_signal(self.text, "postchange", self.text_changed) 63 | urwid.connect_signal(self.text, "complete_prev", lambda source: self._emit("complete_prev")) 64 | urwid.connect_signal(self.text, "complete_next", lambda source: self._emit("complete_next")) 65 | urwid.connect_signal(self.text, "select", lambda source: self._emit("select")) 66 | urwid.connect_signal(self.text, "close", lambda source: self._emit("close")) 67 | super(AutoCompleteBar, self).__init__(self.filler) 68 | 69 | def set_prompt(self, text): 70 | 71 | self.prompt.set_text((self.prompt_attr, text)) 72 | 73 | def set_text(self, text): 74 | 75 | self.text.set_edit_text(text) 76 | 77 | def text_changed(self, source, text): 78 | self._emit("change", text) 79 | 80 | def confirm(self): 81 | self._emit("select") 82 | self._emit("close") 83 | 84 | def cancel(self): 85 | self._emit("close") 86 | 87 | def __len__(self): 88 | return len(self.body) 89 | 90 | def keypress(self, size, key): 91 | return super().keypress(size, key) 92 | 93 | @keymapped() 94 | class AutoCompleteMixin(object): 95 | 96 | auto_complete = None 97 | prompt_attr = "dropdown_prompt" 98 | 99 | def __init__(self, auto_complete, prompt_attr=None, *args, **kwargs): 100 | super().__init__(self.complete_container, *args, **kwargs) 101 | if auto_complete is not None: self.auto_complete = auto_complete 102 | if prompt_attr is not None: 103 | self.prompt_attr = prompt_attr 104 | self.auto_complete_bar = None 105 | self.completing = False 106 | self.complete_anywhere = False 107 | self.case_sensitive = False 108 | self.last_complete_pos = None 109 | self.complete_string_location = None 110 | self.last_filter_text = None 111 | 112 | if self.auto_complete: 113 | self.auto_complete_bar = AutoCompleteBar( 114 | prompt_attr=self.prompt_attr, 115 | complete_fn=self.complete_fn 116 | ) 117 | 118 | urwid.connect_signal( 119 | self.auto_complete_bar, "change", 120 | lambda source, text: self.complete() 121 | ) 122 | urwid.connect_signal( 123 | self.auto_complete_bar, "complete_prev", 124 | lambda source: self.complete_prev() 125 | ) 126 | urwid.connect_signal( 127 | self.auto_complete_bar, "complete_next", 128 | lambda source: self.complete_next() 129 | ) 130 | 131 | urwid.connect_signal( 132 | self.auto_complete_bar, "select", self.on_complete_select 133 | ) 134 | urwid.connect_signal( 135 | self.auto_complete_bar, "close", self.on_complete_close 136 | ) 137 | 138 | def keypress(self, size, key): 139 | return super().keypress(size, key) 140 | # key = super().keypress(size, key) 141 | # if self.completing and key == "enter": 142 | # self.on_complete_select(self) 143 | # else: 144 | # return key 145 | 146 | @property 147 | def complete_container(self): 148 | raise NotImplementedError 149 | 150 | @property 151 | def complete_container_position(self): 152 | return 1 153 | 154 | @property 155 | def complete_body_position(self): 156 | return 0 157 | 158 | @property 159 | def complete_body(self): 160 | raise NotImplementedError 161 | 162 | @property 163 | def complete_items(self): 164 | raise NotImplementedError 165 | 166 | def complete_fn(self, text, state): 167 | tmp = [ 168 | c for c in self.complete_items 169 | if c and text in c 170 | ] if text else self.complete_items 171 | try: 172 | return str(tmp[state]) 173 | except (IndexError, TypeError): 174 | return None 175 | 176 | def complete_widget_at_pos(self, pos): 177 | return self.complete_body[pos] 178 | 179 | def complete_set_focus(self, pos): 180 | self.focus_position = pos 181 | 182 | @keymap_command() 183 | def complete_prefix(self): 184 | self.complete_on() 185 | 186 | @keymap_command() 187 | def complete_substring(self): 188 | self.complete_on(anywhere=True) 189 | 190 | def complete_prev(self): 191 | self.complete(step=-1) 192 | 193 | def complete_next(self): 194 | self.complete(step=1) 195 | 196 | def complete_on(self, anywhere=False, case_sensitive=False): 197 | 198 | if self.completing: 199 | return 200 | self.completing = True 201 | self.show_bar() 202 | if anywhere: 203 | self.complete_anywhere = True 204 | else: 205 | self.complete_anywhere = False 206 | 207 | if case_sensitive: 208 | self.case_sensitive = True 209 | else: 210 | self.case_sensitive = False 211 | 212 | def complete_compare_substring(self, search, candidate): 213 | try: 214 | return candidate.index(search) 215 | except ValueError: 216 | return None 217 | 218 | def complete_compare_fn(self, search, candidate): 219 | 220 | if self.case_sensitive: 221 | f = lambda x: str(x) 222 | else: 223 | f = lambda x: str(x.lower()) 224 | 225 | if self.complete_anywhere: 226 | return self.complete_compare_substring(f(search), f(candidate)) 227 | else: 228 | return 0 if self.complete_compare_substring(f(search), f(candidate))==0 else None 229 | # return f(candidate) 230 | 231 | 232 | @keymap_command() 233 | def complete_off(self): 234 | 235 | if not self.completing: 236 | return 237 | self.filter_text = "" 238 | 239 | self.hide_bar() 240 | self.completing = False 241 | 242 | @keymap_command 243 | def complete(self, step=None, no_wrap=False): 244 | 245 | if not self.filter_text: 246 | return 247 | 248 | # if not step and self.filter_text == self.last_filter_text: 249 | # return 250 | 251 | # logger.info(f"complete: {self.filter_text}") 252 | 253 | if self.last_complete_pos: 254 | widget = self.complete_widget_at_pos(self.last_complete_pos) 255 | if isinstance(widget, HighlightableTextMixin): 256 | widget.unhighlight() 257 | 258 | self.initial_pos = self.complete_body.get_focus()[1] 259 | positions = itertools.cycle( 260 | self.complete_body.positions(reverse=(step and step < 0)) 261 | ) 262 | pos = next(positions) 263 | # logger.info(pos.get_value()) 264 | # import ipdb; ipdb.set_trace() 265 | while pos != self.initial_pos: 266 | # logger.info(pos.get_value()) 267 | pos = next(positions) 268 | for i in range(abs(step or 0)): 269 | # logger.info(pos.get_value()) 270 | pos = next(positions) 271 | 272 | while True: 273 | widget = self.complete_widget_at_pos(pos) 274 | complete_index = self.complete_compare_fn(self.filter_text, str(widget)) 275 | if complete_index is not None: 276 | self.last_complete_pos = pos 277 | if isinstance(widget, HighlightableTextMixin): 278 | widget.highlight(complete_index, complete_index+len(self.filter_text)) 279 | self.complete_set_focus(pos) 280 | break 281 | pos = next(positions) 282 | if pos == self.initial_pos: 283 | break 284 | 285 | # logger.info("done") 286 | self.last_filter_text = self.filter_text 287 | 288 | @keymap_command() 289 | def cancel(self): 290 | logger.debug("cancel") 291 | self.complete_container.focus_position = self.selected_button 292 | self.close() 293 | 294 | def close(self): 295 | self._emit("close") 296 | 297 | def show_bar(self): 298 | pos = self.complete_container_pos 299 | self.complete_container.contents[pos:pos+1] += [( 300 | self.auto_complete_bar, 301 | self.complete_container.options("given", 1) 302 | )] 303 | # self.box.height -= 1 304 | self.complete_container.focus_position = pos 305 | 306 | def hide_bar(self): 307 | pos = self.complete_container_pos 308 | widget = self.complete_widget_at_pos(self.complete_body.get_focus()[1]) 309 | if isinstance(widget, HighlightableTextMixin): 310 | widget.unhighlight() 311 | self.complete_container.focus_position = self.complete_body_position 312 | del self.complete_container.contents[pos] 313 | # self.box.height += 1 314 | 315 | @property 316 | def filter_text(self): 317 | return self.auto_complete_bar.text.get_text()[0] 318 | 319 | @filter_text.setter 320 | def filter_text(self, value): 321 | return self.auto_complete_bar.set_text(value) 322 | 323 | def on_complete_select(self, source): 324 | widget = self.complete_widget_at_pos(self.complete_body.get_focus()[1]) 325 | self.complete_off() 326 | self._emit("select", self.last_complete_pos, widget) 327 | self._emit("close") 328 | 329 | def on_complete_close(self, source): 330 | self.complete_off() 331 | 332 | __all__ = ["AutoCompleteMixin"] 333 | -------------------------------------------------------------------------------- /panwid/datatable/__init__.py: -------------------------------------------------------------------------------- 1 | from .datatable import * 2 | from .dataframe import * 3 | from .columns import * 4 | from .common import * 5 | 6 | __all__ = """ 7 | DataTable 8 | DataTableColumn 9 | DataTableDivider 10 | DataTableText 11 | DataTableDataFrame 12 | """.split() 13 | -------------------------------------------------------------------------------- /panwid/datatable/cells.py: -------------------------------------------------------------------------------- 1 | import logging 2 | logger = logging.getLogger("panwid.datatable") 3 | 4 | from .common import * 5 | 6 | import urwid 7 | class DataTableCell(urwid.WidgetWrap): 8 | 9 | signals = ["click", "select"] 10 | 11 | ATTR = "table_cell" 12 | 13 | def __init__(self, table, column, row, 14 | fill=False, 15 | value_attr=None, 16 | cell_selection=False, 17 | padding=0, 18 | *args, **kwargs): 19 | 20 | 21 | self.table = table 22 | self.column = column 23 | self.row = row 24 | 25 | self.fill = fill 26 | self.value_attr = value_attr 27 | self.cell_selection = cell_selection 28 | 29 | self.attr = self.ATTR 30 | self.attr_focused = "%s focused" %(self.attr) 31 | self.attr_column_focused = "%s column_focused" %(self.attr) 32 | self.attr_highlight = "%s highlight" %(self.attr) 33 | self.attr_highlight_focused = "%s focused" %(self.attr_highlight) 34 | self.attr_highlight_column_focused = "%s column_focused" %(self.attr_highlight) 35 | 36 | self._width = None 37 | self._height = None 38 | self.contents_rows = None 39 | # self.width = None 40 | 41 | if column.padding: 42 | self.padding = column.padding 43 | else: 44 | self.padding = padding 45 | 46 | self.update_contents() 47 | 48 | # logger.info(f"{self.column.name}, {self.column.width}, {self.column.align}") 49 | # if self.row.row_height is not None: 50 | 51 | # self.filler = urwid.Filler(self.contents) 52 | 53 | self.normal_attr_map = {} 54 | self.highlight_attr_map = {} 55 | 56 | self.normal_focus_map = {} 57 | self.highlight_focus_map = {} 58 | self.highlight_column_focus_map = {} 59 | 60 | self.set_attr_maps() 61 | 62 | self.highlight_attr_map.update(self.table.highlight_map) 63 | self.highlight_focus_map.update(self.table.highlight_focus_map) 64 | 65 | self.attrmap = urwid.AttrMap( 66 | # self.filler, 67 | urwid.Filler(self.contents) if "flow" in self.contents.sizing() else self.contents, 68 | attr_map = self.normal_attr_map, 69 | focus_map = self.normal_focus_map 70 | ) 71 | super(DataTableCell, self).__init__(self.attrmap) 72 | 73 | def __repr__(self): 74 | return f"<{self.__class__.__name__}: {self.column.name}>" 75 | 76 | @property 77 | def value(self): 78 | if self.column.value_fn: 79 | row = self.table.get_dataframe_row_object(self.row.index) 80 | val = self.column.value_fn(self.table, row) 81 | else: 82 | val = self.row[self.column.name] 83 | return val 84 | 85 | @value.setter 86 | def value(self, value): 87 | self.table.df[self.row.index, self.column.name] = value 88 | 89 | @property 90 | def formatted_value(self): 91 | 92 | v = self.column._format(self.value) 93 | if not self.width: 94 | return v 95 | # try: 96 | v = str(v)[:self.width-self.padding*2] 97 | # logger.info(f"formatted_value: {v}") 98 | return v 99 | # except TypeError: 100 | # raise Exception(f"{v}, {type(v)}") 101 | 102 | def update_contents(self): 103 | pass 104 | 105 | def set_attr_maps(self): 106 | 107 | self.normal_attr_map[None] = self.attr 108 | self.highlight_attr_map [None] = self.attr_highlight 109 | self.normal_focus_map[None] = self.attr_focused 110 | self.highlight_focus_map[None] = self.attr_highlight_focused 111 | 112 | if self.value_attr: 113 | self.normal_attr_map.update({None: self.value_attr}) 114 | self.normal_focus_map.update({None: "%s focused" %(self.value_attr)}) 115 | self.highlight_attr_map.update({None: "%s highlight" %(self.value_attr)}) 116 | if self.cell_selection: 117 | self.highlight_focus_map.update({None: "%s highlight column_focused" %(self.value_attr)}) 118 | else: 119 | self.highlight_focus_map.update({None: "%s highlight focused" %(self.value_attr)}) 120 | 121 | def highlight(self): 122 | self.attrmap.set_attr_map(self.highlight_attr_map) 123 | self.attrmap.set_focus_map(self.highlight_focus_map) 124 | 125 | def unhighlight(self): 126 | self.attrmap.set_attr_map(self.normal_attr_map) 127 | self.attrmap.set_focus_map(self.normal_focus_map) 128 | 129 | def enable_selection(self): 130 | self.cell_selection = True 131 | 132 | def disable_selection(self): 133 | self.cell_selection = False 134 | 135 | def selectable(self): 136 | return self.cell_selection 137 | 138 | def keypress(self, size, key): 139 | try: 140 | key = super(DataTableCell, self).keypress(size, key) 141 | except AttributeError: 142 | pass 143 | return key 144 | # return super(DataTableCell, self).keypress(size, key) 145 | 146 | def set_attr_map(self, attr_map): 147 | self.attrmap.set_attr_map(attr_map) 148 | 149 | def mouse_event(self, size, event, button, col, row, focus): 150 | if event == 'mouse press': 151 | urwid.emit_signal(self, "click") 152 | 153 | def set_attr(self, attr): 154 | attr_map = self.attrmap.get_attr_map() 155 | attr_map[None] = attr 156 | # self.attrmap.set_attr_map(attr_map) 157 | focus_map = self.attrmap.get_focus_map() 158 | focus_map[None] = "%s focused" %(attr) 159 | self.attrmap.set_focus_map(focus_map) 160 | 161 | def clear_attr(self, attr): 162 | attr_map = self.attrmap.get_attr_map() 163 | attr_map = self.normal_attr_map 164 | # attr_map[None] = self.attr 165 | self.attrmap.set_attr_map(attr_map) 166 | focus_map = self.normal_focus_map #.attr.get_focus_map() 167 | # focus_map[None] = self.attr_focused 168 | self.attrmap.set_focus_map(focus_map) 169 | 170 | def render(self, size, focus=False): 171 | # logger.info("cell render") 172 | maxcol = size[0] 173 | self._width = size[0] 174 | if len(size) > 1: 175 | maxrow = size[1] 176 | self._height = maxrow 177 | else: 178 | self.contents_rows = self.contents.rows(size, focus) 179 | self._height = contents_rows 180 | 181 | if getattr(self.column, "truncate", None): 182 | rows = self.inner_contents.pack((self.width,))[1] 183 | if rows > 1: 184 | self.truncate() 185 | return super().render(size, focus) 186 | 187 | @property 188 | def width(self): 189 | return self._width 190 | 191 | @property 192 | def height(self): 193 | return self._height 194 | 195 | @property 196 | def inner_contents(self): 197 | return self.contents 198 | 199 | def truncate(self): 200 | pass 201 | 202 | 203 | class DataTableDividerCell(DataTableCell): 204 | 205 | # @property 206 | # def fiil(self): 207 | # return self.row.row_height is not None 208 | 209 | def selectable(self): 210 | return False 211 | 212 | def update_contents(self): 213 | if not ( 214 | (isinstance(self, DataTableHeaderCell) and not self.column.in_header) 215 | or (isinstance(self, DataTableFooterCell) and not self.column.in_footer) 216 | ): 217 | divider = self.column.value 218 | else: 219 | divider = urwid.Divider(" ") 220 | contents = urwid.Padding( 221 | divider, 222 | left = self.column.padding_left, 223 | right = self.column.padding_right 224 | ) 225 | self.contents = contents 226 | # self._invalidate() 227 | 228 | class DataTableBodyCell(DataTableCell): 229 | 230 | ATTR = "table_row_body" 231 | 232 | def update_contents(self): 233 | 234 | self.inner = self.table.decorate( 235 | self.row, 236 | self.column, 237 | self.formatted_value 238 | ) 239 | 240 | # try: 241 | # self.inner = self.table.decorate( 242 | # self.row, 243 | # self.column, 244 | # self.formatted_value 245 | # ) 246 | # except Exception as e: 247 | # logger.exception(e) 248 | # self.inner = urwid.Text("") 249 | 250 | if getattr(self.column, "truncate", None): 251 | end_char = u"\N{HORIZONTAL ELLIPSIS}" if self.column.truncate is True else self.column.truncate 252 | contents = urwid.Columns([ 253 | ("weight", 1, self.inner), 254 | (0, urwid.Text(end_char)) 255 | ]) 256 | contents = urwid.Filler(contents) 257 | else: 258 | width = "pack" 259 | 260 | contents = urwid.Padding( 261 | self.inner, 262 | align=self.column.align, 263 | width = width, 264 | left=self.padding, 265 | right=self.padding 266 | ) 267 | # contents = urwid.Filler(contents) 268 | 269 | self.contents = contents 270 | 271 | def truncate(self): 272 | columns = self.contents.original_widget 273 | col = columns.contents[1] 274 | col = (col[0], columns.options("given", 1)) 275 | del self.contents.original_widget.contents[1] 276 | columns.contents.append(col) 277 | 278 | 279 | @property 280 | def inner_contents(self): 281 | return self.inner 282 | 283 | 284 | class DataTableDividerBodyCell(DataTableDividerCell, DataTableBodyCell): 285 | pass 286 | 287 | class DataTableHeaderCell(DataTableCell): 288 | 289 | ATTR = "table_row_header" 290 | 291 | ASCENDING_SORT_MARKER = u"\N{UPWARDS ARROW}" 292 | DESCENDING_SORT_MARKER = u"\N{DOWNWARDS ARROW}" 293 | 294 | def __init__(self, *args, **kwargs): 295 | self.mouse_dragging = False 296 | self.mouse_drag_start = None 297 | self.mouse_drag_end = None 298 | super().__init__(*args, **kwargs) 299 | 300 | @property 301 | def index(self): 302 | return next(i for i, c in enumerate(self.table.visible_columns) 303 | if c.name == self.column.name) 304 | 305 | @property 306 | def min_width(self): 307 | return len(self.label) + self.padding*2 + (1 if self.sort_icon else 0) 308 | 309 | def update_contents(self): 310 | 311 | self.label = self.column.label 312 | if self.column.sort_icon is not None: 313 | self.sort_icon = self.column.sort_icon 314 | else: 315 | self.sort_icon = self.table.sort_icons 316 | 317 | label = (self.label 318 | if isinstance(self.label, urwid.Widget) 319 | else 320 | DataTableText( 321 | self.label, 322 | wrap = "space" if self.column.no_clip_header else "clip", 323 | ) 324 | ) 325 | 326 | padding = urwid.Padding( 327 | label, 328 | align=self.column.align, 329 | width="pack", 330 | left=self.padding, 331 | right=self.padding 332 | ) 333 | 334 | columns = urwid.Columns([ 335 | ("weight", 1, padding) 336 | ]) 337 | 338 | if self.sort_icon: 339 | if self.column.align == "right": 340 | columns.contents.insert(0, 341 | (DataTableText(""), columns.options("given", 1)) 342 | ) 343 | else: 344 | columns.contents.append( 345 | (DataTableText(""), columns.options("given", 1)) 346 | ) 347 | self.contents = columns 348 | 349 | # self.contents = DataTableText( 350 | # self.label, 351 | # wrap = "space" if self.column.no_clip_header else "clip" 352 | # ) 353 | self.update_sort(self.table.sort_by) 354 | 355 | def set_attr_maps(self): 356 | 357 | self.normal_attr_map[None] = self.attr 358 | self.highlight_attr_map [None] = self.attr_highlight 359 | # if self.cell_selection: 360 | self.normal_focus_map[None] = self.attr_column_focused 361 | self.highlight_focus_map[None] = self.attr_highlight_column_focused 362 | 363 | def selectable(self): 364 | return self.table.ui_sort 365 | 366 | def keypress(self, size, key): 367 | if key != "enter": 368 | return key 369 | urwid.emit_signal(self, "select", self) 370 | 371 | def mouse_event(self, size, event, button, col, row, focus): 372 | if event == "mouse press": 373 | logger.info("cell press") 374 | if self.mouse_drag_start is None: 375 | self.row.mouse_drag_source_column = col 376 | self.row.mouse_drag_source = self 377 | return False 378 | elif event == "mouse drag": 379 | logger.info("cell drag") 380 | self.mouse_dragging = True 381 | return False 382 | # urwid.emit_signal(self, "drag_start") 383 | elif event == "mouse release": 384 | logger.info("cell release") 385 | if self.mouse_dragging: 386 | self.mouse_dragging = False 387 | self.mouse_drag_start = None 388 | else: 389 | urwid.emit_signal(self, "select", self) 390 | self.mouse_drag_source = None 391 | # self.mouse_drag_end = col 392 | # raise Exception(self.mouse_drag_start, self.mouse_drag_end) 393 | # self.mouse_drag_start = None 394 | super().mouse_event(size, event, button, col, row, focus) 395 | 396 | def update_sort(self, sort): 397 | if not self.sort_icon: return 398 | 399 | index = 0 if self.column.align=="right" else 1 400 | if sort and sort[0] == self.column.name: 401 | direction = self.DESCENDING_SORT_MARKER if sort[1] else self.ASCENDING_SORT_MARKER 402 | self.contents.contents[index][0].set_text(direction) 403 | # else: 404 | # self.contents.contents[index][0].set_text("") 405 | 406 | 407 | class DataTableDividerHeaderCell(DataTableDividerCell, DataTableHeaderCell): 408 | pass 409 | 410 | 411 | class DataTableFooterCell(DataTableCell): 412 | 413 | ATTR = "table_row_footer" 414 | 415 | def update_contents(self): 416 | if self.column.footer_fn and len(self.table.df): 417 | # self.table.df.log_dump() 418 | if self.column.footer_arg == "values": 419 | footer_arg = self.table.df[self.column.name].to_list() 420 | elif self.column.footer_arg == "rows": 421 | footer_arg = self.table.df.iterrows() 422 | elif self.column.footer_arg == "table": 423 | footer_arg = self.table.df 424 | else: 425 | raise Exception 426 | self.contents = self.table.decorate( 427 | self.row, 428 | self.column, 429 | self.column._format(self.column.footer_fn(self.column, footer_arg)) 430 | ) 431 | else: 432 | self.contents = DataTableText("") 433 | 434 | 435 | class DataTableDividerFooterCell(DataTableDividerCell, DataTableHeaderCell): 436 | 437 | DIVIDER_ATTR = "table_divider_footer" 438 | -------------------------------------------------------------------------------- /panwid/datatable/columns.py: -------------------------------------------------------------------------------- 1 | import logging 2 | logger = logging.getLogger("panwid.datatable") 3 | 4 | from datetime import datetime, date as datetype 5 | 6 | from .common import * 7 | 8 | class NoSuchColumnException(Exception): 9 | pass 10 | 11 | def make_value_function(template): 12 | 13 | def inner(table, row): 14 | pos = table.index_to_position(row.get(table.index)) 15 | return template.format( 16 | data=row, 17 | row=pos+1, 18 | rows_loaded = len(table), 19 | rows_total = table.query_result_count() if table.limit else "?" 20 | ) 21 | 22 | return inner 23 | 24 | class DataTableBaseColumn(object): 25 | 26 | _width = ("weight", 1) 27 | 28 | def __init__( 29 | self, 30 | padding = DEFAULT_CELL_PADDING, 31 | hide=False, 32 | width=None, 33 | min_width=None, 34 | attr = None 35 | 36 | ): 37 | self.hide = hide 38 | self.padding = padding 39 | 40 | if isinstance(self.padding, tuple): 41 | self.padding_left, self.padding_right = self.padding 42 | else: 43 | self.padding_left = self.padding_right = self.padding 44 | 45 | if width is not None: self._width = width 46 | self.min_width = min_width 47 | self.attr = attr 48 | 49 | if isinstance(self._width, tuple): 50 | if self._width[0] != "weight": 51 | raise Exception( 52 | "Column width %s not supported" %(self._width[0]) 53 | ) 54 | self.initial_sizing, self.initial_width = self._width 55 | self.min_width = 3 # FIXME 56 | elif isinstance(self._width, int): 57 | self.initial_sizing = "given" 58 | self.initial_width = self._width 59 | self.min_width = self.initial_width = self._width # assume starting width is minimum 60 | 61 | else: 62 | raise Exception(self._width) 63 | 64 | self.sizing = self.initial_sizing 65 | self.width = self.initial_width 66 | 67 | def __repr__(self): 68 | return f"<{self.__class__.__name__}: {self.name} ({self.width}, {self.sizing})>" 69 | 70 | def width_with_padding(self, table_padding=None): 71 | padding = 0 72 | if self.padding is None and table_padding is not None: 73 | padding = table_padding 74 | return self.width + self.padding_left + self.padding_right 75 | 76 | @property 77 | def index(self): 78 | return self.table.visible_columns.index(self) 79 | 80 | @property 81 | def header(self): 82 | try: 83 | return self.table.header.cells[self.index] 84 | except ValueError: 85 | return None 86 | 87 | 88 | 89 | class DataTableColumn(DataTableBaseColumn): 90 | 91 | def __init__(self, name, 92 | label=None, 93 | value=None, 94 | align="left", wrap="space", 95 | pack=False, 96 | no_clip_header = False, 97 | truncate=False, 98 | format_fn=None, 99 | decoration_fn=None, 100 | sort_key = None, sort_reverse=False, 101 | sort_icon = None, 102 | footer_fn = None, footer_arg = "values", **kwargs): 103 | 104 | super().__init__(**kwargs) 105 | self.name = name 106 | self.label = label if label is not None else name 107 | if value: 108 | if isinstance(value, str): 109 | self.value_fn = make_value_function(value) 110 | elif callable(value): 111 | self.value_fn = value 112 | else: 113 | self.value_fn = None 114 | self.align = align 115 | self.pack = pack 116 | self.wrap = wrap 117 | self.no_clip_header = no_clip_header 118 | self.truncate = truncate 119 | self.format_fn = format_fn 120 | self.decoration_fn = decoration_fn 121 | self.sort_key = sort_key 122 | self.sort_reverse = sort_reverse 123 | self.sort_icon = sort_icon 124 | self.footer_fn = footer_fn 125 | self.footer_arg = footer_arg 126 | logger.debug(f"column {self.name}, width: {self.sizing}, {self.width}") 127 | 128 | 129 | @property 130 | def contents_width(self): 131 | try: 132 | index = next(i for i, c 133 | in enumerate(self.table.visible_columns) 134 | if getattr(c, "name", None) == self.name) 135 | except StopIteration: 136 | raise Exception(self.name, [ c.name for c in self.table.visible_columns]) 137 | # logger.info(f"len: {len(self.table.body)}") 138 | 139 | l = [ 140 | ( 141 | getattr(r.cells[index].value, "min_width", None) 142 | or 143 | len(str(r.cells[index].formatted_value)) 144 | ) + self.padding*2 145 | for r in (self.table.body) 146 | ] + [self.table.header.cells[index].min_width or 0] + [self.min_width or 0] 147 | return max(l) 148 | 149 | @property 150 | def minimum_width(self): 151 | # if self.sizing == "pack": 152 | if self.pack: 153 | # logger.info(f"min: {self.name}, {self.contents_width}") 154 | return self.contents_width 155 | else: 156 | return self.min_width or len(self.label) + self.padding_left + self.padding_right + (1 if self.sort_icon else 0) 157 | 158 | 159 | def _format(self, v): 160 | 161 | # First, call the format function for the column, if there is one 162 | if self.format_fn: 163 | v = self.format_fn(v) 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 170 | return self.format(v) 171 | 172 | 173 | def format(self, v): 174 | 175 | # Do our best to make the value into something presentable 176 | if v is None: 177 | v = " " 178 | elif isinstance(v, int): 179 | v = "%d" %(v) 180 | elif isinstance(v, float): 181 | v = "%.03f" %(v) 182 | elif isinstance(v, datetime): 183 | v = v.strftime("%Y-%m-%d %H:%M:%S") 184 | elif isinstance(v, datetype): 185 | v = v.strftime("%Y-%m-%d") 186 | return v 187 | 188 | 189 | class DataTableDivider(DataTableBaseColumn): 190 | 191 | _width = 1 192 | 193 | def __init__(self, char=" ", in_header=False, in_footer=False, **kwargs): 194 | super().__init__(**kwargs) 195 | self.char = char 196 | self.in_header = in_header 197 | self.in_footer = in_footer 198 | 199 | @property 200 | def name(self): 201 | return "divider" 202 | 203 | @property 204 | def value(self): 205 | # FIXME: should use SolidFill for rows that span multiple screen rows 206 | w = urwid.Divider(self.char) 207 | return w 208 | 209 | @property 210 | def contents_width(self): 211 | return len(self.char) 212 | 213 | @property 214 | def pack(self): 215 | return False 216 | 217 | @property 218 | def align(self): 219 | return "left" 220 | -------------------------------------------------------------------------------- /panwid/datatable/common.py: -------------------------------------------------------------------------------- 1 | import urwid 2 | import itertools 3 | 4 | DEFAULT_CELL_PADDING = 0 5 | 6 | def partition(pred, iterable): 7 | 'Use a predicate to partition entries into false entries and true entries' 8 | # partition(is_odd, range(10)) --> 0 2 4 6 8 and 1 3 5 7 9 9 | t1, t2 = itertools.tee(iterable) 10 | return itertools.filterfalse(pred, t1), filter(pred, t2) 11 | 12 | intersperse = lambda e,l: sum([[x, e] for x in l],[])[:-1] 13 | 14 | class DataTableText(urwid.Text): 15 | 16 | DEFAULT_END_CHAR = u"\N{HORIZONTAL ELLIPSIS}" 17 | 18 | def truncate(self, width, end_char=None): 19 | # raise Exception(width) 20 | text = self.get_text()[0] 21 | max_width = width 22 | if end_char: 23 | if end_char is True: 24 | end_char = self.DEFAULT_END_CHAR 25 | max_width -= len(end_char) 26 | else: 27 | end_char="" 28 | # raise Exception(len(text), max_width) 29 | if len(text) > max_width: 30 | self.set_text(text[:max_width]+end_char) 31 | 32 | def __len__(self): 33 | return len(self.get_text()[0]) 34 | -------------------------------------------------------------------------------- /panwid/datatable/dataframe.py: -------------------------------------------------------------------------------- 1 | import logging 2 | logger = logging.getLogger("panwid.datatable") 3 | import raccoon as rc 4 | import collections 5 | 6 | class DataTableDataFrame(rc.DataFrame): 7 | 8 | DATA_TABLE_COLUMNS = ["_dirty", "_focus_position", "_value_fn", "_cls", "_details", "_rendered_row"] 9 | 10 | def __init__(self, data=None, columns=None, index=None, index_name="index", sort=None): 11 | 12 | self.sidecar_columns = [] 13 | if columns and not index_name in columns: 14 | columns.insert(0, index_name) 15 | columns += self.DATA_TABLE_COLUMNS 16 | super(DataTableDataFrame, self).__init__( 17 | data=data, 18 | columns=columns, 19 | index=index, 20 | index_name=index_name, 21 | sort=sort 22 | ) 23 | # for c in self.DATA_TABLE_COLUMNS: 24 | # self[c] = None 25 | 26 | def _validate_index(self, indexes): 27 | try: 28 | return super(DataTableDataFrame, self)._validate_index(indexes) 29 | except ValueError: 30 | logger.error("duplicates in index: %s" %( 31 | [item for item, count 32 | in list(collections.Counter(indexes).items()) if count > 1 33 | ])) 34 | raise 35 | 36 | 37 | def log_dump(self, n=5, columns=None, label=None): 38 | df = self 39 | if columns: 40 | if not isinstance(columns, list): 41 | columns = [columns] 42 | df = df[columns] 43 | logger.info("%slength: %d, index: %s [%s%s]\n%s" %( 44 | "%s, " %(label) if label else "", 45 | len(self), 46 | self.index_name, 47 | ",".join([str(x) for x in self.index[0:min(n, len(self.index))]]), 48 | "..." if len(self.index) > n else "", 49 | df.head(n))) 50 | 51 | @staticmethod 52 | def extract_keys(obj): 53 | return obj.keys() if hasattr(obj, "keys") else obj.__dict__.keys() 54 | 55 | @staticmethod 56 | def extract_value(obj, key): 57 | if isinstance(obj, collections.abc.MutableMapping): 58 | # raise Exception(obj) 59 | return obj.get(key, None) 60 | else: 61 | return getattr(obj, key, None) 62 | 63 | def transpose_data(self, rows, with_sidecar = False): 64 | 65 | # raise Exception([ r[self.index_name] for r, s in rows]) 66 | 67 | if with_sidecar: 68 | data_columns, self.sidecar_columns = [ 69 | list(set().union(*x)) 70 | for x in zip(*[( 71 | self.extract_keys(d), 72 | self.extract_keys(s) 73 | ) 74 | for d, s in rows ]) 75 | ] 76 | 77 | else: 78 | data_columns = list( 79 | set().union(*(list(d.keys() 80 | if hasattr(d, "keys") 81 | else d.__dict__.keys()) 82 | for d in rows)) 83 | ) 84 | 85 | data_columns += [ 86 | c for c in self.columns 87 | if c not in data_columns 88 | and c not in self.sidecar_columns 89 | and c != self.index_name 90 | and c not in self.DATA_TABLE_COLUMNS 91 | ] 92 | data_columns += ["_cls"] 93 | 94 | data = dict( 95 | list(zip((data_columns + self.sidecar_columns), 96 | [ list(z) for z in zip(*[[ 97 | self.extract_value(d, k) if k in data_columns else self.extract_value(s, k) 98 | for k in data_columns + self.sidecar_columns] 99 | for d, s in ( 100 | rows 101 | if with_sidecar 102 | else [ (r, {}) for r in rows] 103 | ) 104 | ])] 105 | )) 106 | ) 107 | 108 | return data 109 | 110 | 111 | def update_rows(self, rows, replace=False, with_sidecar = False): 112 | 113 | if not len(rows): 114 | return [] 115 | 116 | data = self.transpose_data(rows, with_sidecar = with_sidecar) 117 | # data["_details"] = [{"open": False, "disabled": False}] * len(rows) 118 | data["_cls"] = [type(rows[0][0] if with_sidecar else rows[0])] * len(rows) # all rows assumed to have same class 119 | 120 | # raise Exception(data["_cls"]) 121 | # if not "_details" in data: 122 | # data["_details"] = [{"open": False, "disabled": False}] * len(rows) 123 | 124 | if replace: 125 | if len(rows): 126 | indexes = [x for x in self.index if x not in data.get(self.index_name, [])] 127 | if len(indexes): 128 | self.delete_rows(indexes) 129 | else: 130 | self.delete_all_rows() 131 | 132 | # logger.info(f"update_rowGs: {self.index}, {data[self.index_name]}") 133 | 134 | if self.index_name not in data: 135 | index = list(range(len(self), len(self) + len(rows))) 136 | data[self.index_name] = index 137 | else: 138 | index = data[self.index_name] 139 | 140 | for c in data.keys(): 141 | # try: 142 | # raise Exception(data[self.index_name], c, data[c]) 143 | self.set(data[self.index_name], c, data[c]) 144 | # except ValueError as e: 145 | # logger.error(e) 146 | # logger.info(f"update_rows: {self.index}, {data}") 147 | # 148 | for idx in data[self.index_name]: 149 | if not self.get(idx, "_details"): 150 | self.set(idx, "_details", {"open": False, "disabled": False}) 151 | 152 | return data.get(self.index_name, []) 153 | 154 | def append_rows(self, rows): 155 | 156 | length = len(rows) 157 | if not length: 158 | return 159 | 160 | colnames = list(self.columns) + [c for c in self.DATA_TABLE_COLUMNS if c not in self.columns] 161 | 162 | # data_columns = list(set().union(*(list(d.keys()) for d in rows))) 163 | data = self.transpose_data(rows) 164 | colnames += [c for c in data.keys() if c not in colnames] 165 | 166 | for c in self.columns: 167 | if not c in data: 168 | data[c] = [None]*length 169 | 170 | for c in colnames: 171 | if not c in self.columns: 172 | self[c] = None 173 | 174 | kwargs = dict( 175 | columns = colnames, 176 | data = data, 177 | sort=False, 178 | index=data[self.index_name], 179 | index_name = self.index_name, 180 | ) 181 | 182 | try: 183 | newdata = DataTableDataFrame(**kwargs) 184 | except ValueError: 185 | raise Exception(kwargs) 186 | # newdata.log_dump() 187 | # self.log_dump(10, label="before") 188 | try: 189 | self.append(newdata) 190 | except ValueError: 191 | raise Exception(f"{self.index}, {newdata}") 192 | # self.log_dump(10, label="after") 193 | 194 | # def add_column(self, column, data=None): 195 | # self[column] = data 196 | 197 | def clear(self): 198 | self.delete_all_rows() 199 | # self.delete_rows(self.index) 200 | -------------------------------------------------------------------------------- /panwid/datatable/rows.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import itertools 3 | 4 | import urwid 5 | 6 | from .cells import * 7 | from .columns import * 8 | from orderedattrdict import AttrDict 9 | 10 | class DataTableRow(urwid.WidgetWrap): 11 | 12 | def __init__(self, table, 13 | content=None, 14 | row_height=None, 15 | divider=None, padding=None, 16 | cell_selection=False, 17 | style = None, 18 | *args, **kwargs): 19 | 20 | self.table = table 21 | self.row_height = row_height 22 | self.content = content 23 | # if not isinstance(self.content, int): 24 | # raise Exception(self.content, type(self)) 25 | self.divider = divider 26 | self.padding = padding 27 | self.cell_selection = cell_selection 28 | self.style = style 29 | # self.details = None 30 | self.sort = self.table.sort_by 31 | self.attr = self.ATTR 32 | self.attr_focused = "%s focused" %(self.attr) 33 | self.attr_column_focused = "%s column_focused" %(self.attr) 34 | self.attr_highlight = "%s highlight" %(self.attr) 35 | self.attr_highlight_focused = "%s focused" %(self.attr_highlight) 36 | self.attr_highlight_column_focused = "%s column_focused" %(self.attr_highlight) 37 | self.attr_map = { 38 | None: self.attr, 39 | } 40 | 41 | self.focus_map = { 42 | self.attr: self.attr_focused, 43 | self.attr_highlight: self.attr_highlight_focused, 44 | } 45 | 46 | # needed to restore if cell selection is toggled 47 | self.original_focus_map = self.focus_map.copy() 48 | 49 | # if self.cell_selection: 50 | self.focus_map.update({ 51 | self.attr_focused: self.attr_column_focused, 52 | self.attr_highlight_focused: self.attr_highlight_column_focused, 53 | }) 54 | self.focus_map.update(self.table.column_focus_map) 55 | self.cell_selection_focus_map = self.focus_map.copy() 56 | 57 | if cell_selection: 58 | self.enable_cell_selection() 59 | else: 60 | self.disable_cell_selection() 61 | 62 | self.focus_map.update(table.focus_map) 63 | 64 | self.contents_placeholder = urwid.WidgetPlaceholder(urwid.Text("")) 65 | 66 | w = self.contents_placeholder 67 | 68 | self.update() 69 | 70 | # if self.row_height: 71 | self.box = urwid.BoxAdapter(w, self.row_height or 1) 72 | 73 | self.pile = urwid.Pile([ 74 | ("weight", 1, self.box) 75 | ]) 76 | 77 | self.attrmap = urwid.AttrMap( 78 | self.pile, 79 | attr_map = self.attr_map, 80 | focus_map = self.focus_map, 81 | ) 82 | 83 | super(DataTableRow, self).__init__(self.attrmap) 84 | 85 | def on_resize(self): 86 | 87 | if self.row_height is not None: 88 | return 89 | l = [1] 90 | # for i, c in enumerate(self.cells): 91 | for c, w in zip(self.cells, self.column_widths( (self.table.width,) )): 92 | # try: 93 | # c.contents.render( (self.table.visible_columns[i].width,), False) 94 | # except Exception as e: 95 | # raise Exception(c, c.contents, e) 96 | 97 | try: 98 | rows = c.contents.rows( (w,) ) 99 | except AttributeError: 100 | continue 101 | # logger.debug(f"{c}, {c.contents}, {w}, {rows}") 102 | 103 | # self.table.header.render((self.table.width, self.row_height), False) 104 | # raise Exception(self.table.header.data_cells[i].width) 105 | # rows = self.table.header.rows( (self.table.header.cells[i].width,) ) 106 | # except Exception as e: 107 | # raise Exception(type(self), type(self.contents), e) 108 | # print(c, rows) 109 | l.append(rows) 110 | self.box.height = max(l) 111 | 112 | if self.details_open: 113 | self.open_details() 114 | 115 | # logger.debug(f"height: {self.box.height}") 116 | # (w, o) = self.pile.contents[0] 117 | # self.pile.contents[0] = (w, self.pile.options("given", max(l))) 118 | 119 | 120 | def keypress(self, size, key): 121 | try: 122 | key = super(DataTableRow, self).keypress(size, key) 123 | except AttributeError: 124 | pass 125 | return key 126 | 127 | def enable_cell_selection(self): 128 | self.cell_selection = True 129 | self.focus_map = self.cell_selection_focus_map 130 | 131 | def disable_cell_selection(self): 132 | self.cell_selection = False 133 | self.focus_map = self.original_focus_map 134 | 135 | def resize_column(self, index, width): 136 | # col = self.table.visible_columns[index*2] 137 | (widget, options) = self.columns.contents[index*2] 138 | self.columns.contents[index*2] = (widget, self.columns.options(*width)) 139 | 140 | def make_columns(self): 141 | 142 | # logger.info("make_columns") 143 | self.cells = self.make_cells() 144 | 145 | columns = urwid.Columns([]) 146 | 147 | idx = None 148 | for i, cell in enumerate(self.cells): 149 | if not (idx or isinstance(cell, DataTableDividerCell)): 150 | idx = i 151 | col = self.table.visible_columns[i] 152 | options = columns.options(col.sizing, col.width_with_padding(self.padding)) 153 | columns.contents.append( 154 | (cell, options) 155 | 156 | ) 157 | if idx: 158 | columns.focus_position = idx 159 | return columns 160 | 161 | def make_contents(self): 162 | self.columns = self.make_columns() 163 | return self.columns 164 | 165 | @property 166 | def contents(self): 167 | return self.contents_placeholder.original_widget 168 | 169 | def update(self): 170 | contents = self.make_contents() 171 | # if self.row_height is None: 172 | # contents = urwid.Filler(contents) 173 | self.contents_placeholder.original_widget = contents 174 | 175 | def selectable(self): 176 | return True 177 | 178 | def set_focus_column(self, index): 179 | for i, cell in enumerate(self): 180 | if i == index: 181 | cell.highlight() 182 | else: 183 | cell.unhighlight() 184 | def __len__(self): 185 | return len(self.columns.contents) 186 | 187 | def __iter__(self): 188 | return iter( self.columns[i] for i in range(0, len(self.columns.contents)) ) 189 | 190 | @property 191 | def values(self): 192 | return AttrDict(list(zip([c.name for c in self.table.visible_columns], [ c.value for c in self ]))) 193 | 194 | @property 195 | def data_cells(self): 196 | return [ c for c in self.cells if not isinstance(c, DataTableDividerCell)] 197 | 198 | def column_widths(self, size=None): 199 | if not size: 200 | size = (self.table.width,) 201 | return self.columns.column_widths(size) 202 | 203 | @property 204 | def width(self): 205 | return self._width 206 | 207 | @property 208 | def height(self): 209 | return self._height 210 | 211 | 212 | class DataTableDetails(urwid.WidgetWrap): 213 | 214 | def __init__(self, row, content, indent=None): 215 | 216 | self.row = row 217 | self.contents = content 218 | self.columns = urwid.Columns([ 219 | ("weight", 1, content) 220 | ]) 221 | if indent: 222 | self.columns.contents.insert(0, 223 | (urwid.Padding(urwid.Text(" ")), 224 | self.columns.options("given", indent) 225 | ) 226 | ) 227 | 228 | super().__init__(self.columns) 229 | 230 | def selectable(self): 231 | return not self.row.details_disabled 232 | 233 | 234 | class DataTableBodyRow(DataTableRow): 235 | 236 | ATTR = "table_row_body" 237 | 238 | DIVIDER_CLASS = DataTableDividerBodyCell 239 | 240 | 241 | @property 242 | def index(self): 243 | return self.content 244 | 245 | @property 246 | def data(self): 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) 252 | 253 | def __getattr__(self, attr): 254 | return object.__getattribute__(self.data_source, attr) 255 | 256 | def __getitem__(self, column): 257 | cls = self.table.df[self.index, "_cls"] 258 | # row = self.data 259 | if column in self.table.df.columns: 260 | # logger.info(f"__getitem__: {column}={self.table.df.get(self.index, column)}") 261 | return self.table.df[self.index, column] 262 | else: 263 | raise KeyError 264 | # raise Exception(column, self.table.df.columns) 265 | 266 | 267 | def __setitem__(self, column, value): 268 | self.table.df[self.index, column] = value 269 | # logger.info(f"__setitem__: {column}, {value}, {self.table.df[self.index, column]}") 270 | 271 | def get(self, key, default=None): 272 | try: 273 | return self[key] 274 | except KeyError: 275 | return default 276 | 277 | @property 278 | def details_open(self): 279 | # logger.info(f"{self['_details']}") 280 | # raise Exception(self.get([self.index, "_details"], {})) 281 | return self.get("_details", {}).get("open", False) 282 | 283 | @details_open.setter 284 | def details_open(self, value): 285 | details = self["_details"] 286 | details["open"] = value 287 | self["_details"] = details 288 | 289 | @property 290 | def details_disabled(self): 291 | return (not self.table.detail_selectable) or self.get([self.index, "_details"], {}).get("disabled", False) 292 | 293 | @details_disabled.setter 294 | def details_disabled(self, value): 295 | details = self["_details"] 296 | details["disabled"] = value 297 | if value == True: 298 | self.details_focused = False 299 | self["_details"] = details 300 | 301 | @property 302 | def details_focused(self): 303 | return self.details_open and ( 304 | len(self.pile.contents) == 0 305 | or self.pile.focus_position > 0 306 | ) 307 | 308 | @details_focused.setter 309 | def details_focused(self, value): 310 | if value: 311 | self.pile.focus_position = len(self.pile.contents)-1 312 | else: 313 | self.pile.focus_position = 0 314 | 315 | @property 316 | def details(self): 317 | if not getattr(self, "_details", None): 318 | 319 | content = self.table.detail_fn((self.data_source)) 320 | logger.debug(f"open_details: {type(content)}") 321 | if not content: 322 | return 323 | 324 | # self.table.header.render( (self.table.width,) ) 325 | indent_width = 0 326 | visible_count = itertools.count() 327 | 328 | def should_indent(x): 329 | if (isinstance(self.table.detail_hanging_indent, int) 330 | and (x[2] is None or x[2] <= self.table.detail_hanging_indent)): 331 | return True 332 | elif (isinstance(self.table.detail_hanging_indent, str) 333 | and x[1].name != self.table.detail_hanging_indent): 334 | return True 335 | return False 336 | 337 | if self.table.detail_hanging_indent: 338 | indent_width = sum([ 339 | x[1].width if not x[1].hide else 0 340 | for x in itertools.takewhile( 341 | should_indent, 342 | [ (i, c, next(visible_count) if not c.hide else None) 343 | for i, c in enumerate(self.table._columns) ] 344 | ) 345 | ]) 346 | 347 | self._details = DataTableDetails(self, content, indent_width) 348 | return self._details 349 | 350 | 351 | def open_details(self): 352 | 353 | if not self.table.detail_fn or not self.details or self.details_open: 354 | return 355 | 356 | if len(self.pile.contents) > 1: 357 | return 358 | 359 | if self.table.detail_replace: 360 | self.pile.contents[0] = (urwid.Filler(urwid.Text("")), self.pile.options("given", 0)) 361 | 362 | self.pile.contents.append( 363 | (self.details, self.pile.options("pack")) 364 | ) 365 | 366 | self.details_focused = True 367 | if not self["_details"]: 368 | self["_details"] = AttrDict() 369 | self["_details"]["open"] = True 370 | 371 | 372 | def close_details(self): 373 | if not self.table.detail_fn or not self.details_open: 374 | return 375 | # raise Exception 376 | self["_details"]["open"] = False 377 | 378 | if self.table.detail_replace: 379 | self.pile.contents[0] = (self.box, self.pile.options("pack")) 380 | 381 | # del self.pile.contents[:] 382 | # self.pile.contents.append( 383 | # (self.box, self.pile.options("pack")) 384 | # ) 385 | if len(self.pile.contents) >= 2: 386 | del self.pile.contents[1] 387 | 388 | def toggle_details(self): 389 | 390 | if self.details_open: 391 | self.close_details() 392 | else: 393 | self.open_details() 394 | 395 | def get_attr(self): 396 | return self.attrmap.get_attr_map().get(self.ATTR) 397 | 398 | def set_attr(self, attr): 399 | attr_map = self.attrmap.get_attr_map() 400 | attr_map[self.ATTR] = attr 401 | if self.cell_selection: 402 | attr_map[self.attr_highlight] = "%s highlight focused" %(attr) 403 | else: 404 | attr_map[self.attr_highlight] = "%s highlight" %(attr) 405 | self.attrmap.set_attr_map(attr_map) 406 | 407 | focus_map = self.attrmap.get_focus_map() 408 | focus_map[self.ATTR] = "%s focused" %(attr) 409 | focus_map[self.attr_highlight] = "%s highlight focused" %(attr) 410 | if self.cell_selection: 411 | focus_map[self.attr_focused] = "%s column_focused" %(attr) 412 | focus_map[self.attr_highlight_focused] = "%s highlight column_focused" %(attr) 413 | else: 414 | focus_map[self.attr_focused] = "%s focused" %(attr) 415 | focus_map[self.attr_highlight_focused] = "%s highlight focused" %(attr) 416 | self.attrmap.set_focus_map(focus_map) 417 | 418 | def clear_attr(self, attr): 419 | attr_map = self.attrmap.get_attr_map() 420 | for a in [self.ATTR, self.attr_highlight]: 421 | if a in attr_map: 422 | del attr_map[a] 423 | self.attrmap.set_attr_map(attr_map) 424 | focus_map = self.attrmap.get_focus_map() 425 | for a in [self.attr_focused, self.attr_highlight_focused]: 426 | if a in focus_map: 427 | del focus_map[a] 428 | focus_map[self.ATTR] = "%s focused" %(self.ATTR) 429 | self.attrmap.set_focus_map(focus_map) 430 | 431 | def make_cells(self): 432 | 433 | def col_to_attr(col): 434 | if col.attr is None: 435 | return None 436 | if callable(col.attr): 437 | return col.attr(self.data) 438 | elif col.attr in self.data: 439 | return self.data[col.attr] 440 | elif isinstance(col.attr, str): 441 | return col.attr 442 | else: 443 | return None 444 | 445 | return [ 446 | DataTableBodyCell( 447 | self.table, 448 | col, 449 | self, 450 | # self.data[col.name] if not col.format_record else self.data, 451 | value_attr=col_to_attr(col), 452 | cell_selection=self.cell_selection 453 | ) 454 | if isinstance(col, DataTableColumn) 455 | else DataTableDividerBodyCell(self.table, col, self) 456 | for i, col in enumerate(self.table.visible_columns)] 457 | 458 | # class DataTableDetailRow(DataTableRow): 459 | 460 | # ATTR = "table_row_detail" 461 | 462 | # @property 463 | # def details(self): 464 | # return self.content 465 | 466 | # def make_contents(self): 467 | # col = DataTableColumn("details") 468 | # return DataTableDetailCell(self.table, col, self) 469 | 470 | # def selectable(self): 471 | # return self.table.detail_selectable 472 | 473 | 474 | class DataTableHeaderRow(DataTableRow): 475 | 476 | signals = ["column_click", "drag"] 477 | 478 | ATTR = "table_row_header" 479 | 480 | DIVIDER_CLASS = DataTableDividerHeaderCell 481 | 482 | def __init__(self, *args, **kwargs): 483 | super().__init__(*args, **kwargs) 484 | self.mouse_press = False 485 | self.mouse_dragging = False 486 | self.mouse_drag_start = None 487 | self.mouse_drag_end = None 488 | self.mouse_drag_source = None 489 | self.mouse_drag_source_column = None 490 | 491 | def make_cells(self): 492 | cells = [ 493 | DataTableHeaderCell( 494 | self.table, 495 | col, 496 | self, 497 | sort=self.sort, 498 | ) 499 | if isinstance(col, DataTableColumn) 500 | else DataTableDividerHeaderCell(self.table, col, self) 501 | for i, col in enumerate(self.table.visible_columns)] 502 | 503 | def sort_by_index(source, index): 504 | urwid.emit_signal(self, "column_click", index) 505 | 506 | if self.table.ui_sort: 507 | for i, cell in enumerate([c for c in cells if not isinstance(c, DataTableDividerCell)]): 508 | urwid.connect_signal( 509 | cell, 510 | 'click', 511 | functools.partial(sort_by_index, index=i) 512 | ) 513 | urwid.connect_signal( 514 | cell, 515 | "select", 516 | functools.partial(sort_by_index, index=i) 517 | ) 518 | 519 | return cells 520 | 521 | def selectable(self): 522 | return self.table.ui_sort 523 | 524 | def update_sort(self, sort): 525 | for c in self.data_cells: 526 | c.update_sort(sort) 527 | 528 | def mouse_event(self, size, event, button, col, row, focus): 529 | 530 | if not super().mouse_event(size, event, button, col, row, focus): 531 | if event == "mouse press": 532 | self.mouse_press = True 533 | if self.mouse_drag_start is None: 534 | self.mouse_drag_start = col 535 | elif event == "mouse drag": 536 | if self.mouse_press: 537 | self.mouse_dragging = True 538 | # FIXME 539 | # self.mouse_drag_end = col 540 | # urwid.emit_signal( 541 | # self, 542 | # "drag", 543 | # self.mouse_drag_source, 544 | # self.mouse_drag_source_column, 545 | # self.mouse_drag_start, self.mouse_drag_end 546 | # ) 547 | # self.mouse_dragging = False 548 | # self.mouse_drag_start = None 549 | # FIXME 550 | elif event == "mouse release": 551 | self.mouse_press = False 552 | if self.mouse_dragging: 553 | # raise Exception(f"drag: {self.mouse_drag_source}") 554 | self.mouse_dragging = False 555 | self.mouse_drag_end = col 556 | # raise Exception(self.mouse_drag_start) 557 | urwid.emit_signal( 558 | self, 559 | "drag", 560 | self.mouse_drag_source, 561 | self.mouse_drag_source_column, 562 | self.mouse_drag_start, self.mouse_drag_end 563 | ) 564 | # raise Exception(self.mouse_drag_source.column.name, self.mouse_drag_start, self.mouse_drag_end) 565 | self.mouse_drag_source = None 566 | self.mouse_drag_start = None 567 | 568 | 569 | 570 | class DataTableFooterRow(DataTableRow): 571 | 572 | ATTR = "table_row_footer" 573 | 574 | DIVIDER_CLASS = DataTableDividerFooterCell 575 | 576 | def make_cells(self): 577 | return [ 578 | DataTableFooterCell( 579 | self.table, 580 | col, 581 | self, 582 | sort=self.sort, 583 | ) 584 | if isinstance(col, DataTableColumn) 585 | else DataTableDividerBodyCell(self.table, col, self) 586 | for i, col in enumerate(self.table.visible_columns)] 587 | 588 | def selectable(self): 589 | return False 590 | -------------------------------------------------------------------------------- /panwid/dialog/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | logger = logging.getLogger(__name__) 3 | 4 | import urwid 5 | import asyncio 6 | 7 | class PopUpMixin(object): 8 | 9 | def open_popup(self, view, title=None, width=75, height=75): 10 | 11 | urwid.connect_signal( 12 | view, "close_popup", self.close_popup 13 | ) 14 | 15 | popup = PopUpFrame(self, view, title=title) 16 | overlay = PopUpOverlay( 17 | self, popup, view, 18 | 'center', ('relative', width), 19 | 'middle', ('relative', height) 20 | ) 21 | self._w.original_widget = overlay 22 | self.popup_visible = True 23 | 24 | def close_popup(self, source): 25 | self._w.original_widget = self.view 26 | self.popup_visible = False 27 | 28 | 29 | class PopUpFrame(urwid.WidgetWrap): 30 | 31 | def __init__(self, parent, body, title = None): 32 | 33 | self.parent = parent 34 | self.line_box = urwid.LineBox(body) 35 | super(PopUpFrame, self).__init__(self.line_box) 36 | 37 | 38 | class PopUpOverlay(urwid.Overlay): 39 | 40 | def __init__(self, parent, *args, **kwargs): 41 | self.parent = parent 42 | super(PopUpOverlay,self).__init__(*args, **kwargs) 43 | 44 | def keypress(self, size, key): 45 | key = super().keypress(size, key) 46 | if key in [ "esc", "q" ]: 47 | self.parent.close_popup() 48 | else: 49 | return key 50 | 51 | class BasePopUp(urwid.WidgetWrap): 52 | 53 | signals = ["close_popup"] 54 | 55 | def selectable(self): 56 | return True 57 | 58 | class ChoiceDialog(BasePopUp): 59 | 60 | choices = [] 61 | signals = ["select"] 62 | 63 | def __init__(self, parent, prompt=None): 64 | self.parent = parent 65 | if prompt: self.prompt = prompt 66 | self.text = urwid.Text( 67 | self.prompt + " [%s]" %("".join(list(self.choices.keys()))), align="center" 68 | ) 69 | super(ChoiceDialog, self).__init__( 70 | urwid.Filler(urwid.Padding(self.text)) 71 | ) 72 | 73 | @property 74 | def choices(self): 75 | raise NotImplementedError 76 | 77 | def keypress(self, size, key): 78 | if key in list(self.choices.keys()): 79 | self.choices[key]() 80 | self._emit("select", key) 81 | else: 82 | return key 83 | 84 | 85 | class SquareButton(urwid.Button): 86 | 87 | button_left = urwid.Text("[") 88 | button_right = urwid.Text("]") 89 | 90 | def pack(self, size, focus=False): 91 | cols = sum( 92 | [ w.pack()[0] for w in [ 93 | self.button_left, 94 | self._label, 95 | self.button_right 96 | ]]) + self._w.dividechars*2 97 | 98 | return ( cols, ) 99 | 100 | class OKCancelDialog(BasePopUp): 101 | 102 | focus = None 103 | 104 | def __init__(self, parent, focus=None, *args, **kwargs): 105 | 106 | self.parent = parent 107 | if focus is not None: 108 | self.focus = focus 109 | 110 | self.ok_button = SquareButton(("bold", "OK")) 111 | 112 | urwid.connect_signal( 113 | self.ok_button, "click", 114 | lambda s: self.confirm() 115 | ) 116 | 117 | self.cancel_button = SquareButton(("bold", "Cancel")) 118 | 119 | urwid.connect_signal( 120 | self.cancel_button, "click", 121 | lambda s: self.cancel() 122 | ) 123 | 124 | 125 | self.body = urwid.Pile([]) 126 | for name, widget in self.widgets.items(): 127 | setattr(self, name, widget) 128 | self.body.contents.append( 129 | (widget, self.body.options("weight", 1)) 130 | ) 131 | 132 | self.pile = urwid.Pile( 133 | [ 134 | ("pack", self.body), 135 | ("weight", 1, urwid.Padding( 136 | urwid.Columns([ 137 | ("weight", 1, 138 | urwid.Padding( 139 | self.ok_button, align="center", width=12) 140 | ), 141 | ("weight", 1, 142 | urwid.Padding( 143 | self.cancel_button, align="center", width=12) 144 | ) 145 | ]), 146 | align="center" 147 | )), 148 | ] 149 | ) 150 | self.body_position = 0 151 | if self.title: 152 | self.pile.contents.insert( 153 | 0, 154 | (urwid.Filler( 155 | urwid.AttrMap( 156 | urwid.Padding( 157 | urwid.Text(self.title) 158 | ), 159 | "header" 160 | ) 161 | ), self.pile.options("given", 2)) 162 | ) 163 | self.body_position += 1 164 | 165 | self.pile.selectable = lambda: True 166 | self.pile.focus_position = self.body_position 167 | if self.focus: 168 | if self.focus == "ok": 169 | self.pile.set_focus_path(self.ok_focus_path) 170 | elif self.focus == "cancel": 171 | self.pile.set_focus_path(self.cancel_focus_path) 172 | elif isinstance(self.focus, int): 173 | return [self.body_position, self.focus] 174 | else: 175 | raise NotImplementedError 176 | 177 | super(OKCancelDialog, self).__init__( 178 | urwid.Filler(self.pile, valign="top") 179 | ) 180 | 181 | @property 182 | def title(self): 183 | return None 184 | 185 | @property 186 | def widgets(self): 187 | raise RuntimeError("must set widgets property") 188 | 189 | def action(self): 190 | raise RuntimeError("must override action method") 191 | 192 | @property 193 | def ok_focus_path(self): 194 | return [self.body_position+1,0] 195 | 196 | @property 197 | def cancel_focus_path(self): 198 | return [self.body_position+1,1] 199 | 200 | @property 201 | def focus_paths(self): 202 | return [ 203 | [self.body_position, i] 204 | for i in range(len(self.body.contents)) 205 | ] + [ 206 | self.ok_focus_path, 207 | self.cancel_focus_path 208 | ] 209 | 210 | def cycle_focus(self, step): 211 | path = self.pile.get_focus_path()[:2] 212 | logger.info(f"{path}, {self.focus_paths}") 213 | self.pile.set_focus_path( 214 | self.focus_paths[ 215 | (self.focus_paths.index(path) + step) % len(self.focus_paths) 216 | ] 217 | ) 218 | 219 | def confirm(self): 220 | rv = self.action() 221 | if asyncio.iscoroutine(rv): 222 | asyncio.get_event_loop().create_task(rv) 223 | 224 | self.close() 225 | 226 | def cancel(self): 227 | self.close() 228 | 229 | def close(self): 230 | self._emit("close_popup") 231 | 232 | def selectable(self): 233 | return True 234 | 235 | def keypress(self, size, key): 236 | if key == "meta enter": 237 | self.confirm() 238 | return 239 | key = super().keypress(size, key) 240 | if key == "enter": 241 | self.confirm() 242 | return 243 | if key in ["tab", "shift tab"]: 244 | self.cycle_focus(1 if key == "tab" else -1) 245 | else: 246 | return key 247 | 248 | 249 | 250 | class ConfirmDialog(ChoiceDialog): 251 | 252 | def __init__(self, parent, *args, **kwargs): 253 | super(ConfirmDialog, self).__init__(parent, *args, **kwargs) 254 | 255 | def action(self, value): 256 | raise RuntimeError("must override action method") 257 | 258 | @property 259 | def prompt(self): 260 | return "Are you sure?" 261 | 262 | def confirm(self): 263 | self.action() 264 | self.close() 265 | 266 | def cancel(self): 267 | self.close() 268 | 269 | def close(self): 270 | self.parent.close_popup() 271 | 272 | @property 273 | def choices(self): 274 | return { 275 | "y": self.confirm, 276 | "n": self.cancel 277 | } 278 | 279 | class BaseView(urwid.WidgetWrap): 280 | 281 | focus_widgets = [] 282 | top_view = None 283 | 284 | def __init__(self, view): 285 | 286 | self.view = view 287 | self.placeholder = urwid.WidgetPlaceholder(urwid.Filler(urwid.Text(""))) 288 | super(BaseView, self).__init__(self.placeholder) 289 | self.placeholder.original_widget = self.view 290 | 291 | def open_popup(self, view, title=None, width=("relative", 75), height=("relative", 75)): 292 | 293 | urwid.connect_signal( 294 | view, "close_popup", self.close_popup 295 | ) 296 | 297 | popup = PopUpFrame(self, view, title=title) 298 | overlay = PopUpOverlay( 299 | self, popup, self.view, 300 | 'center', width, 301 | 'middle', height 302 | ) 303 | self._w.original_widget = overlay 304 | self.popup_visible = True 305 | 306 | def close_popup(self, source=None): 307 | self._w.original_widget = self.view 308 | self.popup_visible = False 309 | 310 | __all__ = [ 311 | "BaseView", 312 | "BasePopUp", 313 | "ChoiceDialog", 314 | "SquareButton" 315 | ] 316 | -------------------------------------------------------------------------------- /panwid/dropdown.py: -------------------------------------------------------------------------------- 1 | import logging 2 | logger = logging.getLogger(__name__) 3 | import os 4 | import random 5 | import string 6 | from functools import wraps 7 | import re 8 | import itertools 9 | 10 | import six 11 | import urwid 12 | from urwid_utils.palette import * 13 | # import urwid_readline 14 | from orderedattrdict import AttrDict 15 | 16 | # from .datatable import * 17 | from .listbox import ScrollingListBox 18 | from .keymap import * 19 | from .highlightable import HighlightableTextMixin 20 | from .autocomplete import AutoCompleteMixin 21 | 22 | class DropdownButton(urwid.Button): 23 | 24 | text_attr = "dropdown_text" 25 | 26 | left_chars = u"" 27 | right_chars = u"" 28 | 29 | 30 | def __init__( 31 | self, label, 32 | text_attr=None, 33 | left_chars=None, right_chars=None 34 | ): 35 | 36 | self.label_text = label 37 | if text_attr: 38 | self.text_attr = text_attr 39 | if left_chars: 40 | self.left_chars = left_chars 41 | if right_chars: 42 | self.right_chars = right_chars 43 | 44 | self.button_left = urwid.Text(self.left_chars) 45 | self.button_right = urwid.Text(self.right_chars) 46 | 47 | self._label = urwid.SelectableIcon("", cursor_position=0) 48 | self.cols = urwid.Columns([ 49 | (len(self.left_chars), self.button_left), 50 | ('weight', 1, self._label), 51 | (len(self.right_chars), self.button_right) 52 | ], dividechars=0) 53 | self.set_label((self.text_attr, self.label_text)) 54 | super(urwid.Button, self).__init__(self.cols) 55 | 56 | @property 57 | def decoration_width(self): 58 | return len(self.left_chars) + len(self.right_chars) 59 | 60 | @property 61 | def width(self): 62 | return self.decoration_width + len(self.label_text) 63 | 64 | 65 | class DropdownItem(HighlightableTextMixin, urwid.WidgetWrap): 66 | 67 | signals = ["click"] 68 | 69 | text_attr = "dropdown_text" 70 | highlight_attr = "dropdown_highlight" 71 | focused_attr = "dropdown_focused" 72 | 73 | def __init__(self, label, value, 74 | margin=0, 75 | text_attr=None, 76 | focused_attr=None, 77 | highlight_attr=None, 78 | left_chars=None, right_chars=None): 79 | 80 | self.label_text = label 81 | self.value = value 82 | self.margin = margin 83 | if text_attr: 84 | self.text_attr = text_attr 85 | if focused_attr: 86 | self.focused_attr = focused_attr 87 | if highlight_attr: 88 | self.highlight_attr = highlight_attr 89 | self.button = DropdownButton( 90 | self.label_text, 91 | text_attr=self.text_attr, 92 | left_chars=left_chars, right_chars=right_chars 93 | ) 94 | 95 | self.padding = urwid.Padding(self.button, width=("relative", 100), 96 | left=self.margin, right=self.margin) 97 | 98 | 99 | self.attr = urwid.AttrMap(self.padding, {None: self.text_attr}) 100 | self.attr.set_focus_map({ 101 | None: self.focused_attr, 102 | self.text_attr: self.focused_attr 103 | }) 104 | super(DropdownItem, self).__init__(self.attr) 105 | urwid.connect_signal( 106 | self.button, 107 | "click", 108 | lambda source: self._emit("click") 109 | ) 110 | 111 | @property 112 | def highlight_source(self): 113 | return self.label_text 114 | 115 | @property 116 | def highlightable_attr_normal(self): 117 | return self.text_attr 118 | 119 | @property 120 | def highlightable_attr_highlight(self): 121 | return self.highlight_attr 122 | 123 | def on_highlight(self): 124 | self.set_text(self.highlight_content) 125 | 126 | def on_unhighlight(self): 127 | self.set_text(self.highlight_source) 128 | 129 | @property 130 | def width(self): 131 | return self.button.width + 2*self.margin 132 | 133 | @property 134 | def decoration_width(self): 135 | return self.button.decoration_width + 2*self.margin 136 | 137 | def __str__(self): 138 | return self.label_text 139 | 140 | def __contains__(self, s): 141 | return s in self.label_text 142 | 143 | def startswith(self, s): 144 | return self.label_text.startswith(s) 145 | 146 | @property 147 | def label(self): 148 | return self.button.label 149 | 150 | def set_text(self, text): 151 | self.button.set_label(text) 152 | 153 | @keymapped() 154 | class DropdownDialog(AutoCompleteMixin, urwid.WidgetWrap, KeymapMovementMixin): 155 | 156 | signals = ["select", "close"] 157 | 158 | text_attr = "dropdown_text" 159 | 160 | min_width = 4 161 | 162 | label = None 163 | border = None 164 | scrollbar = False 165 | margin = 0 166 | max_height = None 167 | 168 | def __init__( 169 | self, 170 | drop_down, 171 | items, 172 | default=None, 173 | label=None, 174 | border=False, 175 | margin = None, 176 | scrollbar=None, 177 | text_attr=None, 178 | focused_attr=None, 179 | prompt_attr=None, 180 | left_chars=None, 181 | right_chars=None, 182 | left_chars_top=None, 183 | rigth_chars_top=None, 184 | max_height=None, 185 | keymap = {}, 186 | **kwargs 187 | ): 188 | 189 | self.drop_down = drop_down 190 | self.items = items 191 | if label is not None: self.label = label 192 | if border is not None: self.border = border 193 | if margin is not None: self.margin = margin 194 | if scrollbar is not None: self.scrollbar = scrollbar 195 | if text_attr: 196 | self.text_attr = text_attr 197 | if focused_attr: 198 | self.focused_attr = focused_attr 199 | if prompt_attr: 200 | self.prompt_attr = prompt_attr 201 | if max_height is not None: self.max_height = max_height 202 | self.selected_button = 0 203 | buttons = [] 204 | 205 | buttons = [ 206 | DropdownItem( 207 | label=l, value=v, margin=self.margin, 208 | text_attr=self.text_attr, 209 | focused_attr=self.focused_attr, 210 | left_chars=left_chars, 211 | right_chars=right_chars, 212 | ) 213 | for l, v in self.items.items() 214 | ] 215 | self.dropdown_buttons = ScrollingListBox( 216 | urwid.SimpleListWalker(buttons), with_scrollbar=scrollbar 217 | ) 218 | 219 | urwid.connect_signal( 220 | self.dropdown_buttons, 221 | 'select', 222 | lambda source, selection: self.on_complete_select(source) 223 | ) 224 | 225 | kwargs = {} 226 | if self.label is not None: 227 | kwargs["title"] = self.label 228 | kwargs["tlcorner"] = u"\N{BOX DRAWINGS LIGHT DOWN AND HORIZONTAL}" 229 | kwargs["trcorner"] = u"\N{BOX DRAWINGS LIGHT DOWN AND LEFT}" 230 | 231 | w = self.dropdown_buttons 232 | if self.border: 233 | w = urwid.LineBox(w, **kwargs) 234 | 235 | self.pile = urwid.Pile([ 236 | ("weight", 1, w), 237 | ]) 238 | super().__init__(self.pile) 239 | 240 | @property 241 | def complete_container(self): 242 | return self.pile 243 | 244 | @property 245 | def complete_container_pos(self): 246 | return 1 247 | 248 | @property 249 | def complete_body(self): 250 | return self.body 251 | 252 | @property 253 | def complete_items(self): 254 | return self.body 255 | 256 | @property 257 | def max_item_width(self): 258 | if not len(self): 259 | return self.min_width 260 | return max(w.width for w in self) 261 | 262 | @property 263 | def width(self): 264 | width = self.max_item_width 265 | if self.border: 266 | width += 2 267 | return width 268 | 269 | @property 270 | def height(self): 271 | height = min(len(self), self.max_height) 272 | if self.border: 273 | height += 2 274 | return height 275 | 276 | @property 277 | def body(self): 278 | return self.dropdown_buttons.body 279 | 280 | def __getitem__(self, i): 281 | return self.body[i] 282 | 283 | def __len__(self): 284 | return len(self.body) 285 | 286 | @property 287 | def focus_position(self): 288 | return self.dropdown_buttons.focus_position 289 | 290 | @focus_position.setter 291 | def focus_position(self, pos): 292 | self.dropdown_buttons.listbox.set_focus_valign("top") 293 | self.dropdown_buttons.focus_position = pos 294 | 295 | @property 296 | def selection(self): 297 | return self.dropdown_buttons.selection 298 | 299 | # def on_complete_select(self, pos, widget): 300 | 301 | # # logger.debug("select_button: %s" %(button)) 302 | # label = widget.label 303 | # value = widget.value 304 | # self.selected_button = self.focus_position 305 | # self.complete_off() 306 | # self._emit("select", widget) 307 | # self._emit("close") 308 | 309 | # def keypress(self, size, key): 310 | # return super(DropdownDialog, self).keypress(size, key) 311 | 312 | 313 | @property 314 | def selected_value(self): 315 | if not self.focus_position: 316 | return None 317 | return self.body[self.focus_position].value 318 | 319 | @keymapped() 320 | class Dropdown(urwid.PopUpLauncher): 321 | # Based in part on SelectOne widget from 322 | # https://github.com/tuffy/python-audio-tools 323 | 324 | signals = ["change"] 325 | 326 | text_attr = "dropdown_text" 327 | label_attr = "dropdown_label" 328 | focused_attr = "dropdown_focused" 329 | highlight_attr = "dropdown_highlight" 330 | prompt_attr = "dropdown_prompt" 331 | 332 | auto_complete = None 333 | label = None 334 | empty_label = u"\N{EMPTY SET}" 335 | expanded = False 336 | margin = 0 337 | 338 | def __init__( 339 | self, 340 | items=None, 341 | label=None, 342 | default=None, 343 | expanded=None, 344 | border=False, scrollbar=False, 345 | margin=None, 346 | text_attr=None, 347 | label_attr=None, 348 | focused_attr=None, 349 | highlight_attr=None, 350 | prompt_attr=None, 351 | left_chars=None, right_chars=None, 352 | left_chars_top=None, right_chars_top=None, 353 | auto_complete=None, 354 | max_height=10, 355 | # keymap = {} 356 | ): 357 | 358 | if items is not None: 359 | self._items = items 360 | if label is not None: 361 | self.label = label 362 | if expanded is not None: 363 | self.expanded = expanded 364 | self.default = default 365 | 366 | self.border = border 367 | self.scrollbar = scrollbar 368 | if auto_complete is not None: self.auto_complete = auto_complete 369 | 370 | # self.keymap = keymap 371 | 372 | if margin: 373 | self.margin = margin 374 | 375 | if text_attr: 376 | self.text_attr = text_attr 377 | if label_attr: 378 | self.label_attr = label_attr 379 | if focused_attr: 380 | self.focused_attr = focused_attr 381 | if highlight_attr: 382 | self.highlight_attr = highlight_attr 383 | if prompt_attr: 384 | self.prompt_attr = prompt_attr 385 | 386 | if isinstance(self.items, list): 387 | if len(self.items): 388 | self._items = AttrDict( 389 | item if isinstance(item, tuple) else (item, n) 390 | for n, item in enumerate(self.items) 391 | ) 392 | else: 393 | self._items = AttrDict() 394 | else: 395 | self._items = self.items 396 | 397 | 398 | self.button = DropdownItem( 399 | u"", None, 400 | margin=self.margin, 401 | text_attr=self.text_attr, 402 | highlight_attr=self.highlight_attr, 403 | focused_attr=self.focused_attr, 404 | left_chars = left_chars_top if left_chars_top else left_chars, 405 | right_chars = right_chars_top if right_chars_top else right_chars 406 | ) 407 | 408 | self.pop_up = DropdownDialog( 409 | self, 410 | self._items, 411 | self.default, 412 | label=self.label, 413 | border=self.border, 414 | margin=self.margin, 415 | text_attr=self.text_attr, 416 | focused_attr=self.focused_attr, 417 | prompt_attr=self.prompt_attr, 418 | left_chars=left_chars, 419 | right_chars=right_chars, 420 | auto_complete=self.auto_complete, 421 | scrollbar=scrollbar, 422 | max_height=max_height, 423 | # keymap=self.KEYMAP 424 | ) 425 | 426 | urwid.connect_signal( 427 | self.pop_up, 428 | "select", 429 | lambda souce, pos, selection: self.select(selection) 430 | ) 431 | 432 | urwid.connect_signal( 433 | self.pop_up, 434 | "close", 435 | lambda source: self.close_pop_up() 436 | ) 437 | 438 | if self.default is not None: 439 | try: 440 | if isinstance(self.default, str): 441 | try: 442 | self.select_label(self.default) 443 | except ValueError: 444 | pass 445 | else: 446 | raise StopIteration 447 | except StopIteration: 448 | try: 449 | self.select_value(self.default) 450 | except ValueError: 451 | self.focus_position = 0 452 | 453 | if len(self): 454 | self.select(self.selection) 455 | else: 456 | self.button.set_text((self.text_attr, self.empty_label)) 457 | 458 | cols = [ (self.button_width, self.button) ] 459 | 460 | if self.label: 461 | cols[0:0] = [ 462 | ("pack", urwid.Text([(self.label_attr, "%s: " %(self.label))])), 463 | ] 464 | self.columns = urwid.Columns(cols, dividechars=0) 465 | 466 | w = self.columns 467 | if self.border: 468 | w = urwid.LineBox(self.columns) 469 | w = urwid.Padding(w, width=self.width) 470 | 471 | super(Dropdown, self).__init__(w) 472 | urwid.connect_signal( 473 | self.button, 474 | 'click', 475 | lambda button: self.open_pop_up() 476 | ) 477 | if self.expanded: 478 | self.open_pop_up() 479 | 480 | @classmethod 481 | def get_palette_entries(cls): 482 | return { 483 | "dropdown_text": PaletteEntry( 484 | foreground="light gray", 485 | background="dark blue", 486 | foreground_high="light gray", 487 | background_high="#003", 488 | ), 489 | "dropdown_focused": PaletteEntry( 490 | foreground="white", 491 | background="light blue", 492 | foreground_high="white", 493 | background_high="#009", 494 | ), 495 | "dropdown_highlight": PaletteEntry( 496 | foreground="yellow", 497 | background="light blue", 498 | foreground_high="yellow", 499 | background_high="#009", 500 | ), 501 | "dropdown_label": PaletteEntry( 502 | foreground="white", 503 | background="black" 504 | ), 505 | "dropdown_prompt": PaletteEntry( 506 | foreground="light blue", 507 | background="black" 508 | ) 509 | } 510 | 511 | 512 | @keymap_command() 513 | def complete_prefix(self): 514 | if not self.auto_complete: 515 | return 516 | self.open_pop_up() 517 | self.pop_up.complete_prefix() 518 | 519 | @keymap_command() 520 | def complete_substring(self): 521 | if not self.auto_complete: 522 | return 523 | self.open_pop_up() 524 | self.pop_up.complete_substring() 525 | 526 | def create_pop_up(self): 527 | # print("create") 528 | return self.pop_up 529 | 530 | @property 531 | def button_width(self): 532 | return self.pop_up.max_item_width + self.button.decoration_width 533 | 534 | @property 535 | def pop_up_width(self): 536 | w = self.button_width 537 | if self.border: 538 | w += 2 539 | return w 540 | 541 | @property 542 | def contents_width(self): 543 | # raise Exception(self.button.width) 544 | w = self.button_width 545 | if self.label: 546 | w += len(self.label) + 2 547 | return max(self.pop_up.width, w) 548 | 549 | @property 550 | def width(self): 551 | width = max(self.contents_width, self.pop_up.width) 552 | if self.border: 553 | width += 2 554 | return width 555 | 556 | @property 557 | def height(self): 558 | height = self.pop_up.height + 1 559 | return height 560 | 561 | def pack(self, size, focus=False): 562 | return (self.width, self.height) 563 | 564 | @property 565 | def page_size(self): 566 | return self.pop_up.height 567 | 568 | def open_pop_up(self): 569 | # print("open") 570 | super(Dropdown, self).open_pop_up() 571 | 572 | def close_pop_up(self): 573 | super().close_pop_up() 574 | 575 | def get_pop_up_parameters(self): 576 | return {'left': (len(self.label) + 2 if self.label else 0), 577 | 'top': 0, 578 | 'overlay_width': self.pop_up_width, 579 | 'overlay_height': self.pop_up.height 580 | } 581 | 582 | @property 583 | def focus_position(self): 584 | return self.pop_up.focus_position 585 | 586 | @focus_position.setter 587 | def focus_position(self, pos): 588 | if pos == self.focus_position: 589 | return 590 | # self.select_index(pos) 591 | old_pos = self.focus_position 592 | self.pop_up.selected_button = self.pop_up.focus_position = pos 593 | self.select(self.selection) 594 | 595 | @property 596 | def items(self): 597 | return self._items 598 | 599 | @property 600 | def selection(self): 601 | return self.pop_up.selection 602 | 603 | @property 604 | def items(self): 605 | return self._items 606 | 607 | @property 608 | def selection(self): 609 | return self.pop_up.selection 610 | 611 | def select_label(self, label, case_sensitive=False): 612 | 613 | old_value = self.value 614 | 615 | f = lambda x: x 616 | if not case_sensitive: 617 | f = lambda x: x.lower() if isinstance(x, str) else x 618 | 619 | try: 620 | index = next(itertools.dropwhile( 621 | lambda x: f(x[1]) != f(label), 622 | enumerate((self._items.keys()) 623 | ) 624 | ))[0] 625 | except (StopIteration, IndexError): 626 | raise ValueError 627 | self.focus_position = index 628 | 629 | def select_value(self, value): 630 | 631 | try: 632 | index = next( 633 | itertools.dropwhile( 634 | lambda x: x[1] != value, 635 | enumerate((self._items.values())) 636 | ) 637 | )[0] 638 | except (StopIteration, IndexError): 639 | raise ValueError 640 | self.focus_position = index 641 | 642 | 643 | @property 644 | def labels(self): 645 | return self._items.keys() 646 | 647 | @property 648 | def values(self): 649 | return self._items.values() 650 | 651 | @property 652 | def selected_label(self): 653 | return self.selection.label 654 | 655 | @selected_label.setter 656 | def selected_label(self, label): 657 | return self.select_label(label) 658 | 659 | @property 660 | def selected_value(self): 661 | if not self.selection: 662 | return None 663 | return self.selection.value 664 | 665 | @selected_value.setter 666 | def selected_value(self, value): 667 | return self.select_value(value) 668 | 669 | @property 670 | def value(self): 671 | return self.selected_value 672 | 673 | @value.setter 674 | def value(self, value): 675 | old_value = self.value 676 | 677 | # try to set by value. if not found, try to set by label 678 | try: 679 | self.selected_value = value 680 | except StopIteration: 681 | self.selected_label = value 682 | 683 | def cycle_prev(self): 684 | self.cycle(-1) 685 | 686 | def action(self): 687 | pass 688 | 689 | @keymap_command("cycle") 690 | def cycle(self, n): 691 | pos = self.focus_position + n 692 | if pos > len(self) - 1: 693 | pos = len(self) - 1 694 | elif pos < 0: 695 | pos = 0 696 | # self.focus_position = pos 697 | self.focus_position = pos 698 | 699 | def select(self, button): 700 | logger.debug("select: %s" %(button)) 701 | self.button.set_text((self.text_attr, button.label)) 702 | self.pop_up.dropdown_buttons.listbox.set_focus_valign("top") 703 | # if old_pos != pos: 704 | self.action() 705 | self._emit("change", self.selected_label, self.selected_value) 706 | 707 | # def set_items(self, items, selected_value): 708 | # self._items = items 709 | # self.make_selection([label for (label, value) in items if 710 | # value is selected_value][0], 711 | # selected_value) 712 | def __len__(self): 713 | return len(self.items) 714 | 715 | __all__ = ["Dropdown"] 716 | -------------------------------------------------------------------------------- /panwid/highlightable.py: -------------------------------------------------------------------------------- 1 | import logging 2 | logger = logging.getLogger(__name__) 3 | 4 | class HighlightableTextMixin(object): 5 | 6 | @property 7 | def highlight_state(self): 8 | if not getattr(self, "_highlight_state", False): 9 | self._highlight_state = False 10 | self._highlight_case_sensitive = False 11 | self._highlight_string = None 12 | return self._highlight_state 13 | 14 | @property 15 | def highlight_content(self): 16 | if self.highlight_state: 17 | return self.get_highlight_text() 18 | else: 19 | return self.highlight_source 20 | 21 | 22 | def highlight(self, start, end): 23 | self._highlight_state = True 24 | self._highlight_location = (start, end) 25 | self.on_highlight() 26 | 27 | def unhighlight(self): 28 | self._highlight_state = False 29 | self._highlight_location = None 30 | self.on_unhighlight() 31 | 32 | def get_highlight_text(self): 33 | 34 | if not self._highlight_location: 35 | return None 36 | 37 | return [ 38 | (self.highlightable_attr_normal, self.highlight_source[:self._highlight_location[0]]), 39 | (self.highlightable_attr_highlight, self.highlight_source[self._highlight_location[0]:self._highlight_location[1]]), 40 | (self.highlightable_attr_normal, self.highlight_source[self._highlight_location[1]:]), 41 | ] 42 | 43 | @property 44 | def highlight_source(self): 45 | raise NotImplementedError 46 | 47 | @property 48 | def highlightable_attr_normal(self): 49 | raise NotImplementedError 50 | 51 | @property 52 | def highlightable_attr_highlight(self): 53 | raise NotImplementedError 54 | 55 | def on_highlight(self): 56 | pass 57 | 58 | def on_unhighlight(self): 59 | pass 60 | 61 | __all__ = ["HighlightableTextMixin"] 62 | -------------------------------------------------------------------------------- /panwid/keymap.py: -------------------------------------------------------------------------------- 1 | # Mixin class for mapping keyboard input to widget methods. 2 | 3 | import logging 4 | logger = logging.getLogger(__name__) 5 | 6 | import six 7 | import asyncio 8 | import urwid 9 | import re 10 | 11 | KEYMAP_GLOBAL = {} 12 | 13 | _camel_snake_re_1 = re.compile(r'(.)([A-Z][a-z]+)') 14 | _camel_snake_re_2 = re.compile('([a-z0-9])([A-Z])') 15 | 16 | def camel_to_snake(s): 17 | s = _camel_snake_re_1.sub(r'\1_\2', s) 18 | return _camel_snake_re_2.sub(r'\1_\2', s).lower() 19 | 20 | 21 | def optional_arg_decorator(fn): 22 | def wrapped_decorator(*args, **kwargs): 23 | if len(args) == 1 and len(kwargs) == 0 and callable(args[0]): 24 | return fn(args[0]) 25 | else: 26 | def real_decorator(decoratee): 27 | return fn(decoratee, *args, **kwargs) 28 | return real_decorator 29 | return wrapped_decorator 30 | 31 | 32 | @optional_arg_decorator 33 | def keymap_command(f, command=None, *args, **kwargs): 34 | f._keymap = True 35 | f._keymap_command = command 36 | f._keymap_args = args 37 | f._keymap_kwargs = kwargs 38 | return f 39 | 40 | 41 | def keymapped(): 42 | 43 | def wrapper(cls): 44 | 45 | cls.KEYMAP_MERGED = {} 46 | 47 | if not hasattr(cls, "KEYMAP_SCOPE"): 48 | cls.KEYMAP_SCOPE = classmethod(lambda cls: camel_to_snake(cls.__name__)) 49 | elif isinstance(cls.KEYMAP_SCOPE, str): 50 | cls.KEYMAP_SCOPE = classmethod(lambda cls: cls.KEYMAP_SCOPE) 51 | 52 | if not cls.KEYMAP_SCOPE() in cls.KEYMAP_MERGED: 53 | cls.KEYMAP_MERGED[cls.KEYMAP_SCOPE()] = {} 54 | if getattr(cls, "KEYMAP", False): 55 | cls.KEYMAP_MERGED[cls.KEYMAP_SCOPE()].update(**cls.KEYMAP) 56 | 57 | 58 | for base in cls.mro(): 59 | if hasattr(base, "KEYMAP"): 60 | if not base.KEYMAP_SCOPE() in cls.KEYMAP_MERGED: 61 | cls.KEYMAP_MERGED[base.KEYMAP_SCOPE()] = {} 62 | cls.KEYMAP_MERGED[base.KEYMAP_SCOPE()].update(**base.KEYMAP) 63 | 64 | # from pprint import pprint; print(cls.KEYMAP_MERGED) 65 | if not hasattr(cls, "KEYMAP_MAPPING"): 66 | cls.KEYMAP_MAPPING = {} 67 | 68 | cls.KEYMAP_MAPPING.update(**getattr(cls.__base__, "KEYMAP_MAPPING", {})) 69 | 70 | cls.KEYMAP_MAPPING.update({ 71 | (getattr(getattr(cls, k), "_keymap_command", k) or k).replace(" ", "_"): k 72 | for k in cls.__dict__.keys() 73 | if hasattr(getattr(cls, k), '_keymap') 74 | }) 75 | 76 | def keymap_command(self, cmd): 77 | logger.debug(f"keymap_command: {cmd}") 78 | args = [] 79 | kwargs = {} 80 | 81 | if callable(cmd): 82 | f = cmd 83 | else: 84 | if isinstance(cmd, tuple): 85 | if len(cmd) == 3: 86 | (cmd, args, kwargs) = cmd 87 | elif len(cmd) == 2: 88 | if isinstance(cmd[1], dict): 89 | (cmd, kwargs) = cmd 90 | else: 91 | (cmd, args) = cmd 92 | else: 93 | raise Exception 94 | elif isinstance(cmd, str): 95 | cmd = cmd.replace(" ", "_") 96 | else: 97 | logger.debug(f"keymap command {cmd} not valid") 98 | return None 99 | 100 | if hasattr(self, cmd): 101 | fn_name = cmd 102 | else: 103 | try: 104 | fn_name = self.KEYMAP_MAPPING[cmd] 105 | except KeyError: 106 | raise KeyError(cmd, self.KEYMAP_MAPPING, type(self)) 107 | 108 | f = getattr(self, fn_name) 109 | 110 | ret = f(*args, **kwargs) 111 | if asyncio.iscoroutine(ret): 112 | asyncio.get_event_loop().create_task(ret) 113 | return None 114 | 115 | cls._keymap_command = keymap_command 116 | 117 | def keymap_register(self, key, cmd): 118 | self.KEYMAP_MERGED[cls.KEYMAP_SCOPE()][key] = cmd 119 | 120 | cls.keymap_register = keymap_register 121 | 122 | def keypress_decorator(func): 123 | 124 | 125 | def keypress(self, size, key): 126 | # logger.debug(f"{cls} wrapped keypress: {key}, {cls.KEYMAP_SCOPE()}, {self.KEYMAP_MERGED.get(cls.KEYMAP_SCOPE(), {}).keys()}") 127 | 128 | if key and callable(func): 129 | # logger.debug(f"{cls} wrapped keypress, key: {key}, calling orig: {func}") 130 | key = func(self, size, key) 131 | if key: 132 | # logger.debug(f"{cls} wrapped keypress, key: {key}, calling super: {super(cls, self).keypress}") 133 | key = super(cls, self).keypress(size, key) 134 | keymap_combined = dict(self.KEYMAP_MERGED, **KEYMAP_GLOBAL) 135 | if key and keymap_combined.get(cls.KEYMAP_SCOPE(), {}).get(key, None): 136 | cmd = keymap_combined[cls.KEYMAP_SCOPE()][key] 137 | if isinstance(cmd, str) and cmd.startswith("keypress "): 138 | new_key = cmd.replace("keypress ", "").strip() 139 | # logger.debug(f"{cls} remap {key} => {new_key}") 140 | key = new_key 141 | else: 142 | # logger.debug(f"{cls} wrapped keypress, key: {key}, calling keymap command") 143 | key = self._keymap_command(cmd) 144 | return key 145 | 146 | return keypress 147 | 148 | cls.keypress = keypress_decorator(getattr(cls, "keypress", None)) 149 | return cls 150 | 151 | return wrapper 152 | 153 | 154 | 155 | 156 | @keymapped() 157 | class KeymapMovementMixin(object): 158 | 159 | @classmethod 160 | def KEYMAP_SCOPE(cls): 161 | return "movement" 162 | 163 | def cycle_position(self, n): 164 | 165 | if len(self): 166 | pos = self.focus_position + n 167 | if pos > len(self) - 1: 168 | pos = len(self) - 1 169 | elif pos < 0: 170 | pos = 0 171 | self.focus_position = pos 172 | 173 | @keymap_command("up") 174 | def keymap_up(self): self.cycle_position(-1) 175 | 176 | @keymap_command("down") 177 | def keymap_down(self): self.cycle_position(1) 178 | 179 | @keymap_command("page up") 180 | def keymap_page_up(self): self.cycle_position(-self.page_size) 181 | 182 | @keymap_command("page down") 183 | def keymap_page_down(self): self.cycle_position(self.page_size) 184 | 185 | @keymap_command("home") 186 | def keymap_home(self): self.focus_position = 0 187 | 188 | @keymap_command("end") 189 | def keymap_end(self): self.focus_position = len(self)-1 190 | 191 | __all__ = [ 192 | "keymapped", 193 | "keymap_command", 194 | "KeymapMovementMixin" 195 | ] 196 | -------------------------------------------------------------------------------- /panwid/listbox.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | import logging 3 | logger = logging.getLogger(__name__.split(".")[0]) 4 | 5 | import urwid 6 | from urwid_utils.palette import * 7 | from .scroll import ScrollBar 8 | 9 | class ListBoxScrollBar(urwid.WidgetWrap): 10 | 11 | def __init__(self, parent): 12 | self.parent = parent 13 | self.pile = urwid.Pile([]) 14 | super(ListBoxScrollBar, self).__init__(self.pile) 15 | 16 | def update(self, size): 17 | width, height = size 18 | scroll_marker_height = 1 19 | del self.pile.contents[:] 20 | if (len(self.parent.body) 21 | and self.parent.row_count 22 | and self.parent.focus is not None 23 | and self.parent.row_count > height): 24 | scroll_position = int( 25 | self.parent.focus_position / self.parent.row_count * height 26 | ) 27 | scroll_marker_height = max( height * (height / self.parent.row_count ), 1) 28 | else: 29 | scroll_position = 0 30 | 31 | pos_marker = urwid.AttrMap(urwid.Text(" "), 32 | {None: "scroll_pos"} 33 | ) 34 | 35 | down_marker = urwid.AttrMap(urwid.Text(u"\N{DOWNWARDS ARROW}"), 36 | {None: "scroll_marker"} 37 | ) 38 | 39 | begin_marker = urwid.AttrMap(urwid.Text(u"\N{CIRCLED MINUS}"), 40 | {None: "scroll_marker"} 41 | ) 42 | 43 | end_marker = urwid.AttrMap(urwid.Text(u"\N{CIRCLED PLUS}"), 44 | {None: "scroll_marker"} 45 | ) 46 | 47 | view_marker = urwid.AttrMap(urwid.Text(" "), 48 | {None: "scroll_view"} 49 | ) 50 | 51 | bg_marker = urwid.AttrMap(urwid.Text(" "), 52 | {None: "scroll_bg"} 53 | ) 54 | 55 | for i in range(height): 56 | if abs( i - scroll_position ) <= scroll_marker_height//2: 57 | if i == 0 and self.parent.focus_position == 0: 58 | marker = begin_marker 59 | elif i+1 == height and self.parent.row_count == self.parent.focus_position+1: 60 | marker = end_marker 61 | 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: 62 | marker = down_marker 63 | else: 64 | marker = pos_marker 65 | else: 66 | if i < scroll_position: 67 | marker = view_marker 68 | elif self.parent.row_count and i/height < ( len(self.parent.body) / self.parent.row_count): 69 | marker = view_marker 70 | else: 71 | marker = bg_marker 72 | self.pile.contents.append( 73 | (urwid.Filler(marker), self.pile.options("weight", 1)) 74 | ) 75 | self._invalidate() 76 | 77 | def selectable(self): 78 | # FIXME: mouse click/drag 79 | return False 80 | 81 | class ScrollingListBox(urwid.WidgetWrap): 82 | 83 | signals = ["select", 84 | "drag_start", "drag_continue", "drag_stop", 85 | "load_more"] 86 | 87 | scrollbar_class = ScrollBar 88 | 89 | def __init__(self, body, 90 | infinite = False, 91 | with_scrollbar=False, 92 | row_count_fn = None, 93 | thumb_char=None, 94 | trough_char=None, 95 | thumb_indicator_top=None, 96 | thumb_indicator_bottom=None): 97 | 98 | self.infinite = infinite 99 | self.with_scrollbar = with_scrollbar 100 | self.row_count_fn = row_count_fn 101 | 102 | self._width = None 103 | self._height = 0 104 | self._rows_max = None 105 | 106 | self.mouse_state = 0 107 | self.drag_from = None 108 | self.drag_last = None 109 | self.drag_to = None 110 | self.load_more = False 111 | self.page = 0 112 | 113 | self.queued_keypress = None 114 | w = self.listbox = urwid.ListBox(body) 115 | 116 | self.columns = urwid.Columns([ 117 | ('weight', 1, self.listbox) 118 | ]) 119 | if self.with_scrollbar: 120 | self.scroll_bar = ListBoxScrollBar(self) 121 | self.columns.contents.append( 122 | (self.scroll_bar, self.columns.options("given", 1)) 123 | ) 124 | super(ScrollingListBox, self).__init__(self.columns) 125 | urwid.connect_signal(self.body, "modified", self.on_modified) 126 | 127 | def on_modified(self): 128 | if self.with_scrollbar and len(self.body): 129 | self.scroll_bar.update(self.size) 130 | 131 | def rows_max(self, size, focus=False): 132 | return urwid.ListBox.rows_max(self, size, focus) 133 | 134 | 135 | @classmethod 136 | def get_palette_entries(cls): 137 | 138 | return { 139 | 140 | "scroll_pos": PaletteEntry( 141 | mono = "white", 142 | foreground = "black", 143 | background = "white", 144 | foreground_high = "black", 145 | background_high = "white" 146 | ), 147 | "scroll_marker": PaletteEntry( 148 | mono = "white,bold", 149 | foreground = "black,bold", 150 | background = "white", 151 | foreground_high = "black,bold", 152 | background_high = "white" 153 | ), 154 | "scroll_view": PaletteEntry( 155 | mono = "black", 156 | foreground = "black", 157 | background = "light gray", 158 | foreground_high = "black", 159 | background_high = "g50" 160 | ), 161 | "scroll_bg": PaletteEntry( 162 | mono = "black", 163 | foreground = "light gray", 164 | background = "dark gray", 165 | foreground_high = "light gray", 166 | background_high = "g23" 167 | ), 168 | 169 | } 170 | 171 | def mouse_event(self, size, event, button, col, row, focus): 172 | 173 | SCROLL_WHEEL_HEIGHT_RATIO = 0.5 174 | if row < 0 or row >= self._height or not len(self.listbox.body): 175 | return 176 | if event == 'mouse press': 177 | if button == 1: 178 | self.mouse_state = 1 179 | self.drag_from = self.drag_last = (col, row) 180 | elif button == 4: 181 | pos = self.listbox.focus_position - int(self._height * SCROLL_WHEEL_HEIGHT_RATIO) 182 | if pos < 0: 183 | pos = 0 184 | self.listbox.focus_position = pos 185 | self.listbox.make_cursor_visible(size) 186 | self._invalidate() 187 | elif button == 5: 188 | pos = self.listbox.focus_position + int(self._height * SCROLL_WHEEL_HEIGHT_RATIO) 189 | if pos > len(self.listbox.body) - 1: 190 | if self.infinite: 191 | self.load_more = True 192 | pos = len(self.listbox.body) - 1 193 | self.listbox.focus_position = pos 194 | self.listbox.make_cursor_visible(size) 195 | self._invalidate() 196 | elif event == 'mouse drag': 197 | if self.drag_from is None: 198 | return 199 | if button == 1: 200 | self.drag_to = (col, row) 201 | if self.mouse_state == 1: 202 | self.mouse_state = 2 203 | urwid.signals.emit_signal( 204 | self, "drag_start",self, self.drag_from 205 | ) 206 | else: 207 | urwid.signals.emit_signal( 208 | self, "drag_continue",self, 209 | self.drag_last, self.drag_to 210 | ) 211 | 212 | self.drag_last = (col, row) 213 | 214 | elif event == 'mouse release': 215 | if self.mouse_state == 2: 216 | self.drag_to = (col, row) 217 | urwid.signals.emit_signal( 218 | self, "drag_stop",self, self.drag_from, self.drag_to 219 | ) 220 | self.mouse_state = 0 221 | return super(ScrollingListBox, self).mouse_event(size, event, button, col, row, focus) 222 | 223 | 224 | def keypress(self, size, key): 225 | 226 | command = self._command_map[key] 227 | if not command: 228 | return super(ScrollingListBox, self).keypress(size, key) 229 | 230 | # down, page down at end trigger load of more data 231 | if ( 232 | command in ["cursor down", "cursor page down"] 233 | and self.infinite 234 | and ( 235 | not len(self.body) 236 | or self.focus_position == len(self.body)-1) 237 | ): 238 | self.load_more = True 239 | self.queued_keypress = key 240 | self._invalidate() 241 | 242 | elif command == "activate": 243 | urwid.signals.emit_signal(self, "select", self, self.selection) 244 | 245 | # else: 246 | return super(ScrollingListBox, self).keypress(size, key) 247 | 248 | @property 249 | def selection(self): 250 | 251 | if len(self.body): 252 | return self.body[self.focus_position] 253 | 254 | @property 255 | def size(self): 256 | return (self._width, self._height) 257 | 258 | def render(self, size, focus=False): 259 | 260 | maxcol = size[0] 261 | self._width = maxcol 262 | if len(size) > 1: 263 | maxrow = size[1] 264 | modified = self._height == 0 265 | self._height = maxrow 266 | if modified: 267 | self.on_modified() 268 | else: 269 | self._height = 0 270 | 271 | # print 272 | # print 273 | # print self.listbox.get_focus_offset_inset(size) 274 | if (self.load_more 275 | and (len(self.body) == 0 276 | or "bottom" in self.ends_visible((maxcol, maxrow)) 277 | ) 278 | ): 279 | 280 | self.load_more = False 281 | self.page += 1 282 | # old_len = len(self.body) 283 | try: 284 | focus = self.focus_position 285 | except IndexError: 286 | focus = None 287 | urwid.signals.emit_signal( 288 | self, "load_more", focus) 289 | if (self.queued_keypress 290 | and focus is not None 291 | # and focus < len(self.body)-1 292 | ): 293 | # logger.info(f"send queued keypress: {focus}, {len(self.body)}") 294 | self.keypress(size, self.queued_keypress) 295 | self.queued_keypress = None 296 | # self.listbox._invalidate() 297 | # self._invalidate() 298 | 299 | return super(ScrollingListBox, self).render(size, focus) 300 | 301 | 302 | def disable(self): 303 | self.selectable = lambda: False 304 | 305 | def enable(self): 306 | self.selectable = lambda: True 307 | 308 | @property 309 | def contents(self): 310 | return self.columns.contents 311 | 312 | @property 313 | def focus(self): 314 | return self.listbox.focus 315 | 316 | @property 317 | def focus_position(self): 318 | if not len(self.listbox.body): 319 | raise IndexError 320 | try: 321 | return self.listbox.focus_position 322 | except IndexError: 323 | pass 324 | return None 325 | 326 | @focus_position.setter 327 | def focus_position(self, value): 328 | if not len(self.body): 329 | return 330 | self.listbox.focus_position = value 331 | self.listbox._invalidate() 332 | 333 | def __getattr__(self, attr): 334 | if attr in ["ends_visible", "focus_position", "set_focus", "set_focus_valign", "body", "focus"]: 335 | return getattr(self.listbox, attr) 336 | # elif attr == "body": 337 | # return self.walker 338 | raise AttributeError(attr) 339 | 340 | @property 341 | def row_count(self): 342 | if self.row_count_fn: 343 | return self.row_count_fn() 344 | return len(self.body) 345 | 346 | __all__ = ["ScrollingListBox"] 347 | -------------------------------------------------------------------------------- /panwid/progressbar.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import urwid 4 | 5 | from .sparkwidgets import * 6 | 7 | class ProgressBar(urwid.WidgetWrap): 8 | 9 | def __init__(self, width, maximum, value=0, 10 | progress_color=None, remaining_color=None): 11 | self.width = width 12 | self.maximum = maximum 13 | self.value = value 14 | self.progress_color = progress_color or DEFAULT_BAR_COLOR 15 | self.remaining_color = remaining_color or DEFAULT_LABEL_COLOR 16 | self.placeholder = urwid.WidgetPlaceholder(urwid.Text("")) 17 | self.update() 18 | super().__init__(self.placeholder) 19 | 20 | def pack(self, size, focus=False): 21 | return (self.width, 1) 22 | 23 | @property 24 | def value_label(self): 25 | label_text = str(self.value) 26 | bar_len = self.spark_bar.bar_width(0) 27 | attr1 = f"{DEFAULT_LABEL_COLOR}:{self.progress_color}" 28 | content = [(attr1, label_text[:bar_len])] 29 | if len(label_text) > bar_len-1: 30 | attr2 = f"{DEFAULT_LABEL_COLOR}:{self.remaining_color}" 31 | content.append((attr2, label_text[bar_len:])) 32 | return urwid.Text(content) 33 | 34 | @property 35 | def maximum_label(self): 36 | label_text = str(self.maximum) 37 | bar_len = self.spark_bar.bar_width(1) 38 | attr1 = f"{DEFAULT_LABEL_COLOR}:{self.remaining_color}" 39 | content = [] 40 | if bar_len: 41 | content.append((attr1, label_text[-bar_len:])) 42 | if len(label_text) > bar_len: 43 | attr2 = f"{DEFAULT_LABEL_COLOR}:{self.progress_color}" 44 | content.insert(0, (attr2, label_text[:-bar_len or None])) 45 | return urwid.Text(content) 46 | 47 | def update(self): 48 | value_label = None 49 | maximum_label = None 50 | 51 | self.spark_bar = SparkBarWidget( 52 | [ 53 | SparkBarItem(self.value, bcolor=self.progress_color), 54 | SparkBarItem(self.maximum-self.value, bcolor=self.remaining_color), 55 | ], width=self.width 56 | ) 57 | overlay1 = urwid.Overlay( 58 | urwid.Filler(self.value_label), 59 | urwid.Filler(self.spark_bar), 60 | "left", 61 | len(self.value_label.get_text()[0]), 62 | "top", 63 | 1 64 | ) 65 | label_len = len(self.maximum_label.get_text()[0]) 66 | overlay2 = urwid.Overlay( 67 | urwid.Filler(self.maximum_label), 68 | overlay1, 69 | "left", 70 | label_len, 71 | "top", 72 | 1, 73 | left=self.width - label_len 74 | ) 75 | self.placeholder.original_widget = urwid.BoxAdapter(overlay2, 1) 76 | 77 | def set_value(self, value): 78 | self.value = value 79 | self.update() 80 | 81 | @property 82 | def items(self): 83 | return self.spark_bar.items 84 | 85 | __all__ = ["ProgressBar"] 86 | -------------------------------------------------------------------------------- /panwid/sparkwidgets.py: -------------------------------------------------------------------------------- 1 | """ 2 | ```sparkwidgets``` 3 | ======================== 4 | 5 | A set of sparkline-ish widgets for urwid 6 | 7 | This module contains a set of urwid text-like widgets for creating tiny but 8 | hopefully useful sparkline-like visualizations of data. 9 | """ 10 | 11 | import urwid 12 | from urwid_utils.palette import * 13 | from collections import deque 14 | import math 15 | import operator 16 | import itertools 17 | import collections 18 | from dataclasses import dataclass 19 | 20 | BLOCK_VERTICAL = [ chr(x) for x in range(0x2581, 0x2589) ] 21 | BLOCK_HORIZONTAL = [" "] + [ chr(x) for x in range(0x258F, 0x2587, -1) ] 22 | 23 | DEFAULT_LABEL_COLOR = "black" 24 | DEFAULT_LABEL_COLOR_DARK = "black" 25 | DEFAULT_LABEL_COLOR_LIGHT = "white" 26 | 27 | DEFAULT_BAR_COLOR = "white" 28 | 29 | DISTINCT_COLORS_16 = urwid.display_common._BASIC_COLORS[1:] 30 | 31 | DISTINCT_COLORS_256 = [ 32 | '#f00', '#080', '#00f', '#d6f', '#0ad', '#f80', '#8f0', '#666', 33 | '#f88', '#808', '#0fd', '#66f', '#aa8', '#060', '#faf', '#860', 34 | '#60a', '#600', '#ff8', '#086', '#8a6', '#adf', '#88a', '#f60', 35 | '#068', '#a66', '#f0a', '#fda' 36 | ] 37 | 38 | DISTINCT_COLORS_TRUE = [ 39 | '#ff0000', '#008c00', '#0000ff', '#c34fff', 40 | '#01a5ca', '#ec9d00', '#76ff00', '#595354', 41 | '#ff7598', '#940073', '#00f3cc', '#4853ff', 42 | '#a6a19a', '#004301', '#edb7ff', '#8a6800', 43 | '#6100a3', '#5c0011', '#fff585', '#007b69', 44 | '#92b853', '#abd4ff', '#7e79a3', '#ff5401', 45 | '#0a577d', '#a8615c', '#e700b9', '#ffc3a6' 46 | ] 47 | 48 | COLOR_SCHEMES = { 49 | "mono": { 50 | "mode": "mono" 51 | }, 52 | "rotate_16": { 53 | "mode": "rotate", 54 | "colors": DISTINCT_COLORS_16 55 | }, 56 | "rotate_256": { 57 | "mode": "rotate", 58 | "colors": DISTINCT_COLORS_256 59 | }, 60 | "rotate_true": { 61 | "mode": "rotate", 62 | "colors": DISTINCT_COLORS_TRUE 63 | }, 64 | "signed": { 65 | "mode": "rules", 66 | "colors": { 67 | "nonnegative": "default", 68 | "negative": "dark red" 69 | }, 70 | "rules": [ 71 | ( "<", 0, "negative" ), 72 | ( "else", "nonnegative" ), 73 | ] 74 | } 75 | } 76 | 77 | def pairwise(iterable): 78 | "s -> (s0,s1), (s1,s2), (s2, s3), ..." 79 | a, b = itertools.tee(iterable) 80 | next(b, None) 81 | return zip(a, b) 82 | 83 | 84 | def get_palette_entries( 85 | chart_colors = None, 86 | label_colors = None 87 | ): 88 | 89 | NORMAL_FG_MONO = "white" 90 | NORMAL_FG_16 = "light gray" 91 | NORMAL_BG_16 = "black" 92 | NORMAL_FG_256 = "light gray" 93 | NORMAL_BG_256 = "black" 94 | 95 | palette_entries = {} 96 | 97 | if not label_colors: 98 | label_colors = list(set([ 99 | DEFAULT_LABEL_COLOR, 100 | DEFAULT_LABEL_COLOR_DARK, 101 | DEFAULT_LABEL_COLOR_LIGHT 102 | ])) 103 | 104 | 105 | if chart_colors: 106 | colors = chart_colors 107 | else: 108 | colors = (urwid.display_common._BASIC_COLORS 109 | + DISTINCT_COLORS_256 110 | + DISTINCT_COLORS_TRUE ) 111 | 112 | fcolors = colors + label_colors 113 | bcolors = colors 114 | 115 | for fcolor in fcolors: 116 | if isinstance(fcolor, PaletteEntry): 117 | fname = fcolor.name 118 | ffg = fcolor.foreground 119 | fbg = NORMAL_BG_16 120 | ffghi = fcolor.foreground_high 121 | fbghi = NORMAL_BG_256 122 | else: 123 | fname = fcolor 124 | ffg = (fcolor 125 | if fcolor in urwid.display_common._BASIC_COLORS 126 | else NORMAL_FG_16) 127 | fbg = NORMAL_BG_16 128 | ffghi = fcolor 129 | fbghi = NORMAL_BG_256 130 | 131 | palette_entries.update({ 132 | fname: PaletteEntry( 133 | name = fname, 134 | mono = NORMAL_FG_MONO, 135 | foreground = ffg, 136 | background = fbg, 137 | foreground_high = ffghi, 138 | background_high = fbghi 139 | ), 140 | }) 141 | 142 | for bcolor in bcolors: 143 | 144 | if isinstance(bcolor, PaletteEntry): 145 | bname = "%s:%s" %(fname, bcolor.name) 146 | bfg = ffg 147 | bbg = bcolor.background 148 | bfghi = ffghi 149 | bbghi = bcolor.background_high 150 | else: 151 | bname = "%s:%s" %(fname, bcolor) 152 | bfg = fcolor 153 | bbg = bcolor 154 | bfghi = fcolor 155 | bbghi = bcolor 156 | 157 | palette_entries.update({ 158 | bname: PaletteEntry( 159 | name = bname, 160 | mono = NORMAL_FG_MONO, 161 | foreground = (bfg 162 | if bfg in urwid.display_common._BASIC_COLORS 163 | else NORMAL_BG_16), 164 | background = (bbg 165 | if bbg in urwid.display_common._BASIC_COLORS 166 | else NORMAL_BG_16), 167 | foreground_high = bfghi, 168 | background_high = bbghi 169 | ), 170 | }) 171 | 172 | return palette_entries 173 | 174 | 175 | 176 | OPERATOR_MAP = { 177 | "<": operator.lt, 178 | "<=": operator.le, 179 | ">": operator.gt, 180 | ">=": operator.ge, 181 | "=": operator.eq, 182 | "else": lambda a, b: True 183 | } 184 | 185 | 186 | class SparkWidget(urwid.Text): 187 | 188 | @staticmethod 189 | def make_rule_function(scheme): 190 | 191 | rules = scheme["rules"] 192 | def rule_function(value): 193 | return scheme["colors"].get( 194 | next(iter(filter( 195 | lambda rule: all( 196 | OPERATOR_MAP[cond[0]](value, cond[1] if len(cond) > 1 else None) 197 | for cond in [rule] 198 | ), scheme["rules"] 199 | )))[-1] 200 | ) 201 | return rule_function 202 | 203 | 204 | @staticmethod 205 | def normalize(v, a, b, scale_min, scale_max): 206 | 207 | if scale_max == scale_min: 208 | return v 209 | return max( 210 | a, 211 | min( 212 | b, 213 | (((v - scale_min) / (scale_max - scale_min) ) * (b - a) + a) 214 | ) 215 | ) 216 | 217 | 218 | def parse_scheme(self, scheme): 219 | 220 | if isinstance(scheme, dict): 221 | color_scheme = scheme 222 | else: 223 | try: 224 | color_scheme = COLOR_SCHEMES[scheme] 225 | except: 226 | return lambda x: scheme 227 | # raise Exception("Unknown color scheme: %s" %(scheme)) 228 | 229 | mode = color_scheme["mode"] 230 | if mode == "mono": 231 | return None 232 | 233 | elif mode == "rotate": 234 | return deque(color_scheme["colors"]) 235 | 236 | elif mode == "rules": 237 | return self.make_rule_function(color_scheme) 238 | 239 | else: 240 | raise Exception("Unknown color scheme mode: %s" %(mode)) 241 | 242 | @property 243 | def current_color(self): 244 | 245 | return self.colors[0] 246 | 247 | def next_color(self): 248 | if not self.colors: 249 | return 250 | self.colors.rotate(-1) 251 | return self.current_color 252 | 253 | def get_color(self, item): 254 | if not self.colors: 255 | color = None 256 | elif callable(self.colors): 257 | color = self.colors(item) 258 | elif isinstance(self.colors, collections.Iterable): 259 | color = self.current_color 260 | self.next_color() 261 | return color 262 | else: 263 | raise Exception(self.colors) 264 | 265 | return color 266 | 267 | 268 | class SparkColumnWidget(SparkWidget): 269 | """ 270 | A sparkline-ish column widget for Urwid. 271 | 272 | Given a list of numeric values, this widget will draw a small text-based 273 | vertical bar graph of the values, one character per value. Column segments 274 | can be colorized according to a color scheme or by assigning each 275 | value a color. 276 | 277 | :param items: A list of items to be charted in the widget. Items can be 278 | either numeric values or tuples, the latter of which must be of the form 279 | ('attribute', value) where attribute is an urwid text attribute and value 280 | is a numeric value. 281 | 282 | :param color_scheme: A string or dictionary containing the name of or 283 | definition of a color scheme for the widget. 284 | 285 | :param underline: one of None, "negative", or "min", specifying values that 286 | should be marked in the chart. "negative" shows negative values as little 287 | dots at the bottom of the chart, while "min" uses a unicode combining 288 | three dots character to indicate minimum values. Results of this and the 289 | rest of these parameters may not look great with all terminals / fonts, 290 | so if this looks weird, don't use it. 291 | 292 | :param overline: one of None or "max" specfying values that should be marked 293 | in the chart. "max" draws three dots above the max value. See underline 294 | description for caveats. 295 | 296 | :param scale_min: Set a minimum scale for the chart. By default, the range 297 | of the chart's Y axis will expand to show all values, but this parameter 298 | can be used to restrict or expand the Y-axis. 299 | 300 | :param scale_max: Set the maximum for the Y axis. -- see scale_min. 301 | """ 302 | 303 | chars = BLOCK_VERTICAL 304 | 305 | def __init__(self, items, 306 | color_scheme = "mono", 307 | scale_min = None, 308 | scale_max = None, 309 | underline = None, 310 | overline = None, 311 | *args, **kwargs): 312 | 313 | self.items = items 314 | self.colors = self.parse_scheme(color_scheme) 315 | 316 | self.underline = underline 317 | self.overline = overline 318 | 319 | self.values = [ i[1] if isinstance(i, tuple) else i for i in self.items ] 320 | 321 | v_min = min(self.values) 322 | v_max = max(self.values) 323 | 324 | 325 | def item_to_glyph(item): 326 | 327 | color = None 328 | 329 | if isinstance(item, tuple): 330 | color = item[0] 331 | value = item[1] 332 | else: 333 | color = self.get_color(item) 334 | value = item 335 | 336 | if self.underline == "negative" and value < 0: 337 | glyph = " \N{COMBINING DOT BELOW}" 338 | else: 339 | 340 | 341 | # idx = scale_value(value, scale_min=scale_min, scale_max=scale_max) 342 | idx = self.normalize( 343 | value, 0, len(self.chars)-1, 344 | scale_min if scale_min else v_min, 345 | scale_max if scale_max else v_max) 346 | 347 | glyph = self.chars[int(round(idx))] 348 | 349 | if self.underline == "min" and value == v_min: 350 | glyph = "%s\N{COMBINING TRIPLE UNDERDOT}" %(glyph) 351 | 352 | if self.overline == "max" and value == v_max: 353 | glyph = "%s\N{COMBINING THREE DOTS ABOVE}" %(glyph) 354 | 355 | if color: 356 | return (color, glyph) 357 | else: 358 | return glyph 359 | 360 | self.sparktext = [ 361 | item_to_glyph(i) 362 | for i in self.items 363 | ] 364 | super(SparkColumnWidget, self).__init__(self.sparktext, *args, **kwargs) 365 | 366 | 367 | # via https://github.com/rg3/dhondt 368 | def dhondt_formula(votes, seats): 369 | return votes / (seats + 1) 370 | 371 | def bar_widths(party_votes, total_seats): 372 | # Calculate the quotients matrix (list in this case). 373 | quot = [] 374 | ret = dict() 375 | for p in dict(enumerate(party_votes)): 376 | ret[p] = 0 377 | for s in range(0, total_seats): 378 | q = dhondt_formula(party_votes[p], s) 379 | quot.append((q, p)) 380 | 381 | # Sort the quotients by value. 382 | quot.sort(reverse=True) 383 | 384 | # Take the highest quotients with the assigned parties. 385 | for s in range(0, total_seats): 386 | ret[quot[s][1]] += 1 387 | return list(ret.values()) 388 | 389 | 390 | @dataclass 391 | class SparkBarItem: 392 | 393 | value: int 394 | label: str = None 395 | fcolor: str = None 396 | bcolor: str = None 397 | align: str = "<" 398 | fill: str = " " 399 | 400 | @property 401 | def steps(self): 402 | return len(BLOCK_HORIZONTAL) 403 | 404 | def formatted_label(self, total): 405 | if self.label is None: 406 | return None 407 | try: 408 | pct = int(round(self.value/total*100, 0)) 409 | except: 410 | pct = "" 411 | 412 | return str(self.label).format( 413 | value=self.value, 414 | pct=pct 415 | ) 416 | def truncated_label(self, width, total): 417 | 418 | label = self.formatted_label(total) 419 | if not label: 420 | return None 421 | return ( 422 | label[:width-1] + "\N{HORIZONTAL ELLIPSIS}" 423 | if len(label) > width 424 | else label 425 | ) 426 | 427 | # s = "{label:.{n}}".format( 428 | # label=self.formatted_label(total), 429 | # n=min(len(label), width), 430 | # ) 431 | # if len(s) > width: 432 | # chars[-1] = "\N{HORIZONTAL ELLIPSIS}" 433 | 434 | 435 | def output(self, width, total, next_color=None): 436 | 437 | steps_width = width % self.steps if next_color else None 438 | chars_width = width // self.steps# - (1 if steps_width else 0) 439 | # print(width, chars_width, steps_width) 440 | label = self.truncated_label(chars_width, total) 441 | if label: 442 | chars = "{:{a}{m}.{m}}".format( 443 | label, 444 | m=max(chars_width, 0), 445 | a=self.align or "<", 446 | ) 447 | # if len(label) > chars_width: 448 | # chars[-1] = "\N{HORIZONTAL ELLIPSIS}" 449 | else: 450 | chars = self.fill * chars_width 451 | 452 | 453 | 454 | output = [ 455 | ( 456 | "%s:%s" %( 457 | self.fcolor or DEFAULT_LABEL_COLOR, 458 | self.bcolor or DEFAULT_BAR_COLOR), chars 459 | ) 460 | ] 461 | 462 | if steps_width: 463 | attr = f"{self.bcolor}:{next_color}" 464 | output.append( 465 | (attr, BLOCK_HORIZONTAL[steps_width]) 466 | ) 467 | return output 468 | 469 | 470 | class SparkBarWidget(SparkWidget): 471 | """ 472 | A sparkline-ish horizontal stacked bar widget for Urwid. 473 | 474 | This widget graphs a set of values in a horizontal bar style. 475 | 476 | :param items: A list of items to be charted in the widget. Items can be 477 | either numeric values or tuples, the latter of which must be of the form 478 | ('attribute', value) where attribute is an urwid text attribute and value 479 | is a numeric value. 480 | 481 | :param width: Width of the widget in characters. 482 | 483 | :param color_scheme: A string or dictionary containing the name of or 484 | definition of a color scheme for the widget. 485 | """ 486 | 487 | fill_char = " " 488 | 489 | def __init__(self, items, width, 490 | color_scheme="mono", 491 | label_color=None, 492 | min_width=None, 493 | fit_label=False, 494 | normalize=None, 495 | fill_char=None, 496 | *args, **kwargs): 497 | 498 | self.items = [ 499 | i if isinstance(i, SparkBarItem) else SparkBarItem(i) 500 | for i in items 501 | ] 502 | 503 | self.colors = self.parse_scheme(color_scheme) 504 | 505 | for i in self.items: 506 | if not i.bcolor: 507 | i.bcolor = self.get_color(i) 508 | if fill_char: 509 | i.fill_char = fill_char 510 | 511 | self.width = width 512 | self.label_color = label_color 513 | self.min_width = min_width 514 | self.fit_label = fit_label 515 | 516 | values = None 517 | total = None 518 | 519 | if normalize: 520 | values = [ item.value for item in self.items ] 521 | v_min = min(values) 522 | v_max = max(values) 523 | values = [ 524 | int(self.normalize(v, 525 | normalize[0], normalize[1], 526 | v_min, v_max)) 527 | for v in values 528 | ] 529 | for i, v in enumerate(values): 530 | self.items[i].value = v 531 | 532 | 533 | filtered_items = self.items 534 | values = [i.value for i in filtered_items] 535 | total = sum(values) 536 | 537 | charwidth = total / self.width 538 | 539 | self.sparktext = [] 540 | 541 | position = 0 542 | lastcolor = None 543 | 544 | values = [i.value for i in filtered_items] 545 | 546 | # number of steps that can be represented within each screen character 547 | # represented by Unicode block characters 548 | steps = len(BLOCK_HORIZONTAL) 549 | 550 | # use a prorportional representation algorithm to distribute the number 551 | # of available steps among each bar segment 552 | self.bars = bar_widths(values, self.width*steps) 553 | 554 | if self.min_width or self.fit_label: 555 | # make any requested adjustments to bar widths 556 | for i in range(len(self.bars)): 557 | if self.min_width and self.bars[i] < self.min_width*steps: 558 | self.bars[i] = self.min_width*steps 559 | if self.fit_label: 560 | # need some slack here to compensate for self.bars that don't 561 | # begin on a character boundary 562 | label_len = len(self.items[i].formatted_label(total))+2 563 | if self.bars[i] < label_len*steps: 564 | self.bars[i] = label_len*steps 565 | # use modified proportions to calculate new proportions that try 566 | # to account for min_width and fit_label 567 | self.bars = bar_widths(self.bars, self.width*steps) 568 | 569 | # filtered_items = [item for i, item in enumerate(self.items) if self.bars[i]] 570 | # self.bars = [b for b in self.bars if b] 571 | 572 | for i, (item, item_next) in enumerate(pairwise(filtered_items)): 573 | width = self.bars[i] 574 | output = item.output(width, total=total, next_color=item_next.bcolor) 575 | self.sparktext += output 576 | 577 | output = filtered_items[-1].output(self.bars[-1], total=total) 578 | self.sparktext += output 579 | 580 | if not self.sparktext: 581 | self.sparktext = "" 582 | self.set_text(self.sparktext) 583 | super(SparkBarWidget, self).__init__(self.sparktext, *args, **kwargs) 584 | 585 | def bar_width(self, index): 586 | return self.bars[index]//len(BLOCK_HORIZONTAL) 587 | 588 | 589 | __all__ = [ 590 | "SparkColumnWidget", "SparkBarWidget", "SparkBarItem", 591 | "get_palette_entries", 592 | "DEFAULT_LABEL_COLOR", "DEFAULT_LABEL_COLOR_DARK", "DEFAULT_LABEL_COLOR_LIGHT" 593 | ] 594 | -------------------------------------------------------------------------------- /panwid/tabview.py: -------------------------------------------------------------------------------- 1 | import logging 2 | logger = logging.getLogger("panwid.tabview") 3 | 4 | import urwid 5 | from urwid_utils.palette import * 6 | 7 | # Based on the tabview widget from github/@ountainstorm 8 | # https://github.com/mountainstorm/mt_urwid/ 9 | 10 | class TabHandle(urwid.WidgetWrap): 11 | 12 | LABEL_CHARS_UNLOCKED = u"\u24e7 " 13 | LABEL_CHARS_LOCKED = u"\u25cb " 14 | 15 | def __init__(self, tab_view, title, locked=False, padding = 3, 16 | attr_inactive = {}, attr_active = {}): 17 | self.tab_view = tab_view 18 | self.title = title 19 | self.locked = locked 20 | if locked: 21 | self.label = self.LABEL_CHARS_LOCKED + title 22 | else: 23 | self.label = self.LABEL_CHARS_UNLOCKED + title 24 | 25 | self.label = ' '*padding + self.label + ' '*padding 26 | 27 | self.text = urwid.SelectableIcon(self.label) 28 | # self.padding = urwid.Padding(self.text, align="left", width=20, left=3, right=3) 29 | self.attr = urwid.AttrMap(self.text, attr_inactive, "tabview_active") 30 | super(TabHandle, self).__init__(self.attr) 31 | 32 | def set_text_attr(self, attr): 33 | self.text.set_text((attr, self.label)) 34 | 35 | 36 | def selectable(self): 37 | return True 38 | 39 | def keypress(self, size, key): 40 | 41 | if key == "enter": 42 | self.tab_view._set_active_by_tab(self) 43 | elif key == 'tab': 44 | self.tab_view.set_active_next() 45 | elif key == 'shift tab': 46 | self.tab_view.set_active_prev() 47 | else: 48 | return key 49 | 50 | def mouse_event(self, size, event, button, col, row, focus): 51 | if button == 1: 52 | if not self.locked: 53 | tab_width = self._w.pack(size)[0] 54 | if col <= 2: 55 | # close this tab 56 | self.tab_view._close_by_tab(self) 57 | return 58 | # make this tab active 59 | self.tab_view._set_active_by_tab(self) 60 | #raise AttributeError("b: %s - c: %u, r: %u - ev: %s" % (button, col, row, event)) 61 | 62 | 63 | class TabHeader(urwid.WidgetWrap): 64 | 65 | def __init__(self, attr_inactive={}, attr_active={}, divider = True): 66 | 67 | self.columns = urwid.Columns([], 1) 68 | 69 | contents = [ ('weight', 1, x) for x in [self.columns] ] 70 | # contents = [ ('pack', self.attr) ] 71 | if divider: 72 | contents += [ ('pack', urwid.Divider('-')) ] 73 | self.pile = urwid.Pile(contents) 74 | # self.pile.selectable = lambda: True 75 | super(TabHeader, self).__init__(self.pile) 76 | 77 | @property 78 | def contents(self): 79 | return self.columns.contents 80 | 81 | def set_focus(self, idx): 82 | 83 | self.columns.focus_position = idx 84 | 85 | def options(self, s): 86 | return self.columns.options(s) 87 | 88 | 89 | class Tab(object): 90 | 91 | HOTKEYS = dict() 92 | 93 | def __init__(self, label, content, hotkey=None, locked=False): 94 | 95 | self.label = label 96 | self.content = content 97 | if hotkey: 98 | Tab.HOTKEYS["meta %s" %(hotkey)] = self 99 | else: 100 | hotkey = "meta %s" %(self.label[0].lower()) 101 | if not hotkey in Tab.HOTKEYS: 102 | Tab.HOTKEYS[hotkey] = self 103 | else: 104 | hotkey = None 105 | self.hotkey = hotkey 106 | self.locked = locked 107 | 108 | # yuck 109 | def __getitem__(self, idx): 110 | if idx == "0": 111 | return self.label 112 | elif idx == 1: 113 | return self.content 114 | 115 | 116 | 117 | class TabView(urwid.WidgetWrap): 118 | 119 | signals = ["activate"] 120 | 121 | def __init__(self, tabs, 122 | attr_inactive={None: "tabview_inactive"}, 123 | attr_active={None: "tabview_active"}, 124 | selected = 0, tab_bar_initial_focus = False): 125 | self.attr_inactive = attr_inactive 126 | self.attr_active = attr_active 127 | self._contents = [] 128 | self.tab_bar = TabHeader(attr_inactive, attr_active) 129 | self.body = urwid.AttrMap(urwid.SolidFill(' '), attr_active) 130 | display_widget = urwid.Pile( 131 | ( ('pack', self.tab_bar), ('weight', 1, self.body) ) 132 | ) 133 | # display_widget.selectable = lambda: True 134 | super(TabView, self).__init__(display_widget) 135 | 136 | if not tab_bar_initial_focus: 137 | display_widget.focus_position = 1 138 | 139 | # now add all the tabs 140 | for tab in tabs: 141 | self.add_tab(tab) 142 | if selected is not None: 143 | self.set_active_tab(selected) 144 | 145 | @property 146 | def active_tab(self): 147 | return self._contents[self.active_tab_idx] 148 | 149 | @classmethod 150 | def get_palette_entries(cls): 151 | return { 152 | "tabview_inactive": PaletteEntry( 153 | foreground = "light gray", 154 | background = "black" 155 | ), 156 | "tabview_active": PaletteEntry( 157 | foreground = "white", 158 | background = "dark blue", 159 | foreground_high = "white", 160 | background_high = "#009", 161 | ), 162 | } 163 | 164 | def add_tab(self, tab): 165 | label = tab.label 166 | content = tab.content 167 | hokey = tab.hotkey 168 | locked = tab.locked 169 | 170 | self.tab_bar.contents.append( 171 | ( 172 | TabHandle( 173 | self, 174 | label, 175 | locked, 176 | attr_active = self.attr_active, 177 | attr_inactive = self.attr_inactive, 178 | 179 | ), 180 | self.tab_bar.options('pack') 181 | ) 182 | ) 183 | self._contents.append(tab) 184 | self.set_active_tab(len(self._contents)-1) 185 | 186 | def set_active_tab(self, idx): 187 | 188 | if idx < 0 or idx > len(self._contents) - 1: 189 | return 190 | 191 | self.tab_bar.set_focus(idx) 192 | 193 | for i, tab in enumerate(self.tab_bar.contents): 194 | if i == idx: 195 | tab[0].set_text_attr(self.attr_active[None]) 196 | else: 197 | tab[0].set_text_attr(self.attr_inactive[None]) 198 | 199 | self._w.contents[1] = ( 200 | urwid.AttrMap( 201 | self._contents[idx].content, 202 | self.attr_inactive 203 | ), 204 | self._w.contents[1][1] 205 | ) 206 | self.active_tab_idx = idx 207 | urwid.signals.emit_signal(self, "activate", self, self._contents[idx]) 208 | 209 | def get_tab_by_label(self, label): 210 | 211 | for tab in self._contents: 212 | if tab.label == label: 213 | return tab.content 214 | return None 215 | 216 | def get_tab_index_by_label(self, label): 217 | 218 | for i, tab in enumerate(self._contents): 219 | if tab.label == label: 220 | return i 221 | return None 222 | 223 | 224 | def set_active_next(self): 225 | if self.active_tab_idx < (len(self._contents)-1): 226 | self.set_active_tab(self.active_tab_idx+1) 227 | else: 228 | self.set_active_tab(0) 229 | 230 | def set_active_prev(self): 231 | if self.active_tab_idx > 0: 232 | self.set_active_tab(self.active_tab_idx-1) 233 | else: 234 | self.set_active_tab(len(self._contents)-1) 235 | 236 | def close_active_tab(self): 237 | if not self.tab_bar.contents[self.active_tab_idx][0].locked: 238 | del self.tab_bar.contents[self.active_tab_idx] 239 | new_idx = self.active_tab_idx 240 | if len(self._contents) <= self.active_tab_idx: 241 | new_idx -= 1 242 | del self._contents[self.active_tab_idx] 243 | self.set_active_tab(new_idx) 244 | 245 | def _set_active_by_tab(self, tab): 246 | for idx, t in enumerate(self.tab_bar.contents): 247 | if t[0] is tab: 248 | self.set_active_tab(idx) 249 | break 250 | 251 | def _close_by_tab(self, tab): 252 | for idx, t in enumerate(self.tab_bar.contents): 253 | if t[0] is tab: 254 | self.set_active_tab(idx) 255 | self.close_active_tab() 256 | break 257 | 258 | def keypress(self, size, key): 259 | 260 | if key in Tab.HOTKEYS: 261 | idx = self.get_tab_index_by_label(Tab.HOTKEYS[key].label) 262 | self.set_active_tab(idx) 263 | else: 264 | return super(TabView, self).keypress(size, key) 265 | 266 | __all__ = ["TabView", "Tab"] 267 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | from setuptools import setup, find_packages 5 | import sys 6 | from os import path 7 | from glob import glob 8 | 9 | name = 'panwid' 10 | setup(name=name, 11 | version='0.3.5', 12 | description='Useful widgets for urwid', 13 | author='Tony Cebzanov', 14 | author_email='tonycpsu@gmail.com', 15 | url='https://github.com/tonycpsu/panwid', 16 | python_requires='>=3.6', 17 | classifiers=[ 18 | 'Environment :: Console', 19 | 'License :: OSI Approved :: GNU General Public License v2 (GPLv2)', 20 | 'Intended Audience :: Developers' 21 | ], 22 | packages=find_packages(), 23 | data_files=[('share/doc/%s' % name, ['LICENSE','README.md']), 24 | ], 25 | install_requires=[ 26 | "urwid", 27 | "urwid-utils >= 0.1.2", 28 | "six", 29 | "raccoon >= 3.0.0", 30 | "orderedattrdict", 31 | "urwid_readline ~= 0.13" 32 | ], 33 | test_suite="test", 34 | # dependency_links=[ 35 | # "https://github.com/tonycpsu/urwid_utils/tarball/master#egg=urwid_utils-0.0.5dev" 36 | # ], 37 | ) 38 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tonycpsu/panwid/e85bcfb95ef7e11aa594a996bae829f67787e1ee/test/__init__.py -------------------------------------------------------------------------------- /test/test_datatable.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from panwid.datatable import * 4 | from orderedattrdict import AttrDict 5 | 6 | class TestDataTableWithIndex(unittest.TestCase): 7 | 8 | def setUp(self): 9 | 10 | self.data = [ 11 | dict(a=1, b=2.345, c="foo"), 12 | dict(a=2, b=4.817, c="bar"), 13 | dict(a=3, b=-3.19, c="baz") 14 | ] 15 | self.columns = [ 16 | DataTableColumn("a"), 17 | DataTableColumn("b"), 18 | DataTableColumn("c") 19 | ] 20 | 21 | def test_create(self): 22 | 23 | dt = DataTable(self.columns, data=self.data, index="a") 24 | dt.refresh() 25 | self.assertEqual(len(dt), 3) 26 | 27 | def test_create_without_index(self): 28 | 29 | dt = DataTable(self.columns, data=self.data) 30 | dt.refresh() 31 | self.assertEqual(len(dt), 3) 32 | 33 | def test_add_row_with_index(self): 34 | 35 | dt = DataTable(self.columns, data=self.data, index="a") 36 | dt.refresh() 37 | dt.add_row(dict(a=4, b=7.142, c="qux")) 38 | self.assertEqual(len(dt), 4) 39 | 40 | def test_add_row_without_index(self): 41 | 42 | dt = DataTable(self.columns, data=self.data) 43 | dt.refresh() 44 | dt.add_row(dict(a=4, b=7.142, c="qux")) 45 | self.assertEqual(len(dt), 4) 46 | -------------------------------------------------------------------------------- /test/test_dropdown.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from panwid.dropdown import * 4 | from orderedattrdict import AttrDict 5 | 6 | class TestDropdown(unittest.TestCase): 7 | 8 | def setUp(self): 9 | 10 | self.data = AttrDict([('Adipisci eius dolore consectetur.', 34), 11 | ('Aliquam consectetur velit dolore', 19), 12 | ('Amet ipsum quaerat numquam.', 25), 13 | ('Amet quisquam labore dolore.', 30), 14 | ('Amet velit consectetur.', 20), 15 | ('Consectetur consectetur aliquam voluptatem', 23), 16 | ('Consectetur ipsum aliquam.', 28), 17 | ('Consectetur sit neque est', 15), 18 | ('Dolore voluptatem etincidunt sit', 40), 19 | ('Dolorem porro tempora tempora.', 37), 20 | ('Eius numquam dolor ipsum', 26), 21 | ('Eius tempora etincidunt est', 12), 22 | ('Est adipisci numquam adipisci', 7), 23 | ('Est aliquam dolor.', 38), 24 | ('Etincidunt amet quisquam.', 33), 25 | ('Etincidunt consectetur velit.', 29), 26 | ('Etincidunt dolore eius.', 45), 27 | ('Etincidunt non amet.', 14), 28 | ('Etincidunt velit adipisci labore', 6), 29 | ('Ipsum magnam velit quiquia', 21), 30 | ('Ipsum modi eius.', 3), 31 | ('Labore voluptatem quiquia aliquam', 18), 32 | ('Magnam etincidunt porro magnam', 39), 33 | ('Magnam numquam amet.', 44), 34 | ('Magnam quisquam sit amet.', 27), 35 | ('Magnam voluptatem ipsum neque', 32), 36 | ('Modi est ipsum adipisci', 2), 37 | ('Neque eius voluptatem voluptatem', 42), 38 | ('Neque quisquam ipsum.', 10), 39 | ('Neque quisquam neque.', 48), 40 | ('Non dolore voluptatem.', 41), 41 | ('Non numquam consectetur voluptatem.', 35), 42 | ('Numquam eius dolorem.', 43), 43 | ('Numquam sed neque modi', 9), 44 | ('Porro voluptatem quaerat voluptatem', 11), 45 | ('Quaerat eius quiquia.', 17), 46 | ('Quiquia aliquam etincidunt consectetur.', 0), 47 | ('Quiquia ipsum sit.', 49), 48 | ('Quiquia non dolore quiquia', 8), 49 | ('Quisquam aliquam numquam dolore.', 1), 50 | ('Quisquam dolorem voluptatem adipisci.', 22), 51 | ('Sed magnam dolorem quisquam', 4), 52 | ('Sed tempora modi est.', 16), 53 | ('Sit aliquam dolorem.', 46), 54 | ('Sit modi dolor.', 31), 55 | ('Sit quiquia quiquia non.', 5), 56 | ('Sit quisquam numquam quaerat.', 36), 57 | ('Tempora etincidunt quiquia dolor', 13), 58 | ('Tempora velit etincidunt.', 24), 59 | ('Velit dolor velit.', 47)]) 60 | 61 | def test_create(self): 62 | dropdown = Dropdown(self.data) 63 | 64 | def test_default_label(self): 65 | dropdown = Dropdown(self.data, default=3) 66 | self.assertEqual(dropdown.selected_label, "Ipsum modi eius.") 67 | 68 | def test_default_value(self): 69 | dropdown = Dropdown(self.data, default=37) 70 | self.assertEqual(dropdown.selected_value, 37) 71 | --------------------------------------------------------------------------------