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