diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 789168ec5..273190075 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,8 @@ not released yet * optimization in ikhal when editing events in the far future or past * FIX an issue in ikhal with updating the view of the event list after editing an event +* NEW properties of ikhal themes (dark and light) can now be overriden from the + config file (via the new [palette] section, check the documenation) 0.11.2 ====== diff --git a/khal/settings/khal.spec b/khal/settings/khal.spec index fb9e50f64..ec4e8eefd 100644 --- a/khal/settings/khal.spec +++ b/khal/settings/khal.spec @@ -257,7 +257,7 @@ blank_line_before_day = boolean(default=False) # `khal/settings/khal.spec` in the section `[default]` of the property `theme`. # # __ http://urwid.org/manual/displayattributes.html -# .. _github: # https://github.com/pimutils/khal/issues +# .. _github: https://github.com/pimutils/khal/issues theme = option('dark', 'light', default='dark') # Whether to show a visible frame (with *box drawing* characters) around some @@ -324,3 +324,36 @@ multiple_on_overflow = boolean(default=False) # actually disables highlighting for events that should use the # default color. default_color = color(default='') + +# Override ikhal's color theme with a custom palette. This is useful to style +# certain elements of ikhal individually. +# Palette entries take the form of `key = foreground, background, mono, +# foreground_high, background_high` where foreground and background are used in +# "low color mode" and foreground_high and background_high are used in "high +# color mode" and mono if only monocolor is supported. If you don't want to set +# a value for a certain color, use an empty string (`''`). +# Valid entries for low color mode are listed on the `urwid website +# `_. For +# high color mode you can use any valid 24-bit color value, e.g. `'#ff0000'`. +# +# .. note:: +# 24-bit colors must be enclosed in single quotes to be parsed correctly, +# otherwise the `#` will be interpreted as a comment. +# +# Most modern terminals should support high color mode. +# +# Example entry (particular ugly): +# +# .. highlight:: ini +# +# :: +# +# [palette] +# header = light red, default, default, '#ff0000', default +# edit = '', '', 'bold', '#FF00FF', '#12FF14' +# footer = '', '', '', '#121233', '#656599' +# +# See the default palettes in `khal/ui/colors.py` for all available keys. +# If you can't theme an element in ikhal, please open an issue on `github +# `_. +[palette] diff --git a/khal/settings/utils.py b/khal/settings/utils.py index f01c26fdf..4dac92987 100644 --- a/khal/settings/utils.py +++ b/khal/settings/utils.py @@ -69,11 +69,10 @@ def is_timedelta(string: str) -> dt.timedelta: raise VdtValueError(f"Invalid timedelta: {string}") -def weeknumber_option(option: str) -> Union[str, Literal[False]]: +def weeknumber_option(option: str) -> Union[Literal['left', 'right'], Literal[False]]: """checks if *option* is a valid value :param option: the option the user set in the config file - :type option: str :returns: 'off', 'left', 'right' or False """ option = option.lower() @@ -89,11 +88,10 @@ def weeknumber_option(option: str) -> Union[str, Literal[False]]: "'off', 'left' or 'right'") -def monthdisplay_option(option: str) -> str: +def monthdisplay_option(option: str) -> Literal['firstday', 'firstfullweek']: """checks if *option* is a valid value :param option: the option the user set in the config file - :returns: firstday, firstfullweek """ option = option.lower() if option == 'firstday': @@ -215,6 +213,17 @@ def get_vdir_type(_: str) -> str: # TODO implement return 'calendar' +def validate_palette_entry(attr, definition: str) -> bool: + if len(definition) not in (2, 3, 5): + logging.error('Invalid color definition for %s: %s, must be of length, 2, 3, or 5', + attr, definition) + return False + if (definition[0] not in COLORS and definition[0] != '') or \ + (definition[1] not in COLORS and definition[1] != ''): + logging.error('Invalid color definition for %s: %s, must be one of %s', + attr, definition, COLORS.keys()) + return False + return True def config_checks( config, @@ -263,3 +272,11 @@ def config_checks( if config['calendars'][calendar]['color'] == 'auto': config['calendars'][calendar]['color'] = \ _get_color_from_vdir(config['calendars'][calendar]['path']) + + # check palette settings + valid_palette = True + for attr in config.get('palette', []): + valid_palette = valid_palette and validate_palette_entry(attr, config['palette'][attr]) + if not valid_palette: + logger.fatal('Invalid palette entry') + raise InvalidSettingsError() diff --git a/khal/ui/__init__.py b/khal/ui/__init__.py index 1a93f78a8..806501b43 100644 --- a/khal/ui/__init__.py +++ b/khal/ui/__init__.py @@ -24,7 +24,7 @@ import signal import sys from enum import IntEnum -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Literal, Optional, Tuple import click import urwid @@ -35,7 +35,7 @@ from . import colors from .base import Pane, Window from .editor import EventEditor, ExportDialog -from .widgets import CalendarWidget, NColumns, NPile, button, linebox +from .widgets import CalendarWidget, CAttrMap, NColumns, NPile, button, linebox from .widgets import ExtendedEdit as Edit logger = logging.getLogger('khal') @@ -84,13 +84,13 @@ class DateConversionError(Exception): class SelectableText(urwid.Text): - def selectable(self): + def selectable(self) -> bool: return True - def keypress(self, size, key): + def keypress(self, size: Tuple[int], key: Optional[str]) -> Optional[str]: return key - def get_cursor_coords(self, size): + def get_cursor_coords(self, size: Tuple[int]) -> Tuple[int, int]: return 0, 0 def render(self, size, focus=False): @@ -140,7 +140,7 @@ def relative_day(self, day: dt.date, dtformat: str) -> str: return f'{weekday}, {daystr} ({approx_delta})' - def keypress(self, _, key: str) -> str: + def keypress(self, size: Tuple[int], key: Optional[str]) -> Optional[str]: binds = self._conf['keybindings'] if key in binds['left']: key = 'left' @@ -219,7 +219,7 @@ def set_title(self, mark: str=' ') -> None: self.set_text(mark + ' ' + text.replace('\n', newline)) - def keypress(self, _, key: str) -> str: + def keypress(self, size: Tuple[int], key: Optional[str]) -> Optional[str]: binds = self._conf['keybindings'] if key in binds['left']: key = 'left' @@ -250,7 +250,7 @@ def __init__( self.set_focus_date_callback = set_focus_date_callback super().__init__(*args, **kwargs) - def keypress(self, size, key): + def keypress(self, size: Tuple[int], key: Optional[str]) -> Optional[str]: return super().keypress(size, key) @property @@ -306,7 +306,7 @@ def ensure_date(self, day: dt.date) -> None: self.body.ensure_date(day) self.clean() - def keypress(self, size, key): + def keypress(self, size: Tuple[int], key: Optional[str]) -> Optional[str]: if key in self._conf['keybindings']['up']: key = 'up' if key in self._conf['keybindings']['down']: @@ -610,7 +610,7 @@ def set_selected_date(self, day: dt.date) -> None: title.set_text(title.get_text()[0]) @property - def focus_event(self): + def focus_event(self) -> Optional[U_Event]: if self.body.focus == 0: return None else: @@ -639,17 +639,17 @@ def __init__(self, elistbox, pane) -> None: self.divider = urwid.Divider('─') self.editor = False self._last_focused_date: Optional[dt.date] = None - self._eventshown = False + self._eventshown: Optional[Tuple[str, str]] = None self.event_width = int(self.pane._conf['view']['event_view_weighting']) self.delete_status = pane.delete_status self.toggle_delete_all = pane.toggle_delete_all self.toggle_delete_instance = pane.toggle_delete_instance - self.dlistbox: DateListBox = elistbox + self.dlistbox: DListBox = elistbox self.container = urwid.Pile([self.dlistbox]) urwid.WidgetWrap.__init__(self, self.container) @property - def focus_event(self): + def focus_event(self) -> Optional[U_Event]: """returns the event currently in focus""" return self.dlistbox.focus_event @@ -677,7 +677,7 @@ def focus_date(self, date: dt.date) -> None: self._last_focused_date = date self.dlistbox.ensure_date(date) - def update(self, min_date, max_date, everything): + def update(self, min_date, max_date: dt.date, everything: bool): """update DateListBox if `everything` is True, reset all displayed dates, else only those between @@ -692,7 +692,7 @@ def update(self, min_date, max_date, everything): max_date = self.dlistbox.body.last_date self.dlistbox.body.update_range(min_date, max_date) - def refresh_titles(self, min_date, max_date, everything): + def refresh_titles(self, min_date: dt.date, max_date: dt.date, everything: bool) -> None: """refresh titles in DateListBoxes if `everything` is True, reset all displayed dates, else only those between @@ -700,7 +700,7 @@ def refresh_titles(self, min_date, max_date, everything): """ self.dlistbox.refresh_titles(min_date, max_date, everything) - def update_date_line(self): + def update_date_line(self) -> None: """refresh titles in DateListBoxes""" self.dlistbox.update_date_line() @@ -776,9 +776,9 @@ def update_colors(new_start: dt.date, new_end: dt.date, everything: bool=False): ContainerWidget = linebox[self.pane._conf['view']['frame']] new_pane = urwid.Columns([ - ('weight', 2, ContainerWidget(editor)), - ('weight', 1, ContainerWidget(self.dlistbox)) - ], dividechars=2, focus_column=0) + ('weight', 2, CAttrMap(ContainerWidget(editor), 'editor', 'editor focus')), + ('weight', 1, CAttrMap(ContainerWidget(self.dlistbox), 'reveal focus')), + ], dividechars=0, focus_column=0) new_pane.title = editor.title def teardown(data): @@ -859,6 +859,8 @@ def duplicate(self) -> None: # because their title is determined by X-BIRTHDAY and X-FNAME properties # which are also copied. If the events' summary is edited it will show # up on disk but not be displayed in khal + if self.focus_event is None: + return None event = self.focus_event.event.duplicate() try: self.pane.collection.insert(event) @@ -910,9 +912,9 @@ def new(self, date: dt.date, end: Optional[dt.date]=None) -> None: def selectable(self): return True - def keypress(self, size, key): + def keypress(self, size: Tuple[int], key: Optional[str]) -> Optional[str]: prev_shown = self._eventshown - self._eventshown = False + self._eventshown = None self.clear_event_view() if key in self._conf['keybindings']['new']: @@ -1021,9 +1023,10 @@ def __init__(self, search_func, abort_func) -> None: class Search(Edit): - def keypress(self, size, key): + def keypress(self, size: Tuple[int], key: Optional[str]) -> Optional[str]: if key == 'enter': search_func(self.text) + return None else: return super().keypress(size, key) @@ -1076,8 +1079,13 @@ def __init__(self, collection, conf=None, title: str='', description: str='') -> toggle_delete_instance=self.toggle_delete_instance, dynamic_days=self._conf['view']['dynamic_days'], ) - self.eventscolumn = ContainerWidget(EventColumn(pane=self, elistbox=elistbox)) - calendar = CalendarWidget( + self.eventscolumn = ContainerWidget( + CAttrMap(EventColumn(pane=self, elistbox=elistbox), + 'eventcolumn', + 'eventcolumn focus', + ), + ) + calendar = CAttrMap(CalendarWidget( on_date_change=self.eventscolumn.original_widget.set_focus_date, keybindings=self._conf['keybindings'], on_press={key: self.new_event for key in self._conf['keybindings']['new']}, @@ -1085,7 +1093,7 @@ def __init__(self, collection, conf=None, title: str='', description: str='') -> weeknumbers=self._conf['locale']['weeknumbers'], monthdisplay=self._conf['view']['monthdisplay'], get_styles=collection.get_styles - ) + ), 'calendar', 'calendar focus') if self._conf['view']['dynamic_days']: elistbox.set_focus_date_callback = calendar.set_focus_date else: @@ -1140,7 +1148,7 @@ def cleanup(self, data): event = self.collection.delete_instance(href, etag, account, rec_id) updated_etags[event.href] = event.etag - def keypress(self, size, key: str): + def keypress(self, size: Tuple[int], key: Optional[str]) -> Optional[str]: binds = self._conf['keybindings'] if key in binds['search']: self.search() @@ -1203,11 +1211,19 @@ def new_event(self, date, end): def _urwid_palette_entry( - name: str, color: str, hmethod: str) -> Tuple[str, str, str, str, str, str]: + name: str, color: str, hmethod: str, color_mode: Literal['256colors', 'rgb'], + foreground: str = '', background: str = '', +) -> Tuple[str, str, str, str, str, str]: """Create an urwid compatible palette entry. :param name: name of the new attribute in the palette :param color: color for the new attribute + :param hmethod: which highlighting mode to use, foreground or background + :param color_mode: which color mode we are in, if we are in 256-color mode, + we transform 24-bit/RGB colors to a (somewhat) matching 256-color set color + :param foreground: the foreground color to apply if we use background highlighting method + :param background: the background color to apply if we use foreground highlighting method + :returns: an urwid palette entry """ from ..terminal import COLORS @@ -1218,9 +1234,8 @@ def _urwid_palette_entry( # Colors from the 256 color palette need to be prefixed with h in # urwid. color = 'h' + color - else: - # 24-bit colors are not supported by urwid. - # Convert it to some color on the 256 color palette that might resemble + elif color_mode == '256color': + # Convert to some color on the 256 color palette that might resemble # the 24-bit color. # First, generate the palette (indices 16-255 only). This assumes, that # the terminal actually uses the same palette, which may or may not be @@ -1263,34 +1278,83 @@ def _urwid_palette_entry( # We unconditionally add the color to the high color slot. It seems to work # in lower color terminals as well. if hmethod in ['fg', 'foreground']: - return (name, '', '', '', color, '') + return (name, '', '', '', color, background) else: - return (name, '', '', '', '', color) + return (name, '', '', '', foreground, color) -def _add_calendar_colors(palette: List, collection: CalendarCollection) -> List: +def _add_calendar_colors( + palette: List[Tuple[str, ...]], + collection: 'CalendarCollection', + color_mode: Literal['256colors', 'rgb'], + base: Optional[str] = None, + attr_template: str = 'calendar {}', +) -> List[Tuple[str, ...]]: """Add the colors for the defined calendars to the palette. + We support setting a fixed background or foreground color that we extract + from a giving attribute + :param palette: the base palette :param collection: + :param color_mode: which color mode we are in + :param base: the attribute to extract the background and foreground color from + :param attr_template: the template to use for the attribute name :returns: the modified palette """ + bg_color, fg_color = '', '' + for attr in palette: + if base and attr[0] == base: + if color_mode == 'rgb' and len(attr) >= 5: + bg_color = attr[5] + fg_color = attr[4] + else: + bg_color = attr[2] + fg_color = attr[1] + for cal in collection.calendars: if cal['color'] == '': # No color set for this calendar, use default_color instead. color = collection.default_color else: color = cal['color'] - palette.append(_urwid_palette_entry('calendar ' + cal['name'], color, - collection.hmethod)) - palette.append(_urwid_palette_entry('highlight_days_color', - collection.color, collection.hmethod)) - palette.append(_urwid_palette_entry('highlight_days_multiple', - collection.multiple, collection.hmethod)) + entry = _urwid_palette_entry( + attr_template.format(cal['name']), + color, + collection.hmethod, + color_mode=color_mode, + foreground=fg_color, + background=bg_color, + ) + palette.append(entry) + + entry = _urwid_palette_entry( + 'highlight_days_color', + collection.color, + collection.hmethod, + color_mode=color_mode, + foreground=fg_color, + background=bg_color, + ) + palette.append(entry) + entry = _urwid_palette_entry('highlight_days_multiple', + collection.multiple, + collection.hmethod, + color_mode=color_mode, + foreground=fg_color, + background=bg_color) + palette.append(entry) + return palette -def start_pane(pane, callback, program_info='', quit_keys=None): +def start_pane( + pane, + callback, + program_info='', + quit_keys=None, + color_mode: Literal['rgb', '256colors']='rgb', +): """Open the user interface with the given initial pane.""" quit_keys = quit_keys or ['q'] @@ -1342,8 +1406,27 @@ def emit(self, record): logger.addHandler(header_handler) frame.open(pane, callback) + theme = getattr(colors, pane._conf['view']['theme']) palette = _add_calendar_colors( - getattr(colors, pane._conf['view']['theme']), pane.collection) + theme, pane.collection, color_mode=color_mode, + base='calendar', attr_template='calendar {}', + ) + palette = _add_calendar_colors( + palette, pane.collection, color_mode=color_mode, + base='popupbg', attr_template='calendar {} popup', + ) + + def merge_palettes(pallete_a, pallete_b) -> List[Tuple[str, ...]]: + """Merge two palettes together, with the second palette taking priority.""" + merged = {} + for entry in pallete_a: + merged[entry[0]] = entry + for entry in pallete_b: + merged[entry[0]] = entry + return list(merged.values()) + + overwrite = [(key, *values) for key, values in pane._conf['palette'].items()] + palette = merge_palettes(palette, overwrite) loop = urwid.MainLoop( widget=frame, palette=palette, @@ -1376,9 +1459,11 @@ def check_for_updates(loop, pane): loop.set_alarm_in(60, check_for_updates, pane) loop.set_alarm_in(60, check_for_updates, pane) - # Make urwid use 256 color mode. + + colors_ = 2**24 if color_mode == 'rgb' else 256 loop.screen.set_terminal_properties( - colors=256, bright_is_bold=pane._conf['view']['bold_for_light_color']) + colors=colors_, bright_is_bold=pane._conf['view']['bold_for_light_color'], + ) def ctrl_c(signum, f): raise urwid.ExitMainLoop() diff --git a/khal/ui/colors.py b/khal/ui/colors.py index f218dd93f..5cffb116d 100644 --- a/khal/ui/colors.py +++ b/khal/ui/colors.py @@ -19,17 +19,18 @@ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +from typing import Dict, List, Tuple dark = [ ('header', 'white', 'black'), ('footer', 'white', 'black'), ('line header', 'black', 'white', 'bold'), ('alt header', 'white', '', 'bold'), - ('bright', 'dark blue', 'white', ('bold', 'standout')), + ('bright', 'dark blue', 'white', 'bold,standout'), ('list', 'black', 'white'), ('list focused', 'white', 'light blue', 'bold'), ('edit', 'black', 'white'), - ('edit focused', 'white', 'light blue', 'bold'), + ('edit focus', 'white', 'light blue', 'bold'), ('button', 'black', 'dark cyan'), ('button focused', 'white', 'light blue', 'bold'), @@ -44,7 +45,6 @@ ('dayname', 'light gray', ''), ('monthname', 'light gray', ''), ('weeknumber_right', 'light gray', ''), - ('edit', 'white', 'dark blue'), ('alert', 'white', 'dark red'), ('mark', 'white', 'dark green'), ('frame', 'white', 'black'), @@ -52,6 +52,11 @@ ('frame focus color', 'dark blue', 'black'), ('frame focus top', 'dark magenta', 'black'), + ('eventcolumn', '', '', ''), + ('eventcolumn focus', '', '', ''), + ('calendar', '', '', ''), + ('calendar focus', '', '', ''), + ('editbx', 'light gray', 'dark blue'), ('editcp', 'black', 'light gray', 'standout'), ('popupbg', 'white', 'black', 'bold'), @@ -63,11 +68,11 @@ ('footer', 'black', 'white'), ('line header', 'black', 'white', 'bold'), ('alt header', 'black', '', 'bold'), - ('bright', 'dark blue', 'white', ('bold', 'standout')), + ('bright', 'dark blue', 'white', 'bold,standout'), ('list', 'black', 'white'), ('list focused', 'white', 'light blue', 'bold'), ('edit', 'black', 'white'), - ('edit focused', 'white', 'light blue', 'bold'), + ('edit focus', 'white', 'light blue', 'bold'), ('button', 'black', 'dark cyan'), ('button focused', 'white', 'light blue', 'bold'), @@ -76,13 +81,12 @@ ('today', 'black', 'light gray'), ('date header', '', 'white'), - ('date header focused', 'white', 'dark gray', ('bold', 'standout')), + ('date header focused', 'white', 'dark gray', 'bold,standout'), ('date header selected', 'dark gray', 'light cyan'), ('dayname', 'dark gray', 'white'), ('monthname', 'dark gray', 'white'), ('weeknumber_right', 'dark gray', 'white'), - ('edit', 'white', 'dark blue'), ('alert', 'white', 'dark red'), ('mark', 'white', 'dark green'), ('frame', 'dark gray', 'white'), @@ -90,9 +94,16 @@ ('frame focus color', 'dark blue', 'white'), ('frame focus top', 'dark magenta', 'white'), + ('eventcolumn', '', '', ''), + ('eventcolumn focus', '', '', ''), + ('calendar', '', '', ''), + ('calendar focus', '', '', ''), + ('editbx', 'light gray', 'dark blue'), ('editcp', 'black', 'light gray', 'standout'), ('popupbg', 'white', 'black', 'bold'), ('popupper', 'black', 'light gray'), ('caption', 'black', '', ''), ] + +themes: Dict[str, List[Tuple[str, ...]]] = {'light': light, 'dark': dark} diff --git a/khal/ui/editor.py b/khal/ui/editor.py index fa6498f13..00a1a097d 100644 --- a/khal/ui/editor.py +++ b/khal/ui/editor.py @@ -20,7 +20,7 @@ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. import datetime as dt -from typing import Callable, Dict, List, Literal, Optional +from typing import TYPE_CHECKING, Callable, Dict, List, Literal, Optional, Tuple import urwid @@ -43,6 +43,8 @@ button, ) +if TYPE_CHECKING: + import khal.khalendar.event class StartEnd: @@ -55,7 +57,7 @@ def __init__(self, startdate, starttime, enddate, endtime) -> None: class CalendarPopUp(urwid.PopUpLauncher): - def __init__(self, widget, on_date_change, weeknumbers=False, + def __init__(self, widget, on_date_change, weeknumbers: Literal['left', 'right', False]=False, firstweekday=0, monthdisplay='firstday', keybindings=None) -> None: self._on_date_change = on_date_change self._weeknumbers = weeknumbers @@ -88,7 +90,8 @@ def on_change(new_date): weeknumbers=self._weeknumbers, monthdisplay=self._monthdisplay, initial=initial_date) - pop_up = urwid.LineBox(pop_up) + pop_up = CAttrMap(pop_up, 'calendar', ' calendar focus') + pop_up = CAttrMap(urwid.LineBox(pop_up), 'calendar', 'calendar focus') return pop_up def get_pop_up_parameters(self): @@ -125,10 +128,13 @@ def __init__( on_date_change=on_date_change) wrapped = CalendarPopUp(self._edit, on_date_change, weeknumbers, firstweekday, monthdisplay, keybindings) - padded = urwid.Padding(wrapped, align='left', width=datewidth, left=0, right=1) + padded = CAttrMap( + urwid.Padding(wrapped, align='left', width=datewidth, left=0, right=1), + 'calendar', 'calendar focus', + ) super().__init__(padded) - def _validate(self, text): + def _validate(self, text: str): try: _date = dt.datetime.strptime(text, self._dateformat).date() except ValueError: @@ -157,14 +163,15 @@ def date(self, date): class StartEndEditor(urwid.WidgetWrap): """Widget for editing start and end times (of an event).""" - def __init__(self, start, end, conf, + def __init__(self, + start: dt.datetime, + end: dt.datetime, + conf, on_start_date_change=lambda x: None, on_end_date_change=lambda x: None, ) -> None: """ - :type start: datetime.datetime - :type end: datetime.datetime :param on_start_date_change: a callable that gets called everytime a new start date is entered, with that new date as an argument :param on_end_date_change: same as for on_start_date_change, just for the @@ -172,8 +179,10 @@ def __init__(self, start, end, conf, """ self.allday = not isinstance(start, dt.datetime) self.conf = conf - self._startdt, self._original_start = start, start - self._enddt, self._original_end = end, end + self._startdt: dt.date = start + self._original_start: dt.date = start + self._enddt: dt.date = end + self._original_end: dt.date = end self.on_start_date_change = on_start_date_change self.on_end_date_change = on_end_date_change self._datewidth = len(start.strftime(self.conf['locale']['longdateformat'])) @@ -256,7 +265,7 @@ def _end_date_change(self, date): self._enddt = self.localize_end(dt.datetime.combine(date, self._end_time)) self.on_end_date_change(date) - def toggle(self, checkbox, state): + def toggle(self, checkbox, state: bool): """change from allday to datetime event :param checkbox: the checkbox instance that is used for toggling, gets @@ -264,13 +273,14 @@ def toggle(self, checkbox, state): :type checkbox: checkbox :param state: state the event will toggle to; True if allday event, False if datetime - :type state: bool """ if self.allday is True and state is False: self._startdt = dt.datetime.combine(self._startdt, dt.datetime.min.time()) self._enddt = dt.datetime.combine(self._enddt, dt.datetime.min.time()) elif self.allday is False and state is True: + assert isinstance(self._startdt, dt.datetime) + assert isinstance(self._enddt, dt.datetime) self._startdt = self._startdt.date() self._enddt = self._enddt.date() self.allday = state @@ -336,14 +346,18 @@ def validate(self): class EventEditor(urwid.WidgetWrap): """Widget that allows Editing one `Event()`""" - def __init__(self, pane, event, save_callback=None, always_save=False) -> None: + def __init__( + self, + pane, + event: 'khal.khalendar.event.Event', + save_callback=None, + always_save: bool=False, + ) -> None: """ - :type event: khal.event.Event :param save_callback: call when saving event with new start and end dates and recursiveness of original and edited event as parameters :type save_callback: callable :param always_save: save event even if it has not changed - :type always_save: bool """ self.pane = pane self.event = event @@ -369,13 +383,14 @@ def __init__(self, pane, event, save_callback=None, always_save=False) -> None: self.event.recurobject, self._conf, event.start_local, ) self.summary = urwid.AttrMap(ExtendedEdit( - caption=('caption', 'Title: '), edit_text=event.summary), 'edit' + caption=('caption', 'Title: '), edit_text=event.summary), 'edit', 'edit focus', ) divider = urwid.Divider(' ') - def decorate_choice(c): - return ('calendar ' + c['name'], c['name']) + def decorate_choice(c) -> Tuple[str, str]: + return ('calendar ' + c['name'] + ' popup', c['name']) + self.calendar_chooser= CAttrMap(Choice( [self.collection._calendars[c] for c in self.collection.writable_names], self.collection._calendars[self.event.calendar], @@ -388,13 +403,13 @@ def decorate_choice(c): edit_text=self.description, multiline=True ), - 'edit' + 'edit', 'edit focus', ) self.location = urwid.AttrMap(ExtendedEdit( - caption=('caption', 'Location: '), edit_text=self.location), 'edit' + caption=('caption', 'Location: '), edit_text=self.location), 'edit', 'edit focus', ) self.categories = urwid.AttrMap(ExtendedEdit( - caption=('caption', 'Categories: '), edit_text=self.categories), 'edit' + caption=('caption', 'Categories: '), edit_text=self.categories), 'edit', 'edit focus', ) self.attendees = urwid.AttrMap( ExtendedEdit( @@ -402,10 +417,10 @@ def decorate_choice(c): edit_text=self.attendees, multiline=True ), - 'edit' + 'edit', 'edit focus', ) self.url = urwid.AttrMap(ExtendedEdit( - caption=('caption', 'URL: '), edit_text=self.url), 'edit' + caption=('caption', 'URL: '), edit_text=self.url), 'edit', 'edit focus', ) self.alarms = AlarmsEditor(self.event) self.pile = NListBox(urwid.SimpleFocusListWalker([ @@ -554,17 +569,17 @@ def save(self, button): self._abort_confirmed = False self.pane.window.backtrack() - def keypress(self, size, key): + def keypress(self, size: Tuple[int], key: str) -> Optional[str]: if key in ['esc'] and self.changed and not self._abort_confirmed: self.pane.window.alert( ('light red', 'Unsaved changes! Hit ESC again to discard.')) self._abort_confirmed = True - return + return None else: self._abort_confirmed = False if key in self.pane._conf['keybindings']['save']: self.save(None) - return + return None return super().keypress(size, key) @@ -805,8 +820,8 @@ def __init__(self, this_func, abort_func, event) -> None: caption='Location: ', edit_text="~/%s.ics" % event.summary.strip()) lines.append(export_location) lines.append(urwid.Divider(' ')) - lines.append( - urwid.Button('Save', on_press=this_func, user_data=export_location) - ) + lines.append(CAttrMap( + urwid.Button('Save', on_press=this_func, user_data=export_location), + 'button', 'button focus')) content = NPile(lines) urwid.WidgetWrap.__init__(self, urwid.LineBox(content)) diff --git a/khal/ui/widgets.py b/khal/ui/widgets.py index 7be6413b9..9f12dcc09 100644 --- a/khal/ui/widgets.py +++ b/khal/ui/widgets.py @@ -26,7 +26,7 @@ """ import datetime as dt import re -from typing import Optional, Tuple +from typing import List, Optional, Tuple import urwid @@ -77,7 +77,7 @@ def goto_end_of_line(text): class ExtendedEdit(urwid.Edit): """A text editing widget supporting some more editing commands""" - def keypress(self, size, key: str) -> Optional[Tuple[Tuple[int, int], str]]: + def keypress(self, size: Tuple[int], key: Optional[str]) -> Optional[str]: if key == 'ctrl w': self._delete_word() elif key == 'ctrl u': @@ -201,7 +201,8 @@ def _get_current_value(self): class Choice(urwid.PopUpLauncher): def __init__( - self, choices, active, decorate_func=None, overlay_width=32, callback=lambda: None, + self, choices: List[str], active: str, + decorate_func=None, overlay_width: int=32, callback=lambda: None, ) -> None: self.choices = choices self._callback = callback @@ -211,9 +212,7 @@ def __init__( def create_pop_up(self): pop_up = ChoiceList(self, callback=self._callback) - urwid.connect_signal( - pop_up, 'close', lambda button: self.close_pop_up(), - ) + urwid.connect_signal(pop_up, 'close', lambda button: self.close_pop_up()) return pop_up def get_pop_up_parameters(self): @@ -235,8 +234,7 @@ def active(self, val): self._active = val self.button = urwid.Button(self._decorate(self._active)) urwid.PopUpLauncher.__init__(self, self.button) - urwid.connect_signal(self.button, 'click', - lambda button: self.open_pop_up()) + urwid.connect_signal(self.button, 'click', lambda button: self.open_pop_up()) class ChoiceList(urwid.WidgetWrap): @@ -252,6 +250,7 @@ def __init__(self, parent, callback=lambda: None) -> None: button( parent._decorate(c), attr_map='popupbg', + focus_map='popupbg focus', on_press=self.set_choice, user_data=c, ) @@ -533,7 +532,7 @@ def __init__(self, alarm, delete_handler) -> None: (21, self.duration), (14, urwid.Padding(self.direction, right=1)), self.description, - (10, urwid.Button('Delete', on_press=delete_handler, user_data=self)), + (10, button('Delete', on_press=delete_handler, user_data=self)), ]) urwid.WidgetWrap.__init__(self, self.columns) @@ -552,7 +551,7 @@ def __init__(self, event) -> None: self.pile = NPile( [urwid.Text('Alarms:')] + [self.AlarmEditor(a, self.remove_alarm) for a in event.alarms] + - [urwid.Columns([(12, urwid.Button('Add', on_press=self.add_alarm))])]) + [urwid.Columns([(12, button('Add', on_press=self.add_alarm))])]) urwid.WidgetWrap.__init__(self, self.pile) @@ -582,29 +581,31 @@ def changed(self): class FocusLineBoxWidth(urwid.WidgetDecoration, urwid.WidgetWrap): def __init__(self, widget) -> None: - hline = urwid.Divider('─') - hline_focus = urwid.Divider('━') - self._vline = urwid.SolidFill('│') - self._vline_focus = urwid.SolidFill('┃') + # we cheat here with the attrs, if we use thick dividers we apply the + # focus attr group. We probably should fix this in render() + hline = urwid.AttrMap(urwid.Divider('─'), 'frame') + hline_focus = urwid.AttrMap(urwid.Divider('━'), 'frame focus') + self._vline = urwid.AttrMap(urwid.SolidFill('│'), 'frame') + self._vline_focus = urwid.AttrMap(urwid.SolidFill('┃'), 'frame focus') self._topline = urwid.Columns([ - ('fixed', 1, urwid.Text('┌')), + ('fixed', 1, urwid.AttrMap(urwid.Text('┌'), 'frame')), hline, - ('fixed', 1, urwid.Text('┐')), + ('fixed', 1, urwid.AttrMap(urwid.Text('┐'), 'frame')), ]) self._topline_focus = urwid.Columns([ - ('fixed', 1, urwid.Text('┏')), + ('fixed', 1, urwid.AttrMap(urwid.Text('┏'), 'frame focus')), hline_focus, - ('fixed', 1, urwid.Text('┓')), + ('fixed', 1, urwid.AttrMap(urwid.Text('┓'), 'frame focus')), ]) self._bottomline = urwid.Columns([ - ('fixed', 1, urwid.Text('└')), + ('fixed', 1, urwid.AttrMap(urwid.Text('└'), 'frame')), hline, - ('fixed', 1, urwid.Text('┘')), + ('fixed', 1, urwid.AttrMap(urwid.Text('┘'), 'frame')), ]) self._bottomline_focus = urwid.Columns([ - ('fixed', 1, urwid.Text('┗')), + ('fixed', 1, urwid.AttrMap(urwid.Text('┗'), 'frame focus')), hline_focus, - ('fixed', 1, urwid.Text('┛')), + ('fixed', 1, urwid.AttrMap(urwid.Text('┛'), 'frame focus')), ]) self._middle = urwid.Columns( [('fixed', 1, self._vline), widget, ('fixed', 1, self._vline)], @@ -699,7 +700,7 @@ def render(self, size, focus): } def button(*args, - attr_map: str='button', focus_map='button focused', + attr_map: str='button', focus_map='button focus', padding_left=0, padding_right=0, **kwargs): """wrapping an urwid button in attrmap and padding""" @@ -710,14 +711,9 @@ def button(*args, class CAttrMap(urwid.AttrMap): - """A variant of AttrMap that exposes the some properties of the original widget""" - @property - def active(self): - return self.original_widget.active - - @property - def changed(self): - return self.original_widget.changed + """A variant of AttrMap that exposes all properties of the original widget""" + def __getattr__(self, name): + return getattr(self.original_widget, name) class CPadding(urwid.Padding):