1#!/usr/bin/python 2# 3# Urwid Window-Icon-Menu-Pointer-style widget classes 4# Copyright (C) 2004-2011 Ian Ward 5# 6# This library is free software; you can redistribute it and/or 7# modify it under the terms of the GNU Lesser General Public 8# License as published by the Free Software Foundation; either 9# version 2.1 of the License, or (at your option) any later version. 10# 11# This library is distributed in the hope that it will be useful, 12# but WITHOUT ANY WARRANTY; without even the implied warranty of 13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 14# Lesser General Public License for more details. 15# 16# You should have received a copy of the GNU Lesser General Public 17# License along with this library; if not, write to the Free Software 18# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA 19# 20# Urwid web site: http://excess.org/urwid/ 21 22from __future__ import division, print_function 23 24from urwid.widget import (Text, WidgetWrap, delegate_to_widget_mixin, BOX, 25 FLOW) 26from urwid.canvas import CompositeCanvas 27from urwid.signals import connect_signal 28from urwid.container import Columns, Overlay 29from urwid.util import is_mouse_press 30from urwid.text_layout import calc_coords 31from urwid.signals import disconnect_signal # doctests 32from urwid.split_repr import python3_repr 33from urwid.decoration import WidgetDecoration 34from urwid.command_map import ACTIVATE 35 36class SelectableIcon(Text): 37 ignore_focus = False 38 _selectable = True 39 def __init__(self, text, cursor_position=0): 40 """ 41 :param text: markup for this widget; see :class:`Text` for 42 description of text markup 43 :param cursor_position: position the cursor will appear in the 44 text when this widget is in focus 45 46 This is a text widget that is selectable. A cursor 47 displayed at a fixed location in the text when in focus. 48 This widget has no special handling of keyboard or mouse input. 49 """ 50 self.__super.__init__(text) 51 self._cursor_position = cursor_position 52 53 def render(self, size, focus=False): 54 """ 55 Render the text content of this widget with a cursor when 56 in focus. 57 58 >>> si = SelectableIcon(u"[!]") 59 >>> si 60 <SelectableIcon selectable flow widget '[!]'> 61 >>> si.render((4,), focus=True).cursor 62 (0, 0) 63 >>> si = SelectableIcon("((*))", 2) 64 >>> si.render((8,), focus=True).cursor 65 (2, 0) 66 >>> si.render((2,), focus=True).cursor 67 (0, 1) 68 """ 69 c = self.__super.render(size, focus) 70 if focus: 71 # create a new canvas so we can add a cursor 72 c = CompositeCanvas(c) 73 c.cursor = self.get_cursor_coords(size) 74 return c 75 76 def get_cursor_coords(self, size): 77 """ 78 Return the position of the cursor if visible. This method 79 is required for widgets that display a cursor. 80 """ 81 if self._cursor_position > len(self.text): 82 return None 83 # find out where the cursor will be displayed based on 84 # the text layout 85 (maxcol,) = size 86 trans = self.get_line_translation(maxcol) 87 x, y = calc_coords(self.text, trans, self._cursor_position) 88 if maxcol <= x: 89 return None 90 return x, y 91 92 def keypress(self, size, key): 93 """ 94 No keys are handled by this widget. This method is 95 required for selectable widgets. 96 """ 97 return key 98 99class CheckBoxError(Exception): 100 pass 101 102class CheckBox(WidgetWrap): 103 def sizing(self): 104 return frozenset([FLOW]) 105 106 states = { 107 True: SelectableIcon("[X]", 1), 108 False: SelectableIcon("[ ]", 1), 109 'mixed': SelectableIcon("[#]", 1) } 110 reserve_columns = 4 111 112 # allow users of this class to listen for change events 113 # sent when the state of this widget is modified 114 # (this variable is picked up by the MetaSignals metaclass) 115 signals = ["change", 'postchange'] 116 117 def __init__(self, label, state=False, has_mixed=False, 118 on_state_change=None, user_data=None, checked_symbol=None): 119 """ 120 :param label: markup for check box label 121 :param state: False, True or "mixed" 122 :param has_mixed: True if "mixed" is a state to cycle through 123 :param on_state_change: shorthand for connect_signal() 124 function call for a single callback 125 :param user_data: user_data for on_state_change 126 127 Signals supported: ``'change'``, ``"postchange"`` 128 129 Register signal handler with:: 130 131 urwid.connect_signal(check_box, 'change', callback, user_data) 132 133 where callback is callback(check_box, new_state [,user_data]) 134 Unregister signal handlers with:: 135 136 urwid.disconnect_signal(check_box, 'change', callback, user_data) 137 138 >>> CheckBox(u"Confirm") 139 <CheckBox selectable flow widget 'Confirm' state=False> 140 >>> CheckBox(u"Yogourt", "mixed", True) 141 <CheckBox selectable flow widget 'Yogourt' state='mixed'> 142 >>> cb = CheckBox(u"Extra onions", True) 143 >>> cb 144 <CheckBox selectable flow widget 'Extra onions' state=True> 145 >>> cb.render((20,), focus=True).text # ... = b in Python 3 146 [...'[X] Extra onions '] 147 """ 148 self.__super.__init__(None) # self.w set by set_state below 149 self._label = Text("") 150 self.has_mixed = has_mixed 151 self._state = None 152 if checked_symbol: 153 self.states[True] = SelectableIcon(u"[%s]" % checked_symbol, 1) 154 # The old way of listening for a change was to pass the callback 155 # in to the constructor. Just convert it to the new way: 156 if on_state_change: 157 connect_signal(self, 'change', on_state_change, user_data) 158 self.set_label(label) 159 self.set_state(state) 160 161 def _repr_words(self): 162 return self.__super._repr_words() + [ 163 python3_repr(self.label)] 164 165 def _repr_attrs(self): 166 return dict(self.__super._repr_attrs(), 167 state=self.state) 168 169 def set_label(self, label): 170 """ 171 Change the check box label. 172 173 label -- markup for label. See Text widget for description 174 of text markup. 175 176 >>> cb = CheckBox(u"foo") 177 >>> cb 178 <CheckBox selectable flow widget 'foo' state=False> 179 >>> cb.set_label(('bright_attr', u"bar")) 180 >>> cb 181 <CheckBox selectable flow widget 'bar' state=False> 182 """ 183 self._label.set_text(label) 184 # no need to call self._invalidate(). WidgetWrap takes care of 185 # that when self.w changes 186 187 def get_label(self): 188 """ 189 Return label text. 190 191 >>> cb = CheckBox(u"Seriously") 192 >>> print(cb.get_label()) 193 Seriously 194 >>> print(cb.label) 195 Seriously 196 >>> cb.set_label([('bright_attr', u"flashy"), u" normal"]) 197 >>> print(cb.label) # only text is returned 198 flashy normal 199 """ 200 return self._label.text 201 label = property(get_label) 202 203 def set_state(self, state, do_callback=True): 204 """ 205 Set the CheckBox state. 206 207 state -- True, False or "mixed" 208 do_callback -- False to suppress signal from this change 209 210 >>> changes = [] 211 >>> def callback_a(cb, state, user_data): 212 ... changes.append("A %r %r" % (state, user_data)) 213 >>> def callback_b(cb, state): 214 ... changes.append("B %r" % state) 215 >>> cb = CheckBox('test', False, False) 216 >>> key1 = connect_signal(cb, 'change', callback_a, "user_a") 217 >>> key2 = connect_signal(cb, 'change', callback_b) 218 >>> cb.set_state(True) # both callbacks will be triggered 219 >>> cb.state 220 True 221 >>> disconnect_signal(cb, 'change', callback_a, "user_a") 222 >>> cb.state = False 223 >>> cb.state 224 False 225 >>> cb.set_state(True) 226 >>> cb.state 227 True 228 >>> cb.set_state(False, False) # don't send signal 229 >>> changes 230 ["A True 'user_a'", 'B True', 'B False', 'B True'] 231 """ 232 if self._state == state: 233 return 234 235 if state not in self.states: 236 raise CheckBoxError("%s Invalid state: %s" % ( 237 repr(self), repr(state))) 238 239 # self._state is None is a special case when the CheckBox 240 # has just been created 241 old_state = self._state 242 if do_callback and old_state is not None: 243 self._emit('change', state) 244 self._state = state 245 # rebuild the display widget with the new state 246 self._w = Columns( [ 247 ('fixed', self.reserve_columns, self.states[state] ), 248 self._label ] ) 249 self._w.focus_col = 0 250 if do_callback and old_state is not None: 251 self._emit('postchange', old_state) 252 253 def get_state(self): 254 """Return the state of the checkbox.""" 255 return self._state 256 state = property(get_state, set_state) 257 258 def keypress(self, size, key): 259 """ 260 Toggle state on 'activate' command. 261 262 >>> assert CheckBox._command_map[' '] == 'activate' 263 >>> assert CheckBox._command_map['enter'] == 'activate' 264 >>> size = (10,) 265 >>> cb = CheckBox('press me') 266 >>> cb.state 267 False 268 >>> cb.keypress(size, ' ') 269 >>> cb.state 270 True 271 >>> cb.keypress(size, ' ') 272 >>> cb.state 273 False 274 """ 275 if self._command_map[key] != ACTIVATE: 276 return key 277 278 self.toggle_state() 279 280 def toggle_state(self): 281 """ 282 Cycle to the next valid state. 283 284 >>> cb = CheckBox("3-state", has_mixed=True) 285 >>> cb.state 286 False 287 >>> cb.toggle_state() 288 >>> cb.state 289 True 290 >>> cb.toggle_state() 291 >>> cb.state 292 'mixed' 293 >>> cb.toggle_state() 294 >>> cb.state 295 False 296 """ 297 if self.state == False: 298 self.set_state(True) 299 elif self.state == True: 300 if self.has_mixed: 301 self.set_state('mixed') 302 else: 303 self.set_state(False) 304 elif self.state == 'mixed': 305 self.set_state(False) 306 307 def mouse_event(self, size, event, button, x, y, focus): 308 """ 309 Toggle state on button 1 press. 310 311 >>> size = (20,) 312 >>> cb = CheckBox("clickme") 313 >>> cb.state 314 False 315 >>> cb.mouse_event(size, 'mouse press', 1, 2, 0, True) 316 True 317 >>> cb.state 318 True 319 """ 320 if button != 1 or not is_mouse_press(event): 321 return False 322 self.toggle_state() 323 return True 324 325 326class RadioButton(CheckBox): 327 states = { 328 True: SelectableIcon("(X)", 1), 329 False: SelectableIcon("( )", 1), 330 'mixed': SelectableIcon("(#)", 1) } 331 reserve_columns = 4 332 333 def __init__(self, group, label, state="first True", 334 on_state_change=None, user_data=None): 335 """ 336 :param group: list for radio buttons in same group 337 :param label: markup for radio button label 338 :param state: False, True, "mixed" or "first True" 339 :param on_state_change: shorthand for connect_signal() 340 function call for a single 'change' callback 341 :param user_data: user_data for on_state_change 342 343 This function will append the new radio button to group. 344 "first True" will set to True if group is empty. 345 346 Signals supported: ``'change'``, ``"postchange"`` 347 348 Register signal handler with:: 349 350 urwid.connect_signal(radio_button, 'change', callback, user_data) 351 352 where callback is callback(radio_button, new_state [,user_data]) 353 Unregister signal handlers with:: 354 355 urwid.disconnect_signal(radio_button, 'change', callback, user_data) 356 357 >>> bgroup = [] # button group 358 >>> b1 = RadioButton(bgroup, u"Agree") 359 >>> b2 = RadioButton(bgroup, u"Disagree") 360 >>> len(bgroup) 361 2 362 >>> b1 363 <RadioButton selectable flow widget 'Agree' state=True> 364 >>> b2 365 <RadioButton selectable flow widget 'Disagree' state=False> 366 >>> b2.render((15,), focus=True).text # ... = b in Python 3 367 [...'( ) Disagree '] 368 """ 369 if state=="first True": 370 state = not group 371 372 self.group = group 373 self.__super.__init__(label, state, False, on_state_change, 374 user_data) 375 group.append(self) 376 377 378 379 def set_state(self, state, do_callback=True): 380 """ 381 Set the RadioButton state. 382 383 state -- True, False or "mixed" 384 385 do_callback -- False to suppress signal from this change 386 387 If state is True all other radio buttons in the same button 388 group will be set to False. 389 390 >>> bgroup = [] # button group 391 >>> b1 = RadioButton(bgroup, u"Agree") 392 >>> b2 = RadioButton(bgroup, u"Disagree") 393 >>> b3 = RadioButton(bgroup, u"Unsure") 394 >>> b1.state, b2.state, b3.state 395 (True, False, False) 396 >>> b2.set_state(True) 397 >>> b1.state, b2.state, b3.state 398 (False, True, False) 399 >>> def relabel_button(radio_button, new_state): 400 ... radio_button.set_label(u"Think Harder!") 401 >>> key = connect_signal(b3, 'change', relabel_button) 402 >>> b3 403 <RadioButton selectable flow widget 'Unsure' state=False> 404 >>> b3.set_state(True) # this will trigger the callback 405 >>> b3 406 <RadioButton selectable flow widget 'Think Harder!' state=True> 407 """ 408 if self._state == state: 409 return 410 411 self.__super.set_state(state, do_callback) 412 413 # if we're clearing the state we don't have to worry about 414 # other buttons in the button group 415 if state is not True: 416 return 417 418 # clear the state of each other radio button 419 for cb in self.group: 420 if cb is self: continue 421 if cb._state: 422 cb.set_state(False) 423 424 425 def toggle_state(self): 426 """ 427 Set state to True. 428 429 >>> bgroup = [] # button group 430 >>> b1 = RadioButton(bgroup, "Agree") 431 >>> b2 = RadioButton(bgroup, "Disagree") 432 >>> b1.state, b2.state 433 (True, False) 434 >>> b2.toggle_state() 435 >>> b1.state, b2.state 436 (False, True) 437 >>> b2.toggle_state() 438 >>> b1.state, b2.state 439 (False, True) 440 """ 441 self.set_state(True) 442 443 444class Button(WidgetWrap): 445 def sizing(self): 446 return frozenset([FLOW]) 447 448 button_left = Text("<") 449 button_right = Text(">") 450 451 signals = ["click"] 452 453 def __init__(self, label, on_press=None, user_data=None): 454 """ 455 :param label: markup for button label 456 :param on_press: shorthand for connect_signal() 457 function call for a single callback 458 :param user_data: user_data for on_press 459 460 Signals supported: ``'click'`` 461 462 Register signal handler with:: 463 464 urwid.connect_signal(button, 'click', callback, user_data) 465 466 where callback is callback(button [,user_data]) 467 Unregister signal handlers with:: 468 469 urwid.disconnect_signal(button, 'click', callback, user_data) 470 471 >>> Button(u"Ok") 472 <Button selectable flow widget 'Ok'> 473 >>> b = Button("Cancel") 474 >>> b.render((15,), focus=True).text # ... = b in Python 3 475 [...'< Cancel >'] 476 """ 477 self._label = SelectableIcon("", 0) 478 cols = Columns([ 479 ('fixed', 1, self.button_left), 480 self._label, 481 ('fixed', 1, self.button_right)], 482 dividechars=1) 483 self.__super.__init__(cols) 484 485 # The old way of listening for a change was to pass the callback 486 # in to the constructor. Just convert it to the new way: 487 if on_press: 488 connect_signal(self, 'click', on_press, user_data) 489 490 self.set_label(label) 491 492 def _repr_words(self): 493 # include button.label in repr(button) 494 return self.__super._repr_words() + [ 495 python3_repr(self.label)] 496 497 def set_label(self, label): 498 """ 499 Change the button label. 500 501 label -- markup for button label 502 503 >>> b = Button("Ok") 504 >>> b.set_label(u"Yup yup") 505 >>> b 506 <Button selectable flow widget 'Yup yup'> 507 """ 508 self._label.set_text(label) 509 510 def get_label(self): 511 """ 512 Return label text. 513 514 >>> b = Button(u"Ok") 515 >>> print(b.get_label()) 516 Ok 517 >>> print(b.label) 518 Ok 519 """ 520 return self._label.text 521 label = property(get_label) 522 523 def keypress(self, size, key): 524 """ 525 Send 'click' signal on 'activate' command. 526 527 >>> assert Button._command_map[' '] == 'activate' 528 >>> assert Button._command_map['enter'] == 'activate' 529 >>> size = (15,) 530 >>> b = Button(u"Cancel") 531 >>> clicked_buttons = [] 532 >>> def handle_click(button): 533 ... clicked_buttons.append(button.label) 534 >>> key = connect_signal(b, 'click', handle_click) 535 >>> b.keypress(size, 'enter') 536 >>> b.keypress(size, ' ') 537 >>> clicked_buttons # ... = u in Python 2 538 [...'Cancel', ...'Cancel'] 539 """ 540 if self._command_map[key] != ACTIVATE: 541 return key 542 543 self._emit('click') 544 545 def mouse_event(self, size, event, button, x, y, focus): 546 """ 547 Send 'click' signal on button 1 press. 548 549 >>> size = (15,) 550 >>> b = Button(u"Ok") 551 >>> clicked_buttons = [] 552 >>> def handle_click(button): 553 ... clicked_buttons.append(button.label) 554 >>> key = connect_signal(b, 'click', handle_click) 555 >>> b.mouse_event(size, 'mouse press', 1, 4, 0, True) 556 True 557 >>> b.mouse_event(size, 'mouse press', 2, 4, 0, True) # ignored 558 False 559 >>> clicked_buttons # ... = u in Python 2 560 [...'Ok'] 561 """ 562 if button != 1 or not is_mouse_press(event): 563 return False 564 565 self._emit('click') 566 return True 567 568 569class PopUpLauncher(delegate_to_widget_mixin('_original_widget'), 570 WidgetDecoration): 571 def __init__(self, original_widget): 572 self.__super.__init__(original_widget) 573 self._pop_up_widget = None 574 575 def create_pop_up(self): 576 """ 577 Subclass must override this method and return a widget 578 to be used for the pop-up. This method is called once each time 579 the pop-up is opened. 580 """ 581 raise NotImplementedError("Subclass must override this method") 582 583 def get_pop_up_parameters(self): 584 """ 585 Subclass must override this method and have it return a dict, eg: 586 587 {'left':0, 'top':1, 'overlay_width':30, 'overlay_height':4} 588 589 This method is called each time this widget is rendered. 590 """ 591 raise NotImplementedError("Subclass must override this method") 592 593 def open_pop_up(self): 594 self._pop_up_widget = self.create_pop_up() 595 self._invalidate() 596 597 def close_pop_up(self): 598 self._pop_up_widget = None 599 self._invalidate() 600 601 def render(self, size, focus=False): 602 canv = self.__super.render(size, focus) 603 if self._pop_up_widget: 604 canv = CompositeCanvas(canv) 605 canv.set_pop_up(self._pop_up_widget, **self.get_pop_up_parameters()) 606 return canv 607 608 609class PopUpTarget(WidgetDecoration): 610 # FIXME: this whole class is a terrible hack and must be fixed 611 # when layout and rendering are separated 612 _sizing = set([BOX]) 613 _selectable = True 614 615 def __init__(self, original_widget): 616 self.__super.__init__(original_widget) 617 self._pop_up = None 618 self._current_widget = self._original_widget 619 620 def _update_overlay(self, size, focus): 621 canv = self._original_widget.render(size, focus=focus) 622 self._cache_original_canvas = canv # imperfect performance hack 623 pop_up = canv.get_pop_up() 624 if pop_up: 625 left, top, ( 626 w, overlay_width, overlay_height) = pop_up 627 if self._pop_up != w: 628 self._pop_up = w 629 self._current_widget = Overlay(w, self._original_widget, 630 ('fixed left', left), overlay_width, 631 ('fixed top', top), overlay_height) 632 else: 633 self._current_widget.set_overlay_parameters( 634 ('fixed left', left), overlay_width, 635 ('fixed top', top), overlay_height) 636 else: 637 self._pop_up = None 638 self._current_widget = self._original_widget 639 640 def render(self, size, focus=False): 641 self._update_overlay(size, focus) 642 return self._current_widget.render(size, focus=focus) 643 def get_cursor_coords(self, size): 644 self._update_overlay(size, True) 645 return self._current_widget.get_cursor_coords(size) 646 def get_pref_col(self, size): 647 self._update_overlay(size, True) 648 return self._current_widget.get_pref_col(size) 649 def keypress(self, size, key): 650 self._update_overlay(size, True) 651 return self._current_widget.keypress(size, key) 652 def move_cursor_to_coords(self, size, x, y): 653 self._update_overlay(size, True) 654 return self._current_widget.move_cursor_to_coords(size, x, y) 655 def mouse_event(self, size, event, button, x, y, focus): 656 self._update_overlay(size, focus) 657 return self._current_widget.mouse_event(size, event, button, x, y, focus) 658 def pack(self, size=None, focus=False): 659 self._update_overlay(size, focus) 660 return self._current_widget.pack(size) 661 662 663 664 665 666 667def _test(): 668 import doctest 669 doctest.testmod() 670 671if __name__=='__main__': 672 _test() 673