Included additional-urwid-widgets by AFoeee

This commit is contained in:
Mark Qvist 2021-05-04 15:10:02 +02:00
parent ac81f0768a
commit f5d263ee31
11 changed files with 1250 additions and 0 deletions

View file

@ -0,0 +1,2 @@
#! /usr/bin/env python3
# -*- coding: utf-8 -*-

View file

@ -0,0 +1,345 @@
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
from ..assisting_modules.modifier_key import MODIFIER_KEY # pylint: disable=unused-import
from ..assisting_modules.useful_functions import recursively_replace
from .indicative_listbox import IndicativeListBox
from .integer_picker import IntegerPicker
from .selectable_row import SelectableRow
import calendar
import datetime
import enum
import urwid
class DatePicker(urwid.WidgetWrap):
"""Serves as a selector for dates."""
_TYPE_ERR_MSG = "type {} was expected for {}, but found: {}."
_VALUE_ERR_MSG = "unrecognized value: {}."
# These values are interpreted during the creation of the list items for the day picker.
class DAY_FORMAT(enum.Enum):
DAY_OF_MONTH = 1
DAY_OF_MONTH_TWO_DIGIT = 2
WEEKDAY = 3
# These values are interpreted during the initialization and define the arrangement of the pickers.
class PICKER(enum.Enum):
YEAR = 1
MONTH = 2
DAY = 3
# Specifies which dates are selectable.
class RANGE(enum.Enum):
ALL = 1
ONLY_PAST = 2
ONLY_FUTURE = 3
def __init__(self, initial_date=datetime.date.today(), *, date_range=RANGE.ALL, month_names=calendar.month_name, day_names=calendar.day_abbr,
day_format=(DAY_FORMAT.WEEKDAY, DAY_FORMAT.DAY_OF_MONTH), columns=(PICKER.DAY, PICKER.MONTH, PICKER.YEAR),
modifier_key=MODIFIER_KEY.CTRL, return_unused_navigation_input=False, year_jump_len=50, space_between=2,
min_width_each_picker=9, year_align="center", month_align="center", day_align="center", topBar_align="center",
topBar_endCovered_prop=("", None, None), topBar_endExposed_prop=("───", None, None), bottomBar_align="center",
bottomBar_endCovered_prop=("", None, None), bottomBar_endExposed_prop=("───", None, None), highlight_prop=(None, None)):
assert (type(date_range) == self.__class__.RANGE), self.__class__._TYPE_ERR_MSG.format("<enum 'DatePicker.RANGE'>",
"'date_range'",
type(date_range))
for df in day_format:
assert (type(df) == self.__class__.DAY_FORMAT), self.__class__._TYPE_ERR_MSG.format("<enum 'DatePicker.DAY_FORMAT'>",
"all elements of 'day_format'",
type(df))
# Relevant for 'RANGE.ONLY_PAST' and 'RANGE.ONLY_FUTURE' to limit the respective choices.
self._initial_year = initial_date.year
self._initial_month = initial_date.month
self._initial_day = initial_date.day
# The date pool can be limited, so that only past or future dates are selectable. The initial date is included in the
# pool.
self._date_range = date_range
# The presentation of months and weekdays can be changed by passing alternative values (e.g. abbreviations or numerical
# representations).
self._month_names = month_names
self._day_names = day_names
# Since there are different needs regarding the appearance of the day picker, an iterable can be passed, which allows a
# customization of the presentation.
self._day_format = day_format
# Specifies the text alignment of the individual pickers. The year alignment is passed directly to the year picker.
self._month_align = month_align
self._day_align = day_align
# The default style of a list entry. Since only one list entry will be visible at a time and there is also off focus
# highlighting, the normal value can be 'None' (it is never shown).
self._item_attr = (None, highlight_prop[0])
# A full list of months. (From 'January' to 'December'.)
self._month_list = self._generate_months()
# Set the respective values depending on the date range.
min_year = datetime.MINYEAR
max_year = datetime.MAXYEAR
month_position = self._initial_month - 1
day_position = self._initial_day - 1
if date_range == self.__class__.RANGE.ALL:
initial_month_list = self._month_list
elif date_range == self.__class__.RANGE.ONLY_PAST:
max_year = self._initial_year
# The months of the very last year may be shorten.
self._shortened_month_list = self._generate_months(end=self._initial_month)
initial_month_list = self._shortened_month_list
elif date_range == self.__class__.RANGE.ONLY_FUTURE:
min_year = self._initial_year
# The months of the very first year may be shorten.
self._shortened_month_list = self._generate_months(start=self._initial_month)
initial_month_list = self._shortened_month_list
# The list may not start at 1 but some other day of month, therefore use the first list item.
month_position = 0
day_position = 0
else:
raise ValueError(self.__class__._VALUE_ERR_MSG.format(date_range))
# Create pickers.
self._year_picker = IntegerPicker(self._initial_year,
min_v=min_year,
max_v=max_year,
jump_len=year_jump_len,
on_selection_change=self._year_has_changed,
modifier_key=modifier_key,
return_unused_navigation_input=return_unused_navigation_input,
topBar_align=topBar_align,
topBar_endCovered_prop=topBar_endCovered_prop,
topBar_endExposed_prop=topBar_endExposed_prop,
bottomBar_align=bottomBar_align,
bottomBar_endCovered_prop=bottomBar_endCovered_prop,
bottomBar_endExposed_prop=bottomBar_endExposed_prop,
display_syntax="{:04}",
display_align=year_align,
display_prop=highlight_prop)
self._month_picker = IndicativeListBox(initial_month_list,
position=month_position,
on_selection_change=self._month_has_changed,
modifier_key=modifier_key,
return_unused_navigation_input=return_unused_navigation_input,
topBar_align=topBar_align,
topBar_endCovered_prop=topBar_endCovered_prop,
topBar_endExposed_prop=topBar_endExposed_prop,
bottomBar_align=bottomBar_align,
bottomBar_endCovered_prop=bottomBar_endCovered_prop,
bottomBar_endExposed_prop=bottomBar_endExposed_prop,
highlight_offFocus=highlight_prop[1])
self._day_picker = IndicativeListBox(self._generate_days(self._initial_year, self._initial_month),
position=day_position,
modifier_key=modifier_key,
return_unused_navigation_input=return_unused_navigation_input,
topBar_align=topBar_align,
topBar_endCovered_prop=topBar_endCovered_prop,
topBar_endExposed_prop=topBar_endExposed_prop,
bottomBar_align=bottomBar_align,
bottomBar_endCovered_prop=bottomBar_endCovered_prop,
bottomBar_endExposed_prop=bottomBar_endExposed_prop,
highlight_offFocus=highlight_prop[1])
# To mimic a selection widget, 'IndicativeListbox' is wrapped in a 'urwid.BoxAdapter'. Since two rows are used for the bars,
# size 3 makes exactly one list item visible.
boxed_month_picker = urwid.BoxAdapter(self._month_picker, 3)
boxed_day_picker = urwid.BoxAdapter(self._day_picker, 3)
# Replace the 'DatePicker.PICKER' elements of the parameter 'columns' with the corresponding pickers.
replacements = {self.__class__.PICKER.YEAR : self._year_picker,
self.__class__.PICKER.MONTH : boxed_month_picker,
self.__class__.PICKER.DAY : boxed_day_picker}
columns = recursively_replace(columns, replacements)
# wrap 'urwid.Columns'
super().__init__(urwid.Columns(columns,
min_width=min_width_each_picker,
dividechars=space_between))
def __repr__(self):
return "{}(date='{}', date_range='{}', initial_date='{}-{:02}-{:02}', selected_date='{}')".format(self.__class__.__name__,
self.get_date(),
self._date_range,
self._initial_year,
self._initial_month,
self._initial_day,
self.get_date())
# The returned widget is used for all list entries.
def _generate_item(self, cols, *, align="center"):
return urwid.AttrMap(SelectableRow(cols, align=align),
self._item_attr[0],
self._item_attr[1])
def _generate_months(self, start=1, end=12):
months = []
for month in range(start, end+1):
item = self._generate_item([self._month_names[month]], align=self._month_align)
# Add a new instance variable which holds the numerical value. This makes it easier to get the displayed value.
item._numerical_value = month
months.append(item)
return months
def _generate_days(self, year, month):
start = 1
weekday, end = calendar.monthrange(year, month) # end is included in the range
# If the date range is 'ONLY_PAST', the last month does not end as usual but on the specified day.
if (self._date_range == self.__class__.RANGE.ONLY_PAST) and (year == self._initial_year) and (month == self._initial_month):
end = self._initial_day
# If the date range is 'ONLY_FUTURE', the first month does not start as usual but on the specified day.
elif (self._date_range == self.__class__.RANGE.ONLY_FUTURE) and (year == self._initial_year) and (month == self._initial_month):
start = self._initial_day
weekday = calendar.weekday(year, month, start)
days = []
for day in range(start, end+1):
cols = []
# The 'DatePicker.DAY_FORMAT' elements of the iterable are translated into columns of the day picker. This allows the
# presentation to be customized.
for df in self._day_format:
if df == self.__class__.DAY_FORMAT.DAY_OF_MONTH:
cols.append(str(day))
elif df == self.__class__.DAY_FORMAT.DAY_OF_MONTH_TWO_DIGIT:
cols.append(str(day).zfill(2))
elif df == self.__class__.DAY_FORMAT.WEEKDAY:
cols.append(self._day_names[weekday])
else:
raise ValueError(self.__class__._VALUE_ERR_MSG.format(df))
item = self._generate_item(cols, align=self._day_align)
# Add a new instance variable which holds the numerical value. This makes it easier to get the displayed value.
item._numerical_value = day
# Keeps track of the weekday.
weekday = (weekday + 1) if (weekday < 6) else 0
days.append(item)
return days
def _year_has_changed(self, previous_year, current_year):
month_position_before_change = self._month_picker.get_selected_position()
# Since there are no years in 'RANGE.ALL' that do not have the full month range, the body never needs to be changed after
# initialization.
if self._date_range != self.__class__.RANGE.ALL:
# 'None' stands for trying to keep the old value.
provisional_position = None
# If the previous year was the specified year, the shortened month range must be replaced by the complete one. If this
# shortened month range does not begin at 'January', then the difference must be taken into account.
if previous_year == self._initial_year:
if self._date_range == self.__class__.RANGE.ONLY_FUTURE:
provisional_position = self._month_picker.get_selected_item()._numerical_value - 1
self._month_picker.set_body(self._month_list,
alternative_position=provisional_position)
# If the specified year is selected, the full month range must be replaced with the shortened one.
elif current_year == self._initial_year:
if self._date_range == self.__class__.RANGE.ONLY_FUTURE:
provisional_position = month_position_before_change - (self._initial_month - 1)
self._month_picker.set_body(self._shortened_month_list,
alternative_position=provisional_position)
# Since the month has changed, the corresponding method is called.
self._month_has_changed(month_position_before_change,
self._month_picker.get_selected_position(),
previous_year=previous_year)
def _month_has_changed(self, previous_position, current_position, *, previous_year=None):
# 'None' stands for trying to keep the old value.
provisional_position = None
current_year = self._year_picker.get_value()
# Out of range values are changed by 'IndicativeListBox' to the nearest valid values.
# If the date range is 'ONLY_FUTURE', it may be that a month does not start on the first day. In this case, the value must
# be changed to reflect this difference.
if self._date_range == self.__class__.RANGE.ONLY_FUTURE:
# If the current or previous year is the specified year and the month was the specified month, the value has an offset
# of the specified day. Therefore the deposited numerical value is used. ('-1' because it's an index.)
if ((current_year == self._initial_year) or (previous_year == self._initial_year)) and (previous_position == 0):
provisional_position = self._day_picker.get_selected_item()._numerical_value - 1
# If the current year is the specified year and the current month is the specified month, the month begins not with
# the first day, but with the specified day.
elif (current_year == self._initial_year) and (current_position == 0):
provisional_position = self._day_picker.get_selected_position() - (self._initial_day - 1)
self._day_picker.set_body(self._generate_days(current_year,
self._month_picker.get_selected_item()._numerical_value),
alternative_position=provisional_position)
def get_date(self):
return datetime.date(self._year_picker.get_value(),
self._month_picker.get_selected_item()._numerical_value,
self._day_picker.get_selected_item()._numerical_value)
def set_date(self, date):
# If the date range is limited, test for the new limit.
if self._date_range != self.__class__.RANGE.ALL:
limit = datetime.date(self._initial_year, self._initial_month, self._initial_day)
if (self._date_range == self.__class__.RANGE.ONLY_PAST) and (date > limit):
raise ValueError("The passed date is outside the upper bound of the date range.")
elif (self._date_range == self.__class__.RANGE.ONLY_FUTURE) and (date < limit):
raise ValueError("The passed date is outside the lower bound of the date range.")
year = date.year
month = date.month
day = date.day
# Set the new values, if needed.
if year != self._year_picker.get_value():
self._year_picker.set_value(year)
if month != self._month_picker.get_selected_item()._numerical_value:
month_position = month - 1 # '-1' because it's an index.
if (self._date_range == self.__class__.RANGE.ONLY_FUTURE) and (year == self._initial_year):
# If the value should be negative, the behavior of 'IndicativeListBox' shows effect and position 0 is selected.
month_position = month_position - (self._initial_month - 1)
self._month_picker.select_item(month_position)
if day != self._day_picker.get_selected_item()._numerical_value:
day_position = day - 1 # '-1' because it's an index.
if (self._date_range == self.__class__.RANGE.ONLY_FUTURE) and (year == self._initial_year) and (month == self._initial_month):
day_position = day_position - (self._initial_day - 1)
self._day_picker.select_item(day_position)

