diff --git a/alot/commands/globals.py b/alot/commands/globals.py index 1b5c90155..7cde3185c 100644 --- a/alot/commands/globals.py +++ b/alot/commands/globals.py @@ -16,6 +16,8 @@ import urwid +from alot.settings.theme import get_theme + from . import Command, registerCommand from . import CommandCanceled, SequenceCanceled from .utils import update_keys @@ -1217,3 +1219,39 @@ async def apply(self, ui): if (await ui.choice(self.msg, select='yes', cancel='no', msg_position='left')) == 'no': raise SequenceCanceled() + +@registerCommand( + MODE, "theme", + arguments=[ + (["theme_name"], {"help": "Name of the theme"}) + ], + help="change theme") +class ThemeCommand(Command): + """Change theme.""" + + def __init__(self, theme_name, **kwargs): + """ + :param theme_name: Name of theme. + :type theme_name: str + """ + super(ThemeCommand, self).__init__(**kwargs) + self.theme_name = theme_name + + def apply(self, ui): + try: + themes_dir = settings.get("themes_dir") + theme = get_theme(themes_dir, self.theme_name) + if theme == settings.theme: + # Skip the update and don't rebuild buffers + # unnecessarily. + return + settings.theme = theme + for buffer in ui.buffers: + buffer.rebuild() + ui.update() + logging.info(f"Applied theme {self.theme_name}") + except ConfigError as e: + ui.notify( + f"Error when loading theme:\n {e}", + priority="error" + ) diff --git a/alot/settings/manager.py b/alot/settings/manager.py index 26402d8ce..ded76be35 100644 --- a/alot/settings/manager.py +++ b/alot/settings/manager.py @@ -2,7 +2,6 @@ # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file import importlib.util -import itertools import logging import mailcap import os @@ -19,12 +18,10 @@ from .errors import ConfigError, NoMatchingAccount from .utils import read_config, read_notmuch_config from .utils import resolve_att -from .theme import Theme +from .theme import get_theme DEFAULTSPATH = os.path.join(os.path.dirname(__file__), '..', 'defaults') -DATA_DIRS = get_xdg_env('XDG_DATA_DIRS', - '/usr/local/share:/usr/share').split(':') class SettingsManager: @@ -32,7 +29,7 @@ class SettingsManager: def __init__(self): self.hooks = None self._mailcaps = mailcap.getcaps() - self._theme = None + self.theme = None self._accounts = None self._accountmap = None self._notmuchconfig = None @@ -99,38 +96,15 @@ def read_config(self, path): logging.debug('template directory: `%s`' % tempdir) # themes - themestring = newconfig['theme'] + theme_name = newconfig['theme'] themes_dir = self._config.get('themes_dir') logging.debug('themes directory: `%s`' % themes_dir) - # if config contains theme string use that - data_dirs = [os.path.join(d, 'alot/themes') for d in DATA_DIRS] - if themestring: - # This is a python for/else loop - # https://docs.python.org/3/reference/compound_stmts.html#for - # - # tl/dr; If the loop loads a theme it breaks. If it doesn't break, - # then it raises a ConfigError. - for dir_ in itertools.chain([themes_dir], data_dirs): - theme_path = os.path.join(dir_, themestring) - if not os.path.exists(os.path.expanduser(theme_path)): - logging.warning('Theme `%s` does not exist.', theme_path) - else: - try: - self._theme = Theme(theme_path) - except ConfigError as e: - raise ConfigError('Theme file `%s` failed ' - 'validation:\n%s' % (theme_path, e)) - else: - break - else: - raise ConfigError('Could not find theme {}, see log for more ' - 'information'.format(themestring)) - - # if still no theme is set, resort to default - if self._theme is None: - theme_path = os.path.join(DEFAULTSPATH, 'default.theme') - self._theme = Theme(theme_path) + self.theme = ( + get_theme(themes_dir, theme_name) + if theme_name else + get_theme(DEFAULTSPATH, 'default.theme') + ) self._accounts = self._parse_accounts(self._config) self._accountmap = self._account_table(self._accounts) @@ -306,7 +280,7 @@ def get_theming_attribute(self, mode, name, part=None): :rtype: urwid.AttrSpec """ colours = int(self._config.get('colourmode')) - return self._theme.get_attribute(colours, mode, name, part) + return self.theme.get_attribute(colours, mode, name, part) def get_threadline_theming(self, thread): """ @@ -318,7 +292,7 @@ def get_threadline_theming(self, thread): :type thread: alot.db.thread.Thread """ colours = int(self._config.get('colourmode')) - return self._theme.get_threadline_theming(thread, colours) + return self.theme.get_threadline_theming(thread, colours) def get_tagstring_representation(self, tag, onebelow_normal=None, onebelow_focus=None): @@ -341,7 +315,7 @@ def get_tagstring_representation(self, tag, onebelow_normal=None, :translated: to an alternative string representation """ colourmode = int(self._config.get('colourmode')) - theme = self._theme + theme = self.theme cfg = self._config colours = [1, 16, 256] diff --git a/alot/settings/theme.py b/alot/settings/theme.py index b1accb186..1874216ad 100644 --- a/alot/settings/theme.py +++ b/alot/settings/theme.py @@ -1,14 +1,18 @@ # Copyright (C) 2011-2012 Patrick Totzke # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file +import logging import os +from ..helper import get_xdg_env from ..utils import configobj as checks from .utils import read_config from .errors import ConfigError DEFAULTSPATH = os.path.join(os.path.dirname(__file__), '..', 'defaults') DUMMYDEFAULT = ('default',) * 6 +DATA_DIRS = get_xdg_env('XDG_DATA_DIRS', + '/usr/local/share:/usr/share').split(':') class Theme: @@ -40,6 +44,9 @@ def __init__(self, path): msg = 'missing threadline parts: %s' % ', '.join(diff) raise ConfigError(msg) + def __eq__(self, other): + return self._config == other._config + def get_attribute(self, colourmode, mode, name, part=None): """ returns requested attribute @@ -128,3 +135,28 @@ def fill(key, fallback=None): res[part]['normal'] = pickcolour(fill('normal')) res[part]['focus'] = pickcolour(fill('focus')) return res + + +def get_theme(themes_dir, theme_name) -> Theme: + theme_dirs = [ + themes_dir, + *(os.path.join(d, "alot/themes") for d in DATA_DIRS), + DEFAULTSPATH + ] + theme_paths = [os.path.join(d, theme_name) for d in theme_dirs] + theme_path = next( + (theme_path + for theme_path in theme_paths + if os.path.exists(os.path.expanduser(theme_path))), + None + ) + if theme_path is None: + raise ConfigError( + f"Could not find theme {theme_name}." + ) + try: + return Theme(theme_path) + except ConfigError as e: + msg = f"Theme file `{theme_path}` failed validation: {e}" + logging.error(msg) + raise ConfigError(msg) from e diff --git a/docs/source/usage/modes/global.rst b/docs/source/usage/modes/global.rst index 917a2a23b..9fde877bd 100644 --- a/docs/source/usage/modes/global.rst +++ b/docs/source/usage/modes/global.rst @@ -230,3 +230,13 @@ The following commands are available globally: optional arguments :---tags: tags to display +.. _cmd.global.theme: + +.. describe:: theme + + change theme + + argument + Name of the theme + + diff --git a/tests/settings/test_theme.py b/tests/settings/test_theme.py index 251391994..ce735d045 100644 --- a/tests/settings/test_theme.py +++ b/tests/settings/test_theme.py @@ -2,9 +2,10 @@ # This file is released under the GNU GPL, version 3 or a later revision. # For further details see the COPYING file -import unittest +import os +from unittest import TestCase, mock -from alot.settings import theme +from alot.settings import theme as module DUMMY_THEME = """\ @@ -60,7 +61,7 @@ """ -class TestThemeGetAttribute(unittest.TestCase): +class TestThemeGetAttribute(TestCase): @classmethod def setUpClass(cls): @@ -68,7 +69,7 @@ def setUpClass(cls): # file. This is possible because the argument is handed to # configobj.ConfigObj directly and that accepts eigher: # https://configobj.rtfd.io/en/latest/configobj.html#reading-a-config-file - cls.theme = theme.Theme(DUMMY_THEME.splitlines()) + cls.theme = module.Theme(DUMMY_THEME.splitlines()) def test_invalid_mode_raises_key_error(self): with self.assertRaises(KeyError) as cm: @@ -86,3 +87,34 @@ def test_invalid_name_raises_key_error(self): def test_invalid_colorindex_raises_value_error(self): with self.assertRaises(ValueError): self.theme.get_attribute(0, 'global', 'body') + +class GetThemeTest(TestCase): + def setUp(self): + self.mock_os_path = mock.patch( + "os.path", wraps=os.path + ).start() + self.mock_theme = mock.patch.object( + module, 'Theme', spec_set=module.Theme + ).start() + self.addCleanup(mock.patch.stopall) + + def test_returns_theme_when_theme_found(self): + self.mock_os_path.exists.return_value = True + expected_theme = mock.sentinel + self.mock_theme.return_value = expected_theme + actual_theme = module.get_theme("test", "test.theme") + self.assertEqual(expected_theme, actual_theme) + + def test_raises_config_error_when_theme_not_found(self): + self.mock_os_path.exists.return_value = False + with self.assertRaisesRegex(module.ConfigError, "Could not find theme test.theme"): + module.get_theme("test", "test.theme") + + def test_raises_config_error_when_theme_fails_validation(self): + self.mock_os_path.exists.return_value = True + self.mock_theme.side_effect = module.ConfigError("test error") + with self.assertRaisesRegex( + module.ConfigError, + "Theme file `test/test.theme` failed validation: test error" + ): + module.get_theme("test", "test.theme")