From 85aee23639bb78c07151618af031317c9ecd3408 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Wed, 27 May 2020 15:02:44 +0200 Subject: Add dark mode settings Closes #5394 See #2377 --- doc/changelog.asciidoc | 3 + doc/help/settings.asciidoc | 163 +++++++++++++++++++++++++++++++++++ qutebrowser/config/configdata.yml | 163 +++++++++++++++++++++++++++++++++++ qutebrowser/config/configinit.py | 89 +++++++++++++++++++ scripts/dev/misc_checks.py | 2 +- tests/unit/config/test_configinit.py | 116 ++++++++++++++++++++++++- 6 files changed, 534 insertions(+), 2 deletions(-) diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index d6e42e2b6..a6aead789 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -41,6 +41,9 @@ Added - New `colors.contextmenu.disabled.{fg,bg}` settings to customize colors for disabled items in the context menu. - New line selection mode (`:toggle-selection --line`), bound to `Shift-V` in caret mode. +- New `colors.webpage.darkmode.*` settings to control Chromium's dark mode. + Note that those settings only work with QtWebEngine on Qt >= 5.14 and require + a restart of qutebrowser. Changed ~~~~~~~ diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 4c20126c3..3787238a9 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -111,6 +111,15 @@ |<>|Background color of selected odd tabs. |<>|Foreground color of selected odd tabs. |<>|Background color for webpages if unset (or empty to use the theme's color). +|<>|Which algorithm to use for modifying how colors are rendered with darkmode. +|<>|Contrast for dark mode. +|<>|Render all web contents using a dark theme. +|<>|Render all colors as grayscale. +|<>|Desaturation factor for images in dark mode. +|<>|Which images to apply dark mode to. +|<>|Which pages to apply dark mode to. +|<>|Threshold for inverting background elements with dark mode. +|<>|Threshold for inverting text with dark mode. |<>|Force `prefers-color-scheme: dark` colors for websites. |<>|Number of commands to save in the command history. |<>|Delay (in milliseconds) before updating completions after typing a character. @@ -1534,6 +1543,160 @@ Type: <> Default: +pass:[white]+ +[[colors.webpage.darkmode.algorithm]] +=== colors.webpage.darkmode.algorithm +Which algorithm to use for modifying how colors are rendered with darkmode. +This setting requires a restart. + +Type: <> + +Valid values: + + * +lightness-cielab+: Modify colors by converting them to CIELAB color space and inverting the L value. + * +lightness-hsl+: Modify colors by converting them to the HSL color space and inverting the lightness (i.e. the "L" in HSL). + * +brightness-rgb+: Modify colors by subtracting each of r, g, and b from their maximum value. + +Default: +pass:[lightness-cielab]+ + +On QtWebEngine, this setting requires Qt 5.14 or newer. + +On QtWebKit, this setting is unavailable. + +[[colors.webpage.darkmode.contrast]] +=== colors.webpage.darkmode.contrast +Contrast for dark mode. +This only has an effect when `colors.webpage.darkmode.algorithm` is set to `lightness-hsl` or `brightness-rgb`. +This setting requires a restart. + +Type: <> + +Default: +pass:[0.0]+ + +On QtWebEngine, this setting requires Qt 5.14 or newer. + +On QtWebKit, this setting is unavailable. + +[[colors.webpage.darkmode.enabled]] +=== colors.webpage.darkmode.enabled +Render all web contents using a dark theme. +Example configurations from Chromium's `chrome://flags`: + +- "With simple HSL/CIELAB/RGB-based inversion": Set + `colors.webpage.darkmode.algorithm` accordingly. + +- "With selective image inversion": Set + `colors.webpage.darkmode.policy.images` to `smart`. + +- "With selective inversion of non-image elements": Set + `colors.webpage.darkmode.threshold.text` to 150 and + `colors.webpage.darkmode.threshold.background` to 205. + +- "With selective inversion of everything": Combines the two variants + above. +This setting requires a restart. + +Type: <> + +Default: +pass:[false]+ + +On QtWebEngine, this setting requires Qt 5.14 or newer. + +On QtWebKit, this setting is unavailable. + +[[colors.webpage.darkmode.grayscale.all]] +=== colors.webpage.darkmode.grayscale.all +Render all colors as grayscale. +This only has an effect when `colors.webpage.darkmode.algorithm` is set to `lightness-hsl` or `brightness-rgb`. +This setting requires a restart. + +Type: <> + +Default: +pass:[false]+ + +On QtWebEngine, this setting requires Qt 5.14 or newer. + +On QtWebKit, this setting is unavailable. + +[[colors.webpage.darkmode.grayscale.images]] +=== colors.webpage.darkmode.grayscale.images +Desaturation factor for images in dark mode. +If set to 0, images are left as-is. If set to 1, images are completely grayscale. Values between 0 and 1 desaturate the colors accordingly. +This setting requires a restart. + +Type: <> + +Default: +pass:[0.0]+ + +On QtWebEngine, this setting requires Qt 5.14 or newer. + +On QtWebKit, this setting is unavailable. + +[[colors.webpage.darkmode.policy.images]] +=== colors.webpage.darkmode.policy.images +Which images to apply dark mode to. +This setting requires a restart. + +Type: <> + +Valid values: + + * +always+: Apply dark mode filter to all images. + * +never+: Never apply dark mode filter to any images. + * +smart+: Apply dark mode based on image content. + +Default: +pass:[never]+ + +On QtWebEngine, this setting requires Qt 5.14 or newer. + +On QtWebKit, this setting is unavailable. + +[[colors.webpage.darkmode.policy.page]] +=== colors.webpage.darkmode.policy.page +Which pages to apply dark mode to. +This setting requires a restart. + +Type: <> + +Valid values: + + * +always+: Apply dark mode filter to all frames, regardless of content. + * +smart+: Apply dark mode filter to frames based on background color. + +Default: +pass:[smart]+ + +On QtWebEngine, this setting requires Qt 5.14 or newer. + +On QtWebKit, this setting is unavailable. + +[[colors.webpage.darkmode.threshold.background]] +=== colors.webpage.darkmode.threshold.background +Threshold for inverting background elements with dark mode. +Background elements with brightness above this threshold will be inverted, and below it will be left as in the original, non-dark-mode page. Set to 256 to never invert the color or to 0 to always invert it. +Note: This behavior is the opposite of `colors.webpage.darkmode.threshold.text`! +This setting requires a restart. + +Type: <> + +Default: +pass:[0]+ + +On QtWebEngine, this setting requires Qt 5.14 or newer. + +On QtWebKit, this setting is unavailable. + +[[colors.webpage.darkmode.threshold.text]] +=== colors.webpage.darkmode.threshold.text +Threshold for inverting text with dark mode. +Text colors with brightness below this threshold will be inverted, and above it will be left as in the original, non-dark-mode page. Set to 256 to always invert text color or to 0 to never invert text color. +This setting requires a restart. + +Type: <> + +Default: +pass:[256]+ + +On QtWebEngine, this setting requires Qt 5.14 or newer. + +On QtWebKit, this setting is unavailable. + [[colors.webpage.prefers_color_scheme_dark]] === colors.webpage.prefers_color_scheme_dark Force `prefers-color-scheme: dark` colors for websites. diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index 46b745089..dc49e4cdd 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -2612,6 +2612,169 @@ colors.webpage.prefers_color_scheme_dark: QtWebEngine: Qt 5.14 QtWebKit: false +## dark mode + +# darkModeClassifierType is not exposed, as the icon classifier isn't actually +# implemented in Chromium: +# +# https://source.chromium.org/chromium/chromium/src/+/master:third_party/blink/renderer/platform/graphics/dark_mode_icon_classifier.cc + +colors.webpage.darkmode.enabled: + default: false + type: Bool + desc: >- + Render all web contents using a dark theme. + + Example configurations from Chromium's `chrome://flags`: + + + - "With simple HSL/CIELAB/RGB-based inversion": Set + `colors.webpage.darkmode.algorithm` accordingly. + + - "With selective image inversion": Set + `colors.webpage.darkmode.policy.images` to `smart`. + + - "With selective inversion of non-image elements": Set + `colors.webpage.darkmode.threshold.text` to 150 and + `colors.webpage.darkmode.threshold.background` to 205. + + - "With selective inversion of everything": Combines the two variants + above. + restart: true + backend: + QtWebEngine: Qt 5.14 + QtWebKit: false + +colors.webpage.darkmode.algorithm: + default: lightness-cielab + desc: "Which algorithm to use for modifying how colors are rendered with + darkmode." + type: + name: String + valid_values: + - lightness-cielab: Modify colors by converting them to CIELAB color + space and inverting the L value. + - lightness-hsl: Modify colors by converting them to the HSL color space + and inverting the lightness (i.e. the "L" in HSL). + - brightness-rgb: Modify colors by subtracting each of r, g, and b from + their maximum value. + # kSimpleInvertForTesting is not exposed, as it's equivalent to + # kInvertBrightness without gamma correction, and only available for + # Chromium's automated tests + restart: true + backend: + QtWebEngine: Qt 5.14 + QtWebKit: false + +colors.webpage.darkmode.contrast: + default: 0.0 + type: + name: Float + minval: -1.0 + maxval: 1.0 + desc: >- + Contrast for dark mode. + + This only has an effect when `colors.webpage.darkmode.algorithm` is set to + `lightness-hsl` or `brightness-rgb`. + restart: true + backend: + QtWebEngine: Qt 5.14 + QtWebKit: false + +colors.webpage.darkmode.policy.images: + default: never + type: + name: String + valid_values: + - always: Apply dark mode filter to all images. + - never: Never apply dark mode filter to any images. + - smart: Apply dark mode based on image content. + desc: Which images to apply dark mode to. + restart: true + backend: + QtWebEngine: Qt 5.14 + QtWebKit: false + +colors.webpage.darkmode.policy.page: + default: smart + type: + name: String + valid_values: + - always: Apply dark mode filter to all frames, regardless of content. + - smart: Apply dark mode filter to frames based on background color. + desc: Which pages to apply dark mode to. + restart: true + backend: + QtWebEngine: Qt 5.14 + QtWebKit: false + +colors.webpage.darkmode.threshold.text: + default: 256 + type: + name: Int + minval: 0 + maxval: 256 + desc: >- + Threshold for inverting text with dark mode. + + Text colors with brightness below this threshold will be inverted, and + above it will be left as in the original, non-dark-mode page. Set to 256 + to always invert text color or to 0 to never invert text color. + restart: true + backend: + QtWebEngine: Qt 5.14 + QtWebKit: false + +colors.webpage.darkmode.threshold.background: + default: 0 + type: + name: Int + minval: 0 + maxval: 256 + desc: >- + Threshold for inverting background elements with dark mode. + + Background elements with brightness above this threshold will be inverted, + and below it will be left as in the original, non-dark-mode page. Set to + 256 to never invert the color or to 0 to always invert it. + + Note: This behavior is the opposite of + `colors.webpage.darkmode.threshold.text`! + restart: true + backend: + QtWebEngine: Qt 5.14 + QtWebKit: false + +colors.webpage.darkmode.grayscale.all: + default: false + type: Bool + desc: >- + Render all colors as grayscale. + + This only has an effect when `colors.webpage.darkmode.algorithm` is set to + `lightness-hsl` or `brightness-rgb`. + restart: true + backend: + QtWebEngine: Qt 5.14 + QtWebKit: false + +colors.webpage.darkmode.grayscale.images: + default: 0.0 + type: + name: Float + minval: 0.0 + maxval: 1.0 + desc: >- + Desaturation factor for images in dark mode. + + If set to 0, images are left as-is. If set to 1, images are completely + grayscale. Values between 0 and 1 desaturate the colors accordingly. + restart: true + backend: + QtWebEngine: Qt 5.14 + QtWebKit: false + # emacs: ' ## fonts diff --git a/qutebrowser/config/configinit.py b/qutebrowser/config/configinit.py index b15225210..3c80cfe1b 100644 --- a/qutebrowser/config/configinit.py +++ b/qutebrowser/config/configinit.py @@ -199,6 +199,90 @@ def qt_args(namespace: argparse.Namespace) -> typing.List[str]: return argv +def _darkmode_settings() -> typing.Iterator[typing.Tuple[str, str]]: + """Get necessary blink settings to configure dark mode for QtWebEngine.""" + if not config.val.colors.webpage.darkmode.enabled: + return + + # Mapping from a colors.webpage.darkmode.algorithm setting value to + # Chromium's DarkModeInversionAlgorithm enum values. + algorithms = { + # 0: kOff (not exposed) + # 1: kSimpleInvertForTesting (not exposed) + 'brightness-rgb': 2, # kInvertBrightness + 'lightness-hsl': 3, # kInvertLightness + 'lightness-cielab': 4, # kInvertLightnessLAB + } + + # Mapping from a colors.webpage.darkmode.policy.images setting value to + # Chromium's DarkModeImagePolicy enum values. + image_policies = { + 'always': 0, # kFilterAll + 'never': 1, # kFilterNone + 'smart': 2, # kFilterSmart + } + + # Mapping from a colors.webpage.darkmode.policy.page setting value to + # Chromium's DarkModePagePolicy enum values. + page_policies = { + 'always': 0, # kFilterAll + 'smart': 1, # kFilterByBackground + } + + bools = { + True: 'true', + False: 'false', + } + + _setting_description_type = typing.Tuple[ + str, # qutebrowser option name + str, # darkmode setting name + # Mapping from the config value to a string (or something convertable + # to a string) which gets passed to Chromium. + typing.Optional[typing.Mapping[typing.Any, typing.Union[str, int]]], + ] + if qtutils.version_check('5.15', compiled=False): + settings = [ + ('enabled', 'Enabled', bools), + ('algorithm', 'InversionAlgorithm', algorithms), + ] # type: typing.List[_setting_description_type] + mandatory_setting = 'enabled' + else: + settings = [ + ('algorithm', '', algorithms), + ] + mandatory_setting = 'algorithm' + + settings += [ + ('contrast', 'Contrast', None), + ('policy.images', 'ImagePolicy', image_policies), + ('policy.page', 'PagePolicy', page_policies), + ('threshold.text', 'TextBrightnessThreshold', None), + ('threshold.background', 'BackgroundBrightnessThreshold', None), + ('grayscale.all', 'Grayscale', bools), + ('grayscale.images', 'ImageGrayscale', None), + ] + + for setting, key, mapping in settings: + # To avoid blowing up the commandline length, we only pass modified + # settings to Chromium, as our defaults line up with Chromium's. + # However, we always pass enabled/algorithm to make sure dark mode gets + # actually turned on. + value = config.instance.get( + 'colors.webpage.darkmode.' + setting, + fallback=setting == mandatory_setting) + if isinstance(value, usertypes.Unset): + continue + + if mapping is not None: + value = mapping[value] + + # FIXME: This is "forceDarkMode" starting with Chromium 83 + prefix = 'darkMode' + + yield prefix + key, str(value) + + def _qtwebengine_args(namespace: argparse.Namespace) -> typing.Iterator[str]: """Get the QtWebEngine arguments to use based on the config.""" if not qtutils.version_check('5.11', compiled=False): @@ -224,6 +308,11 @@ def _qtwebengine_args(namespace: argparse.Namespace) -> typing.Iterator[str]: yield '--enable-logging' yield '--v=1' + blink_settings = list(_darkmode_settings()) + if blink_settings: + yield '--blink-settings=' + ','.join('{}={}'.format(k, v) + for k, v in blink_settings) + settings = { 'qt.force_software_rendering': { 'software-opengl': None, diff --git a/scripts/dev/misc_checks.py b/scripts/dev/misc_checks.py index 24c3a1ddc..6bf411bba 100644 --- a/scripts/dev/misc_checks.py +++ b/scripts/dev/misc_checks.py @@ -91,7 +91,7 @@ def check_spelling(): '[Mm]ininum', '[Rr]esett?ed', '[Rr]ecieved', '[Rr]egularily', '[Uu]nderlaying', '[Ii]nexistant', '[Ee]lipsis', 'commiting', 'existant', '[Rr]esetted', '[Ss]imilarily', '[Ii]nformations', - '[Aa]n [Uu][Rr][Ll]'} + '[Aa]n [Uu][Rr][Ll]', '[Tt]reshold'} # Words which look better when splitted, but might need some fine tuning. words |= {'[Ww]ebelements', '[Mm]ouseevent', '[Kk]eysequence', diff --git a/tests/unit/config/test_configinit.py b/tests/unit/config/test_configinit.py index 731c62a66..25c6ce85e 100644 --- a/tests/unit/config/test_configinit.py +++ b/tests/unit/config/test_configinit.py @@ -28,7 +28,7 @@ import pytest from qutebrowser import qutebrowser from qutebrowser.config import (config, configexc, configfiles, configinit, configdata, configtypes) -from qutebrowser.utils import objreg, usertypes +from qutebrowser.utils import objreg, usertypes, version from helpers import utils @@ -705,6 +705,120 @@ class TestQtArgs: assert ('--force-dark-mode' in args) == added + def test_blink_settings(self, config_stub, monkeypatch, parser): + monkeypatch.setattr(configinit.objects, 'backend', + usertypes.Backend.QtWebEngine) + monkeypatch.setattr(configinit.qtutils, 'version_check', + lambda version, exact=False, compiled=True: + True) + + config_stub.val.colors.webpage.darkmode.enabled = True + + parsed = parser.parse_args([]) + args = configinit.qt_args(parsed) + + assert '--blink-settings=darkModeEnabled=true' in args + + +class TestDarkMode: + + @pytest.mark.parametrize('settings, new_qt, expected', [ + # Disabled + ({}, True, []), + ({}, False, []), + + # Enabled without customization + ( + {'enabled': True}, + True, + [('darkModeEnabled', 'true')] + ), + ( + {'enabled': True}, + False, + [('darkMode', '4')] + ), + + # Algorithm + ( + {'enabled': True, 'algorithm': 'brightness-rgb'}, + True, + [('darkModeEnabled', 'true'), + ('darkModeInversionAlgorithm', '2')], + ), + ( + {'enabled': True, 'algorithm': 'brightness-rgb'}, + False, + [('darkMode', '2')], + ), + + ]) + @utils.qt514 + def test_basics(self, config_stub, monkeypatch, + settings, new_qt, expected): + for k, v in settings.items(): + config_stub.set_obj('colors.webpage.darkmode.' + k, v) + monkeypatch.setattr(configinit.qtutils, 'version_check', + lambda version, exact=False, compiled=True: + new_qt) + + assert list(configinit._darkmode_settings()) == expected + + @pytest.mark.parametrize('setting, value, exp_key, exp_val', [ + ('contrast', -0.5, + 'darkModeContrast', '-0.5'), + ('policy.page', 'smart', + 'darkModePagePolicy', '1'), + ('policy.images', 'smart', + 'darkModeImagePolicy', '2'), + ('threshold.text', 100, + 'darkModeTextBrightnessThreshold', '100'), + ('threshold.background', 100, + 'darkModeBackgroundBrightnessThreshold', '100'), + ('grayscale.all', True, + 'darkModeGrayscale', 'true'), + ('grayscale.images', 0.5, + 'darkModeImageGrayscale', '0.5'), + ]) + def test_customization(self, config_stub, monkeypatch, + setting, value, exp_key, exp_val): + config_stub.val.colors.webpage.darkmode.enabled = True + config_stub.set_obj('colors.webpage.darkmode.' + setting, value) + monkeypatch.setattr(configinit.qtutils, 'version_check', + lambda version, exact=False, compiled=True: + True) + + expected = [('darkModeEnabled', 'true'), (exp_key, exp_val)] + assert list(configinit._darkmode_settings()) == expected + + @utils.qt514 + def test_new_chromium(self): + """Fail if we encounter an unknown Chromium version. + + Dark mode in Chromium currently is undergoing various changes (as it's + relatively recent), and Qt 5.15 is supposed to update the underlying + Chromium at some point. + + Make this test fail deliberately with newer Chromium versions, so that + we can test whether dark mode still works manually, and adjust if not. + """ + assert version._chromium_version() in [ + 'unavailable', # QtWebKit + '77.0.3865.129', # Qt 5.14 + '80.0.3987.163', # Qt 5.15 + ] + + def test_options(self, configdata_init): + """Make sure all darkmode options have the right attributes set.""" + for name, opt in configdata.DATA.items(): + if not name.startswith('colors.webpage.darkmode.'): + continue + + backends = {'QtWebEngine': 'Qt 5.14', 'QtWebKit': False} + assert not opt.supports_pattern, name + assert opt.restart, name + assert opt.raw_backends == backends, name + @pytest.mark.parametrize('arg, confval, used', [ # overridden by commandline arg -- cgit v1.2.3-54-g00ecf