View file

@ -0,0 +1,433 @@
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
from ..assisting_modules.modifier_key import MODIFIER_KEY # pylint: disable=unused-import
import enum
import random
import urwid
class IndicativeListBox(urwid.WidgetWrap):
"""Adds two bars to a 'urwid.ListBox', that make it obvious that due to limited space only a part of the list items is displayed."""
_TYPE_ERR_MSG = "type {} was expected for {}, but found: {}."
_VALUE_ERR_MSG = "unrecognized value: {}."
# These values are translated by 'get_nearest_valid_position()' into the corresponding int values.
class POSITION(enum.Enum):
LAST = 1
MIDDLE = 2
RANDOM = 3
def __init__(self, body, *, position=0, on_selection_change=None, initialization_is_selection_change=False,
modifier_key=MODIFIER_KEY.NONE, return_unused_navigation_input=True, topBar_align="center",
topBar_endCovered_prop=("", None, None), topBar_endExposed_prop=("───", None, None), bottomBar_align="center",
bottomBar_endCovered_prop=("", None, None), bottomBar_endExposed_prop=("───", None, None), highlight_offFocus=None):
# If not already done, wrap each item of the body in an 'urwid.AttrMap'. This is necessary to enable off focus highlighting.
body[:] = [urwid.AttrMap(item, None) if not isinstance(item, urwid.AttrMap) else item
for item in body]
# The body of the 'urwid.Frame' is a 'urwid.ListBox'.
self._listbox = urwid.ListBox(body)
# Select the specified list position, or the nearest valid one.
nearest_valid_position = self._get_nearest_valid_position(position)
if nearest_valid_position is not None:
self._listbox.set_focus(nearest_valid_position)
# The bars are just 'urwid.Text' widgets.
self._top_bar = urwid.AttrMap(urwid.Text("", align=topBar_align),
None)
self._bottom_bar = urwid.AttrMap(urwid.Text("", align=bottomBar_align),
None)
# Wrap 'urwid.Frame'.
super().__init__(urwid.Frame(self._listbox,
header=self._top_bar,
footer=self._bottom_bar,
focus_part="body"))
# During the initialization of 'urwid.AttrMap', the value can be passed as non-dict. After initializing, its value can be
# manipulated by passing a dict. The dicts I create below will be used later to change the appearance of the bars.
self._topBar_endCovered_markup = topBar_endCovered_prop[0]
self._topBar_endCovered_focus = {None:topBar_endCovered_prop[1]}
self._topBar_endCovered_offFocus = {None:topBar_endCovered_prop[2]}
self._topBar_endExposed_markup = topBar_endExposed_prop[0]
self._topBar_endExposed_focus = {None:topBar_endExposed_prop[1]}
self._topBar_endExposed_offFocus = {None:topBar_endExposed_prop[2]}
self._bottomBar_endCovered_markup = bottomBar_endCovered_prop[0]
self._bottomBar_endCovered_focus = {None:bottomBar_endCovered_prop[1]}
self._bottomBar_endCovered_offFocus = {None:bottomBar_endCovered_prop[2]}
self._bottomBar_endExposed_markup = bottomBar_endExposed_prop[0]
self._bottomBar_endExposed_focus = {None:bottomBar_endExposed_prop[1]}
self._bottomBar_endExposed_offFocus = {None:bottomBar_endExposed_prop[2]}
# This is used to highlight the selected item when the widget does not have the focus.
self._highlight_offFocus = {None:highlight_offFocus}
self._last_focus_state = None
self._original_item_attr_map = None
# A hook which is triggered when the selection changes.
self.on_selection_change = on_selection_change
# Initialization can be seen as a special case of selection change. This is interesting in combination with the hook.
self._initialization_is_selection_change = initialization_is_selection_change
# 'MODIFIER_KEY' changes the behavior of the list box, so that it responds only to modified input. ('up' => 'ctrl up')
self._modifier_key = modifier_key
# If the list item at the top is selected and you navigate further upwards, the input is normally not swallowed by the
# list box, but passed on so that other widgets can interpret it. This may result in transferring the focus.
self._return_unused_navigation_input = return_unused_navigation_input
# Is 'on_selection_change' triggered during the initialization?
if initialization_is_selection_change and (on_selection_change is not None):
on_selection_change(None,
self.get_selected_position())
def __repr__(self):
return "{}(body='{}', position='{}')".format(self.__class__.__name__,
self.get_body(),
self.get_selected_position())
def render(self, size, focus=False):
just_maxcol = (size[0],)
# The size also includes the two bars, so subtract these.
modified_size = (size[0], # cols
size[1] - self._top_bar.rows(just_maxcol) - self._bottom_bar.rows(just_maxcol)) # rows
# Evaluates which ends are visible and calculates how many list entries are hidden above/below. This is a modified form
# of 'urwid.ListBox.ends_visible()'.
middle, top, bottom = self._listbox.calculate_visible(modified_size, focus=focus)
if middle is None: # empty list box
top_is_visible = True
bottom_is_visible = True
else:
top_is_visible = False
bottom_is_visible = False
trim_top, above = top
trim_bottom, below = bottom
if trim_top == 0:
pos = above[-1][1] if (len(above) != 0) else middle[2]
if self._listbox._body.get_prev(pos) == (None,None):
top_is_visible = True
else:
covered_above = pos
else:
covered_above = self.rearmost_position()
if trim_bottom == 0:
row_offset, _, pos, rows, _ = middle
row_offset += rows # Include the selected position.
for _, pos, rows in below: # 'pos' is overridden
row_offset += rows
if (row_offset < modified_size[1]) or (self._listbox._body.get_next(pos) == (None, None)):
bottom_is_visible = True
else:
covered_below = self.rearmost_position() - pos
else:
covered_below = self.rearmost_position()
# Changes the appearance of the bar at the top depending on whether the first list item is visible and the widget has
# the focus.
if top_is_visible:
self._top_bar.original_widget.set_text(self._topBar_endExposed_markup)
self._top_bar.set_attr_map(self._topBar_endExposed_focus
if focus else self._topBar_endExposed_offFocus)
else:
self._top_bar.original_widget.set_text(self._topBar_endCovered_markup.format(covered_above))
self._top_bar.set_attr_map(self._topBar_endCovered_focus
if focus else self._topBar_endCovered_offFocus)
# Changes the appearance of the bar at the bottom depending on whether the last list item is visible and the widget
# has the focus.
if bottom_is_visible:
self._bottom_bar.original_widget.set_text(self._bottomBar_endExposed_markup)
self._bottom_bar.set_attr_map(self._bottomBar_endExposed_focus
if focus else self._bottomBar_endExposed_offFocus)
else:
self._bottom_bar.original_widget.set_text(self._bottomBar_endCovered_markup.format(covered_below))
self._bottom_bar.set_attr_map(self._bottomBar_endCovered_focus
if focus else self._bottomBar_endCovered_offFocus)
# The highlighting in urwid is bound to the focus. This means that the selected item is only distinguishable as long as
# the widget has the focus. To compensate this, the color scheme of the selected item is otherwiese temporarily changed.
if focus and not self._last_focus_state and (self._original_item_attr_map is not None):
# Resets the appearance of the selected item to its original value.
self._listbox.focus.set_attr_map(self._original_item_attr_map)
elif (self._highlight_offFocus is not None) \
and not focus \
and (self._last_focus_state or (self._last_focus_state is None)) \
and not self.body_is_empty():
# Store the 'attr_map' of the selected item and then change it to accomplish off focus highlighting.
self._original_item_attr_map = self._listbox.focus.get_attr_map()
self._listbox.focus.set_attr_map(self._highlight_offFocus)
# Store the last focus to do/undo the off focus highlighting only if the focus has really changed and not if the
# widget is re-rendered because the terminal size has changed or similar.
self._last_focus_state = focus
return super().render(size, focus=focus)
def keypress(self, size, key):
just_maxcol = (size[0],)
# The size also includes the two bars, so subtract these.
modified_size = (size[0], # cols
size[1] - self._top_bar.rows(just_maxcol) - self._bottom_bar.rows(just_maxcol)) # rows
# Store the focus position before passing the keystroke to the contained list box. That way, it can be compared with the
# position after the input is processed. If the list box body is empty, store None.
focus_position_before_input = self.get_selected_position()
# A keystroke is changed to a modified one ('up' => 'ctrl up'). This prevents the widget from responding when the arrows
# keys are used to navigate between widgets. That way it can be used in a 'urwid.Pile' or similar.
if key == self._modifier_key.prepend_to("up"):
key = self._pass_key_to_contained_listbox(modified_size, "up")
elif key == self._modifier_key.prepend_to("down"):
key = self._pass_key_to_contained_listbox(modified_size, "down")
elif key == self._modifier_key.prepend_to("page up"):
key = self._pass_key_to_contained_listbox(modified_size, "page up")
elif key == self._modifier_key.prepend_to("page down"):
key = self._pass_key_to_contained_listbox(modified_size, "page down")
elif key == self._modifier_key.prepend_to("home"):
# Check if the first list item is already selected.
if (focus_position_before_input is not None) and (focus_position_before_input != 0):
self.select_first_item()
key = None
elif not self._return_unused_navigation_input:
key = None
elif key == self._modifier_key.prepend_to("end"):
# Check if the last list item is already selected.
if (focus_position_before_input is not None) and (focus_position_before_input != self.rearmost_position()):
self.select_last_item()
key = None
elif not self._return_unused_navigation_input:
key = None
elif key not in ("up", "down", "page up", "page down", "home", "end"):
key = self._listbox.keypress(modified_size, key)
focus_position_after_input = self.get_selected_position()
# If the focus position has changed, execute the hook (if existing).
if (focus_position_before_input != focus_position_after_input) and (self.on_selection_change is not None):
self.on_selection_change(focus_position_before_input,
focus_position_after_input)
return key
def mouse_event(self, size, event, button, col, row, focus):
just_maxcol = (size[0],)
topBar_rows = self._top_bar.rows(just_maxcol)
bottomBar_rows = self._bottom_bar.rows(just_maxcol)
# The size also includes the two bars, so subtract these.
modified_size = (size[0], # cols
size[1] - topBar_rows - bottomBar_rows) # rows
was_handeled = False
# An event is changed to a modified one ('mouse press' => 'ctrl mouse press'). This prevents the widget from responding
# when mouse buttons are also used to navigate between widgets.
if event == self._modifier_key.prepend_to("mouse press"):
# Store the focus position before passing the input to the contained list box. That way, it can be compared with the
# position after the input is processed. If the list box body is empty, store None.
focus_position_before_input = self.get_selected_position()
# left mouse button, if not top bar or bottom bar.
if (button == 1.0) and (topBar_rows <= row < (size[1] - bottomBar_rows)):
# Because 'row' includes the top bar, the offset must be substracted before passing it to the contained list box.
result = self._listbox.mouse_event(modified_size, event, button, col, (row - topBar_rows), focus)
was_handeled = result if self._return_unused_navigation_input else True
# mousewheel up
elif button == 4.0:
was_handeled = self._pass_key_to_contained_listbox(modified_size, "page up")
# mousewheel down
elif button == 5.0:
was_handeled = self._pass_key_to_contained_listbox(modified_size, "page down")
focus_position_after_input = self.get_selected_position()
# If the focus position has changed, execute the hook (if existing).
if (focus_position_before_input != focus_position_after_input) and (self.on_selection_change is not None):
self.on_selection_change(focus_position_before_input,
focus_position_after_input)
return was_handeled
# Pass the keystroke to the original widget. If it is not used, evaluate the corresponding variable to decide if it gets
# swallowed or not.
def _pass_key_to_contained_listbox(self, size, key):
result = self._listbox.keypress(size, key)
return result if self._return_unused_navigation_input else None
def get_body(self):
return self._listbox.body
def body_len(self):
return len(self.get_body())
def rearmost_position(self):
return len(self.get_body()) - 1 # last valid index
def body_is_empty(self):
return self.body_len() == 0
def position_is_valid(self, position):
return (position < self.body_len()) and (position >= 0)
# If the passed position is valid, it is returned. Otherwise, the nearest valid position is returned. This ensures that
# positions which are out of range do not result in an error.
def _get_nearest_valid_position(self, position):
if self.body_is_empty():
return None
pos_type = type(position)
if pos_type == int:
if self.position_is_valid(position):
return position
elif position < 0:
return 0
else:
return self.rearmost_position()
elif pos_type == self.__class__.POSITION:
if position == self.__class__.POSITION.LAST:
return self.rearmost_position()
elif position == self.__class__.POSITION.MIDDLE:
return self.body_len() // 2
elif position == self.__class__.POSITION.RANDOM:
return random.randint(0, self.rearmost_position())
else:
raise ValueError(self.__class__._VALUE_ERR_MSG.format(position))
else:
raise TypeError(self.__class__._TYPE_ERR_MSG.format("<class 'int'> or <enum 'IndicativeListBox.POSITION'>",
"'position'",
pos_type))
def get_item(self, position):
if self.position_is_valid(position):
body = self.get_body()
return body[position]
else:
return None
def get_first_item(self):
return self.get_item(0)
def get_last_item(self):
return self.get_item(self.rearmost_position())
def get_selected_item(self):
return self._listbox.focus
def get_selected_position(self):
return self._listbox.focus_position if not self.body_is_empty() else None
def first_item_is_selected(self):
return self.get_selected_position() == 0
def last_item_is_selected(self):
return self.get_selected_position() == self.rearmost_position()
def _reset_highlighting(self):
# Resets the appearance of the selected item to its original value, if off focus highlighting is active.
if not self._last_focus_state and (self._original_item_attr_map is not None):
self._listbox.focus.set_attr_map(self._original_item_attr_map)
# The next time the widget is rendered, the highlighting is redone.
self._original_item_attr_map = None
self._last_focus_state = None
def set_body(self, body, *, alternative_position=None):
focus_position_before_change = self.get_selected_position()
self._reset_highlighting()
# Wrap each item in an 'urwid.AttrMap', if not already done.
self._listbox.body[:] = [urwid.AttrMap(item, None) if not isinstance(item, urwid.AttrMap) else item
for item in body]
# Normally it is tried to hold the focus position. If this is not desired, a position can be passed.
if alternative_position is not None:
nearest_valid_position = self._get_nearest_valid_position(alternative_position)
if nearest_valid_position is not None:
# Because the widget has been re-rendered, the off focus highlighted item must be restored to its original state.
self._reset_highlighting()
self._listbox.set_focus(nearest_valid_position)
# If an initialization is considered a selection change, execute the hook (if existing).
if self._initialization_is_selection_change and (self.on_selection_change is not None):
self.on_selection_change(focus_position_before_change,
self.get_selected_position())
def select_item(self, position):
focus_position_before_change = self.get_selected_position()
nearest_valid_position = self._get_nearest_valid_position(position)
# Focus the new position, if possible and not already focused.
if (nearest_valid_position is not None) and (nearest_valid_position != focus_position_before_change):
self._reset_highlighting()
self._listbox.set_focus(nearest_valid_position)
# Execute the hook (if existing).
if (self.on_selection_change is not None):
self.on_selection_change(focus_position_before_change,
nearest_valid_position)
def select_first_item(self):
self.select_item(0)
def select_last_item(self):
self.select_item(self.rearmost_position())
def delete_position(self, position):
# The saved properties get reseted, just in case that the appearance of the items differs.
self._reset_highlighting()
body = self.get_body()
del body[position]
def delete_selected_position(self):
pos = self.get_selected_position()
# If the list body is not empty, delete the selected item.
if pos is not None:
self.delete_position(pos)

