Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions alot/commands/globals.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
)
48 changes: 11 additions & 37 deletions alot/settings/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -19,20 +18,18 @@
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:
"""Organizes user settings"""
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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
"""
Expand All @@ -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):
Expand All @@ -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]

Expand Down
32 changes: 32 additions & 0 deletions alot/settings/theme.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
# Copyright (C) 2011-2012 Patrick Totzke <patricktotzke@gmail.com>
# 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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
10 changes: 10 additions & 0 deletions docs/source/usage/modes/global.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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


40 changes: 36 additions & 4 deletions tests/settings/test_theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """\
Expand Down Expand Up @@ -60,15 +61,15 @@
"""


class TestThemeGetAttribute(unittest.TestCase):
class TestThemeGetAttribute(TestCase):

@classmethod
def setUpClass(cls):
# We use a list of strings instead of a file path to pass in the config
# 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:
Expand All @@ -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")