#!/usr/bin/python # # Urwid Window-Icon-Menu-Pointer-style widget classes # Copyright (C) 2004-2011 Ian Ward # # This library is free software; you can redistribute it and/or # modify it under the terms of the GNU Lesser General Public # License as published by the Free Software Foundation; either # version 2.1 of the License, or (at your option) any later version. # # This library is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public # License along with this library; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # # Urwid web site: https://urwid.org/ from __future__ import annotations import typing from collections.abc import MutableSequence from urwid.canvas import CompositeCanvas from urwid.command_map import ACTIVATE from urwid.container import Columns, Overlay from urwid.decoration import WidgetDecoration from urwid.signals import disconnect_signal # doctests from urwid.signals import connect_signal from urwid.text_layout import calc_coords from urwid.util import is_mouse_press from urwid.widget import BOX, FLOW, Text, WidgetWrap, delegate_to_widget_mixin if typing.TYPE_CHECKING: from typing_extensions import Literal from urwid.canvas import Canvas, TextCanvas from urwid.widget import Widget class SelectableIcon(Text): ignore_focus = False _selectable = True def __init__(self, text, cursor_position=0): """ :param text: markup for this widget; see :class:`Text` for description of text markup :param cursor_position: position the cursor will appear in the text when this widget is in focus This is a text widget that is selectable. A cursor displayed at a fixed location in the text when in focus. This widget has no special handling of keyboard or mouse input. """ super().__init__(text) self._cursor_position = cursor_position def render(self, size: tuple[int], focus: bool = False) -> TextCanvas | CompositeCanvas: """ Render the text content of this widget with a cursor when in focus. >>> si = SelectableIcon(u"[!]") >>> si >>> si.render((4,), focus=True).cursor (0, 0) >>> si = SelectableIcon("((*))", 2) >>> si.render((8,), focus=True).cursor (2, 0) >>> si.render((2,), focus=True).cursor (0, 1) """ c = super().render(size, focus) if focus: # create a new canvas so we can add a cursor c = CompositeCanvas(c) c.cursor = self.get_cursor_coords(size) return c def get_cursor_coords(self, size: tuple[int]) -> tuple[int, int] | None: """ Return the position of the cursor if visible. This method is required for widgets that display a cursor. """ if self._cursor_position > len(self.text): return None # find out where the cursor will be displayed based on # the text layout (maxcol,) = size trans = self.get_line_translation(maxcol) x, y = calc_coords(self.text, trans, self._cursor_position) if maxcol <= x: return None return x, y def keypress(self, size: tuple[int], key: str) -> str: """ No keys are handled by this widget. This method is required for selectable widgets. """ return key class CheckBoxError(Exception): pass class CheckBox(WidgetWrap): def sizing(self): return frozenset([FLOW]) states = { True: SelectableIcon("[X]", 1), False: SelectableIcon("[ ]", 1), 'mixed': SelectableIcon("[#]", 1) } reserve_columns = 4 # allow users of this class to listen for change events # sent when the state of this widget is modified # (this variable is picked up by the MetaSignals metaclass) signals = ["change", 'postchange'] def __init__( self, label, state: bool | Literal['mixed'] = False, has_mixed: bool = False, on_state_change=None, user_data=None, checked_symbol: str | None = None, ): """ :param label: markup for check box label :param state: False, True or "mixed" :param has_mixed: True if "mixed" is a state to cycle through :param on_state_change: shorthand for connect_signal() function call for a single callback :param user_data: user_data for on_state_change Signals supported: ``'change'``, ``"postchange"`` Register signal handler with:: urwid.connect_signal(check_box, 'change', callback, user_data) where callback is callback(check_box, new_state [,user_data]) Unregister signal handlers with:: urwid.disconnect_signal(check_box, 'change', callback, user_data) >>> CheckBox("Confirm") >>> CheckBox("Yogourt", "mixed", True) >>> cb = CheckBox("Extra onions", True) >>> cb >>> cb.render((20,), focus=True).text [b'[X] Extra onions '] >>> CheckBox("Test", None) Traceback (most recent call last): ... ValueError: None not in (True, False, 'mixed') """ if state not in self.states: raise ValueError(f"{state!r} not in {tuple(self.states.keys())}") self._label = Text(label) self.has_mixed = has_mixed self._state = state if checked_symbol: self.states[True] = SelectableIcon(f"[{checked_symbol}]", 1) # The old way of listening for a change was to pass the callback # in to the constructor. Just convert it to the new way: if on_state_change: connect_signal(self, 'change', on_state_change, user_data) # Initial create expect no callbacks call, create explicit super().__init__( Columns( [('fixed', self.reserve_columns, self.states[state]), self._label], focus_column=0, ), ) def _repr_words(self): return super()._repr_words() + [ repr(self.label)] def _repr_attrs(self): return dict(super()._repr_attrs(), state=self.state) def set_label(self, label): """ Change the check box label. label -- markup for label. See Text widget for description of text markup. >>> cb = CheckBox(u"foo") >>> cb >>> cb.set_label(('bright_attr', u"bar")) >>> cb """ self._label.set_text(label) # no need to call self._invalidate(). WidgetWrap takes care of # that when self.w changes def get_label(self): """ Return label text. >>> cb = CheckBox(u"Seriously") >>> print(cb.get_label()) Seriously >>> print(cb.label) Seriously >>> cb.set_label([('bright_attr', u"flashy"), u" normal"]) >>> print(cb.label) # only text is returned flashy normal """ return self._label.text label = property(get_label) def set_state(self, state: bool | Literal['mixed'], do_callback: bool = True) -> None: """ Set the CheckBox state. state -- True, False or "mixed" do_callback -- False to suppress signal from this change >>> changes = [] >>> def callback_a(user_data, cb, state): ... changes.append("A %r %r" % (state, user_data)) >>> def callback_b(cb, state): ... changes.append("B %r" % state) >>> cb = CheckBox('test', False, False) >>> key1 = connect_signal(cb, 'change', callback_a, user_args=("user_a",)) >>> key2 = connect_signal(cb, 'change', callback_b) >>> cb.set_state(True) # both callbacks will be triggered >>> cb.state True >>> disconnect_signal(cb, 'change', callback_a, user_args=("user_a",)) >>> cb.state = False >>> cb.state False >>> cb.set_state(True) >>> cb.state True >>> cb.set_state(False, False) # don't send signal >>> changes ["A True 'user_a'", 'B True', 'B False', 'B True'] """ if self._state == state: return if state not in self.states: raise CheckBoxError(f"{self!r} Invalid state: {state!r}") # self._state is None is a special case when the CheckBox # has just been created old_state = self._state if do_callback: self._emit('change', state) self._state = state # rebuild the display widget with the new state self._w = Columns([('fixed', self.reserve_columns, self.states[state]), self._label], focus_column=0) if do_callback: self._emit('postchange', old_state) def get_state(self) -> bool | Literal['mixed']: """Return the state of the checkbox.""" return self._state state = property(get_state, set_state) def keypress(self, size: tuple[int], key: str) -> str | None: """ Toggle state on 'activate' command. >>> assert CheckBox._command_map[' '] == 'activate' >>> assert CheckBox._command_map['enter'] == 'activate' >>> size = (10,) >>> cb = CheckBox('press me') >>> cb.state False >>> cb.keypress(size, ' ') >>> cb.state True >>> cb.keypress(size, ' ') >>> cb.state False """ if self._command_map[key] != ACTIVATE: return key self.toggle_state() def toggle_state(self) -> None: """ Cycle to the next valid state. >>> cb = CheckBox("3-state", has_mixed=True) >>> cb.state False >>> cb.toggle_state() >>> cb.state True >>> cb.toggle_state() >>> cb.state 'mixed' >>> cb.toggle_state() >>> cb.state False """ if self.state == False: self.set_state(True) elif self.state == True: if self.has_mixed: self.set_state('mixed') else: self.set_state(False) elif self.state == 'mixed': self.set_state(False) def mouse_event(self, size: tuple[int], event, button: int, x: int, y: int, focus: bool) -> bool: """ Toggle state on button 1 press. >>> size = (20,) >>> cb = CheckBox("clickme") >>> cb.state False >>> cb.mouse_event(size, 'mouse press', 1, 2, 0, True) True >>> cb.state True """ if button != 1 or not is_mouse_press(event): return False self.toggle_state() return True class RadioButton(CheckBox): states = { True: SelectableIcon("(X)", 1), False: SelectableIcon("( )", 1), 'mixed': SelectableIcon("(#)", 1) } reserve_columns = 4 def __init__( self, group: MutableSequence[CheckBox], label, state: bool | Literal['mixed', 'first True'] = "first True", on_state_change=None, user_data=None, ) -> None: """ :param group: list for radio buttons in same group :param label: markup for radio button label :param state: False, True, "mixed" or "first True" :param on_state_change: shorthand for connect_signal() function call for a single 'change' callback :param user_data: user_data for on_state_change This function will append the new radio button to group. "first True" will set to True if group is empty. Signals supported: ``'change'``, ``"postchange"`` Register signal handler with:: urwid.connect_signal(radio_button, 'change', callback, user_data) where callback is callback(radio_button, new_state [,user_data]) Unregister signal handlers with:: urwid.disconnect_signal(radio_button, 'change', callback, user_data) >>> bgroup = [] # button group >>> b1 = RadioButton(bgroup, u"Agree") >>> b2 = RadioButton(bgroup, u"Disagree") >>> len(bgroup) 2 >>> b1 >>> b2 >>> b2.render((15,), focus=True).text # ... = b in Python 3 [...'( ) Disagree '] """ if state == "first True": state = not group self.group = group super().__init__(label, state, False, on_state_change, user_data) group.append(self) def set_state(self, state: bool | Literal['mixed'], do_callback: bool = True) -> None: """ Set the RadioButton state. state -- True, False or "mixed" do_callback -- False to suppress signal from this change If state is True all other radio buttons in the same button group will be set to False. >>> bgroup = [] # button group >>> b1 = RadioButton(bgroup, u"Agree") >>> b2 = RadioButton(bgroup, u"Disagree") >>> b3 = RadioButton(bgroup, u"Unsure") >>> b1.state, b2.state, b3.state (True, False, False) >>> b2.set_state(True) >>> b1.state, b2.state, b3.state (False, True, False) >>> def relabel_button(radio_button, new_state): ... radio_button.set_label(u"Think Harder!") >>> key = connect_signal(b3, 'change', relabel_button) >>> b3 >>> b3.set_state(True) # this will trigger the callback >>> b3 """ if self._state == state: return super().set_state(state, do_callback) # if we're clearing the state we don't have to worry about # other buttons in the button group if state is not True: return # clear the state of each other radio button for cb in self.group: if cb is self: continue if cb._state: cb.set_state(False) def toggle_state(self) -> None: """ Set state to True. >>> bgroup = [] # button group >>> b1 = RadioButton(bgroup, "Agree") >>> b2 = RadioButton(bgroup, "Disagree") >>> b1.state, b2.state (True, False) >>> b2.toggle_state() >>> b1.state, b2.state (False, True) >>> b2.toggle_state() >>> b1.state, b2.state (False, True) """ self.set_state(True) class Button(WidgetWrap): def sizing(self): return frozenset([FLOW]) button_left = Text("<") button_right = Text(">") signals = ["click"] def __init__(self, label, on_press=None, user_data=None) -> None: """ :param label: markup for button label :param on_press: shorthand for connect_signal() function call for a single callback :param user_data: user_data for on_press Signals supported: ``'click'`` Register signal handler with:: urwid.connect_signal(button, 'click', callback, user_data) where callback is callback(button [,user_data]) Unregister signal handlers with:: urwid.disconnect_signal(button, 'click', callback, user_data) >>> Button(u"Ok")