View file

@ -0,0 +1,277 @@
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
from ..assisting_modules.modifier_key import MODIFIER_KEY # pylint: disable=unused-import
from .selectable_row import SelectableRow
import sys # pylint: disable=unused-import
import urwid
class IntegerPicker(urwid.WidgetWrap):
"""Serves as a selector for integer numbers."""
def __init__(self, value, *, min_v=(-sys.maxsize - 1), max_v=sys.maxsize, step_len=1, jump_len=100, on_selection_change=None,
initialization_is_selection_change=False, modifier_key=MODIFIER_KEY.NONE, ascending=True,
return_unused_navigation_input=True, topBar_align="center", topBar_endCovered_prop=("", None, None),
topBar_endExposed_prop=("───", None, None), bottomBar_align="center", bottomBar_endCovered_prop=("", None, None),
bottomBar_endExposed_prop=("───", None, None), display_syntax="{}", display_align="center", display_prop=(None, None)):
assert (min_v <= max_v), "'min_v' must be less than or equal to 'max_v'."
assert (min_v <= value <= max_v), "'min_v <= value <= max_v' must be True."
self._value = value
self._minimum = min_v
self._maximum = max_v
# Specifies how far to move in the respective direction when the keys 'up/down' are pressed.
self._step_len = step_len
# Specifies how far to jump in the respective direction when the keys 'page up/down' or the mouse events 'wheel up/down'
# are passed.
self._jump_len = jump_len
# A hook which is triggered when the value changes.
self.on_selection_change = on_selection_change
# 'MODIFIER_KEY' changes the behavior, so that the widget responds only to modified input. ('up' => 'ctrl up')
self._modifier_key = modifier_key
# Specifies whether moving upwards represents a decrease or an increase of the value.
self._ascending = ascending
# If the minimum has been reached and an attempt is made to select an even smaller value, the input is normally not
# swallowed by the widget, but passed on so that other widgets can interpret it. This may result in transferring the focus.
self._return_unused_navigation_input = return_unused_navigation_input
# The bars are just 'urwid.Text' widgets.
self._top_bar = urwid.AttrMap(urwid.Text("", topBar_align),
None)
self._bottom_bar = urwid.AttrMap(urwid.Text("", bottomBar_align),
None)
# During the initialization of 'urwid.AttrMap', the value can be passed as non-dict. After initializing, its value can be
# manipulated by passing a dict. The dicts I create below will be used later to change the appearance of the widgets.
self._topBar_endCovered_markup = topBar_endCovered_prop[0]
self._topBar_endCovered_focus = {None:topBar_endCovered_prop[1]}
self._topBar_endCovered_offFocus = {None:topBar_endCovered_prop[2]}
self._topBar_endExposed_markup = topBar_endExposed_prop[0]
self._topBar_endExposed_focus = {None:topBar_endExposed_prop[1]}
self._topBar_endExposed_offFocus = {None:topBar_endExposed_prop[2]}
self._bottomBar_endCovered_markup = bottomBar_endCovered_prop[0]
self._bottomBar_endCovered_focus = {None:bottomBar_endCovered_prop[1]}
self._bottomBar_endCovered_offFocus = {None:bottomBar_endCovered_prop[2]}
self._bottomBar_endExposed_markup = bottomBar_endExposed_prop[0]
self._bottomBar_endExposed_focus = {None:bottomBar_endExposed_prop[1]}
self._bottomBar_endExposed_offFocus = {None:bottomBar_endExposed_prop[2]}
# Format the number before displaying it. That way it is easier to read.
self._display_syntax = display_syntax
# The current value is displayed via this widget.
self._display = SelectableRow([display_syntax.format(value)],
align=display_align)
display_attr = urwid.AttrMap(self._display,
display_prop[1],
display_prop[0])
# wrap 'urwid.Pile'
super().__init__(urwid.Pile([self._top_bar,
display_attr,
self._bottom_bar]))
# Is 'on_selection_change' triggered during the initialization?
if initialization_is_selection_change and (on_selection_change is not None):
on_selection_change(None, value)
def __repr__(self):
return "{}(value='{}', min_v='{}', max_v='{}', ascending='{}')".format(self.__class__.__name__,
self._value,
self._minimum,
self._maximum,
self._ascending)
def render(self, size, focus=False):
# Changes the appearance of the bar at the top depending on whether the upper limit is reached.
if self._value == (self._minimum if self._ascending else self._maximum):
self._top_bar.original_widget.set_text(self._topBar_endExposed_markup)
self._top_bar.set_attr_map(self._topBar_endExposed_focus
if focus else self._topBar_endExposed_offFocus)
else:
self._top_bar.original_widget.set_text(self._topBar_endCovered_markup)
self._top_bar.set_attr_map(self._topBar_endCovered_focus
if focus else self._topBar_endCovered_offFocus)
# Changes the appearance of the bar at the bottom depending on whether the lower limit is reached.
if self._value == (self._maximum if self._ascending else self._minimum):
self._bottom_bar.original_widget.set_text(self._bottomBar_endExposed_markup)
self._bottom_bar.set_attr_map(self._bottomBar_endExposed_focus
if focus else self._bottomBar_endExposed_offFocus)
else:
self._bottom_bar.original_widget.set_text(self._bottomBar_endCovered_markup)
self._bottom_bar.set_attr_map(self._bottomBar_endCovered_focus
if focus else self._bottomBar_endCovered_offFocus)
return super().render(size, focus=focus)
def keypress(self, size, key):
# A keystroke is changed to a modified one ('up' => 'ctrl up'). This prevents the widget from responding when the arrows
# keys are used to navigate between widgets. That way it can be used in a 'urwid.Pile' or similar.
if key == self._modifier_key.prepend_to("up"):
successful = self._change_value(-self._step_len)
elif key == self._modifier_key.prepend_to("down"):
successful = self._change_value(self._step_len)
elif key == self._modifier_key.prepend_to("page up"):
successful = self._change_value(-self._jump_len)
elif key == self._modifier_key.prepend_to("page down"):
successful = self._change_value(self._jump_len)
elif key == self._modifier_key.prepend_to("home"):
successful = self._change_value(float("-inf"))
elif key == self._modifier_key.prepend_to("end"):
successful = self._change_value(float("inf"))
else:
successful = False
return key if not successful else None
def mouse_event(self, size, event, button, col, row, focus):
if focus:
# An event is changed to a modified one ('mouse press' => 'ctrl mouse press'). This prevents the original widget from
# responding when mouse buttons are also used to navigate between widgets.
if event == self._modifier_key.prepend_to("mouse press"):
# mousewheel up
if button == 4.0:
result = self._change_value(-self._jump_len)
return result if self._return_unused_navigation_input else True
# mousewheel down
elif button == 5.0:
result = self._change_value(self._jump_len)
return result if self._return_unused_navigation_input else True
return False
# This method tries to change the value depending on the desired arrangement and returns True if this change was successful.
def _change_value(self, summand):
value_before_input = self._value
if self._ascending:
new_value = self._value + summand
if summand < 0:
# If the corresponding limit has already been reached, then determine whether the unused input should be
# returned or swallowed.
if self._value == self._minimum:
return not self._return_unused_navigation_input
# If the new value stays within the permitted range, use it.
elif new_value > self._minimum:
self._value = new_value
# The permitted range would be exceeded, so the limit is set instead.
else:
self._value = self._minimum
elif summand > 0:
if self._value == self._maximum:
return not self._return_unused_navigation_input
elif new_value < self._maximum:
self._value = new_value
else:
self._value = self._maximum
else:
new_value = self._value - summand
if summand < 0:
if self._value == self._maximum:
return not self._return_unused_navigation_input
elif new_value < self._maximum:
self._value = new_value
else:
self._value = self._maximum
elif summand > 0:
if self._value == self._minimum:
return not self._return_unused_navigation_input
elif new_value > self._minimum:
self._value = new_value
else:
self._value = self._minimum
# Update the displayed value.
self._display.set_contents([self._display_syntax.format(self._value)])
# If the value has changed, execute the hook (if existing).
if (value_before_input != self._value) and (self.on_selection_change is not None):
self.on_selection_change(value_before_input,
self._value)
return True
def get_value(self):
return self._value
def set_value(self, value):
if not (self._minimum <= value <= self._maximum):
raise ValueError("'minimum <= value <= maximum' must be True.")
if value != self._value:
value_before_change = self._value
self._value = value
# Update the displayed value.
self._display.set_contents([self._display_syntax.format(self._value)])
# Execute the hook (if existing).
if (self.on_selection_change is not None):
self.on_selection_change(value_before_change, self._value)
def set_to_minimum(self):
self.set_value(self._minimum)
def set_to_maximum(self):
self.set_value(self._maximum)
def set_minimum(self, new_min):
if new_min > self._maximum:
raise ValueError("'new_min' must be less than or equal to the maximum value.")
self._minimum = new_min
if self._value < new_min:
self.set_to_minimum()
def set_maximum(self, new_max):
if new_max < self._minimum:
raise ValueError("'new_max' must be greater than or equal to the minimum value.")
self._maximum = new_max
if self._value > new_max:
self.set_to_maximum()
def minimum_is_selected(self):
return self._value == self._minimum
def maximum_is_selected(self):
return self._value == self._maximum

