diff --git a/nomadnet/vendor/additional_urwid_widgets/LICENSE b/nomadnet/vendor/additional_urwid_widgets/LICENSE new file mode 100644 index 0000000..0bf84bc --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 AFoeee + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/nomadnet/vendor/additional_urwid_widgets/__init__.py b/nomadnet/vendor/additional_urwid_widgets/__init__.py new file mode 100644 index 0000000..d7b0867 --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/__init__.py @@ -0,0 +1,10 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + + +from .assisting_modules.modifier_key import MODIFIER_KEY +from .widgets.date_picker import DatePicker +from .widgets.indicative_listbox import IndicativeListBox +from .widgets.integer_picker import IntegerPicker +from .widgets.message_dialog import MessageDialog +from .widgets.selectable_row import SelectableRow \ No newline at end of file diff --git a/nomadnet/vendor/additional_urwid_widgets/assisting_modules/__init__.py b/nomadnet/vendor/additional_urwid_widgets/assisting_modules/__init__.py new file mode 100644 index 0000000..b16fa58 --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/assisting_modules/__init__.py @@ -0,0 +1,2 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/nomadnet/vendor/additional_urwid_widgets/assisting_modules/modifier_key.py b/nomadnet/vendor/additional_urwid_widgets/assisting_modules/modifier_key.py new file mode 100644 index 0000000..53c1e11 --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/assisting_modules/modifier_key.py @@ -0,0 +1,26 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + + +import enum + + +class MODIFIER_KEY(enum.Enum): + """Represents modifier keys such as 'ctrl', 'shift' and so on. + Not every combination of modifier and input is useful.""" + + NONE = "" + SHIFT = "shift" + ALT = "meta" + CTRL = "ctrl" + SHIFT_ALT = "shift meta" + SHIFT_CTRL = "shift ctrl" + ALT_CTRL = "meta ctrl" + SHIFT_ALT_CTRL = "shift meta ctrl" + + def append_to(self, text, separator=" "): + return (text + separator + self.value) if (self != self.__class__.NONE) else text + + def prepend_to(self, text, separator=" "): + return (self.value + separator + text) if (self != self.__class__.NONE) else text + \ No newline at end of file diff --git a/nomadnet/vendor/additional_urwid_widgets/assisting_modules/useful_functions.py b/nomadnet/vendor/additional_urwid_widgets/assisting_modules/useful_functions.py new file mode 100644 index 0000000..946a2ab --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/assisting_modules/useful_functions.py @@ -0,0 +1,47 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- + +"""A non-thematic collection of useful functions.""" + + +def recursively_replace(original, replacements, include_original_keys=False): + """Clones an iterable and recursively replaces specific values.""" + + # If this function would be called recursively, the parameters 'replacements' and 'include_original_keys' would have to be + # passed each time. Therefore, a helper function with a reduced parameter list is used for the recursion, which nevertheless + # can access the said parameters. + + def _recursion_helper(obj): + #Determine if the object should be replaced. If it is not hashable, the search will throw a TypeError. + try: + if obj in replacements: + return replacements[obj] + except TypeError: + pass + + # An iterable is recursively processed depending on its class. + if hasattr(obj, "__iter__") and not isinstance(obj, (str, bytes, bytearray)): + if isinstance(obj, dict): + contents = {} + + for key, val in obj.items(): + new_key = _recursion_helper(key) if include_original_keys else key + new_val = _recursion_helper(val) + + contents[new_key] = new_val + + else: + contents = [] + + for element in obj: + new_element = _recursion_helper(element) + + contents.append(new_element) + + # Use the same class as the original. + return obj.__class__(contents) + + # If it is not replaced and it is not an iterable, return it. + return obj + + return _recursion_helper(original) diff --git a/nomadnet/vendor/additional_urwid_widgets/widgets/__init__.py b/nomadnet/vendor/additional_urwid_widgets/widgets/__init__.py new file mode 100644 index 0000000..b16fa58 --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/widgets/__init__.py @@ -0,0 +1,2 @@ +#! /usr/bin/env python3 +# -*- coding: utf-8 -*- diff --git a/nomadnet/vendor/additional_urwid_widgets/widgets/date_picker.py b/nomadnet/vendor/additional_urwid_widgets/widgets/date_picker.py new file mode 100644 index 0000000..8d53bd8 --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/widgets/date_picker.py @@ -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("", + "'date_range'", + type(date_range)) + + for df in day_format: + assert (type(df) == self.__class__.DAY_FORMAT), self.__class__._TYPE_ERR_MSG.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) + \ No newline at end of file diff --git a/nomadnet/vendor/additional_urwid_widgets/widgets/indicative_listbox.py b/nomadnet/vendor/additional_urwid_widgets/widgets/indicative_listbox.py new file mode 100644 index 0000000..f49bd52 --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/widgets/indicative_listbox.py @@ -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(" or ", + "'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) diff --git a/nomadnet/vendor/additional_urwid_widgets/widgets/integer_picker.py b/nomadnet/vendor/additional_urwid_widgets/widgets/integer_picker.py new file mode 100644 index 0000000..b52ad6f --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/widgets/integer_picker.py @@ -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 + \ No newline at end of file diff --git a/nomadnet/vendor/additional_urwid_widgets/widgets/message_dialog.py b/nomadnet/vendor/additional_urwid_widgets/widgets/message_dialog.py new file mode 100644 index 0000000..9100cd9 --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/widgets/message_dialog.py @@ -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)) + \ No newline at end of file diff --git a/nomadnet/vendor/additional_urwid_widgets/widgets/selectable_row.py b/nomadnet/vendor/additional_urwid_widgets/widgets/selectable_row.py new file mode 100644 index 0000000..d7fd644 --- /dev/null +++ b/nomadnet/vendor/additional_urwid_widgets/widgets/selectable_row.py @@ -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) +