View file

@ -0,0 +1,40 @@
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
import urwid
class MessageDialog(urwid.WidgetWrap):
"""Wraps 'urwid.Overlay' to show a message and expects a reaction from the user."""
def __init__(self, contents, btns, overlay_size, *, contents_align="left", space_between_btns=2, title="", title_align="center",
background=urwid.SolidFill("#"), overlay_align=("center", "middle"), overlay_min_size=(None, None), left=0, right=0,
top=0, bottom=0):
# Message part
texts = [urwid.Text(content, align=contents_align)
for content in contents]
# Lower part
lower_part = [urwid.Divider(" "),
urwid.Columns(btns, dividechars=space_between_btns)]
# frame
line_box = urwid.LineBox(urwid.Pile(texts + lower_part),
title=title,
title_align=title_align)
# Wrap 'urwid.Overlay'
super().__init__(urwid.Overlay(urwid.Filler(line_box),
background,
overlay_align[0],
overlay_size[0],
overlay_align[1],
overlay_size[1],
min_width=overlay_min_size[0],
min_height=overlay_min_size[1],
left=left,
right=right,
top=top,
bottom=bottom))

View file

@ -0,0 +1,47 @@
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
import urwid
class SelectableRow(urwid.WidgetWrap):
"""Wraps 'urwid.Columns' to make it selectable.
This class has been slightly modified, but essentially corresponds to this class posted on stackoverflow.com:
https://stackoverflow.com/questions/52106244/how-do-you-combine-multiple-tui-forms-to-write-more-complex-applications#answer-52174629"""
def __init__(self, contents, *, align="left", on_select=None, space_between=2):
# A list-like object, where each element represents the value of a column.
self.contents = contents
self._columns = urwid.Columns([urwid.Text(c, align=align) for c in contents],
dividechars=space_between)
# Wrap 'urwid.Columns'.
super().__init__(self._columns)
# A hook which defines the behavior that is executed when a specified key is pressed.
self.on_select = on_select
def __repr__(self):
return "{}(contents='{}')".format(self.__class__.__name__,
self.contents)
def selectable(self):
return True
def keypress(self, size, key):
if (key == "enter") and (self.on_select is not None):
self.on_select(self)
key = None
return key
def set_contents(self, contents):
# Update the list record inplace...
self.contents[:] = contents
# ... and update the displayed items.
for t, (w, _) in zip(contents, self._columns.contents):
w.set_text(t)