1# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
2
3# Copyright 2020-2021 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
4#
5# This file is part of qutebrowser.
6#
7# qutebrowser is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# qutebrowser is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with qutebrowser.  If not, see <https://www.gnu.org/licenses/>.
19
20"""Get darkmode arguments to pass to Qt.
21
22Overview of blink setting names based on the Qt version:
23
24Qt 5.10
25-------
26
27First implementation, called "high contrast mode".
28
29- highContrastMode (kOff/kSimpleInvertForTesting/kInvertBrightness/kInvertLightness)
30- highContrastGrayscale (bool)
31- highContrastContrast (float)
32- highContractImagePolicy (kFilterAll/kFilterNone)
33
34Qt 5.11, 5.12, 5.13
35-------------------
36
37New "smart" image policy.
38
39- Mode/Grayscale/Contrast as above
40- highContractImagePolicy (kFilterAll/kFilterNone/kFilterSmart [new!])
41
42Qt 5.14
43-------
44
45Renamed to "darkMode".
46
47- darkMode (kOff/kSimpleInvertForTesting/kInvertBrightness/kInvertLightness/
48            kInvertLightnessLAB [new!])
49- darkModeGrayscale (bool)
50- darkModeContrast (float)
51- darkModeImagePolicy (kFilterAll/kFilterNone/kFilterSmart)
52- darkModePagePolicy (kFilterAll/kFilterByBackground) [new!]
53- darkModeTextBrightnessThreshold (int) [new!]
54- darkModeBackgroundBrightnessThreshold (int) [new!]
55- darkModeImageGrayscale (float) [new!]
56
57Qt 5.15.0 and 5.15.1
58--------------------
59
60"darkMode" split into "darkModeEnabled" and "darkModeInversionAlgorithm".
61
62- darkModeEnabled (bool) [new!]
63- darkModeInversionAlgorithm (kSimpleInvertForTesting/kInvertBrightness/
64                                kInvertLightness/kInvertLightnessLAB)
65- Rest (except darkMode) as above.
66- NOTE: smart image policy is broken with Qt 5.15.0!
67
68Qt 5.15.2
69---------
70
71Prefix changed to "forceDarkMode".
72
73- As with Qt 5.15.0 / .1, but with "forceDarkMode" as prefix.
74
75Qt 5.15.3
76---------
77
78Settings split to new --dark-mode-settings switch:
79https://chromium-review.googlesource.com/c/chromium/src/+/2390588
80
81- Everything except forceDarkModeEnabled goes to the other switch.
82- Algorithm uses a different enum with kOff gone.
83- No "forceDarkMode" prefix anymore.
84
85Removed DarkModePagePolicy:
86https://chromium-review.googlesource.com/c/chromium/src/+/2323441
87
88"prefers color scheme dark" changed enum values:
89https://chromium-review.googlesource.com/c/chromium/src/+/2232922
90
91- Now needs to be 0 for dark and 1 for light
92  (before: 0 no preference / 1 dark / 2 light)
93"""
94
95import os
96import copy
97import enum
98import dataclasses
99import collections
100from typing import (Any, Iterator, Mapping, MutableMapping, Optional, Set, Tuple, Union,
101                    Sequence, List)
102
103from qutebrowser.config import config
104from qutebrowser.utils import usertypes, utils, log, version
105
106
107_BLINK_SETTINGS = 'blink-settings'
108
109
110class Variant(enum.Enum):
111
112    """A dark mode variant."""
113
114    qt_511_to_513 = enum.auto()
115    qt_514 = enum.auto()
116    qt_515_0 = enum.auto()
117    qt_515_1 = enum.auto()
118    qt_515_2 = enum.auto()
119    qt_515_3 = enum.auto()
120
121
122# Mapping from a colors.webpage.darkmode.algorithm setting value to
123# Chromium's DarkModeInversionAlgorithm enum values.
124_ALGORITHMS = {
125    # 0: kOff (not exposed)
126    # 1: kSimpleInvertForTesting (not exposed)
127    'brightness-rgb': 2,  # kInvertBrightness
128    'lightness-hsl': 3,  # kInvertLightness
129    'lightness-cielab': 4,  # kInvertLightnessLAB
130}
131# kInvertLightnessLAB is not available with Qt < 5.14
132_ALGORITHMS_BEFORE_QT_514 = _ALGORITHMS.copy()
133_ALGORITHMS_BEFORE_QT_514['lightness-cielab'] = _ALGORITHMS['lightness-hsl']
134# Qt >= 5.15.3, based on dark_mode_settings.h
135_ALGORITHMS_NEW = {
136    # 0: kSimpleInvertForTesting (not exposed)
137    'brightness-rgb': 1,  # kInvertBrightness
138    'lightness-hsl': 2,  # kInvertLightness
139    'lightness-cielab': 3,  # kInvertLightnessLAB
140}
141
142# Mapping from a colors.webpage.darkmode.policy.images setting value to
143# Chromium's DarkModeImagePolicy enum values.
144# Values line up with dark_mode_settings.h for 5.15.3+.
145_IMAGE_POLICIES = {
146    'always': 0,  # kFilterAll
147    'never': 1,  # kFilterNone
148    'smart': 2,  # kFilterSmart
149}
150
151# Mapping from a colors.webpage.darkmode.policy.page setting value to
152# Chromium's DarkModePagePolicy enum values.
153_PAGE_POLICIES = {
154    'always': 0,  # kFilterAll
155    'smart': 1,  # kFilterByBackground
156}
157
158_BOOLS = {
159    True: 'true',
160    False: 'false',
161}
162
163
164@dataclasses.dataclass
165class _Setting:
166
167    """A single dark mode setting."""
168
169    option: str
170    chromium_key: str
171    mapping: Optional[Mapping[Any, Union[str, int]]] = None
172
173    def _value_str(self, value: Any) -> str:
174        if self.mapping is None:
175            return str(value)
176        return str(self.mapping[value])
177
178    def chromium_tuple(self, value: Any) -> Tuple[str, str]:
179        return self.chromium_key, self._value_str(value)
180
181    def with_prefix(self, prefix: str) -> '_Setting':
182        return _Setting(
183            option=self.option,
184            chromium_key=prefix + self.chromium_key,
185            mapping=self.mapping,
186        )
187
188
189class _Definition:
190
191    """A collection of dark mode setting names for the given QtWebEngine version.
192
193    Attributes:
194        _settings: A list of _Setting objects for this definition.
195        mandatory: A set of settings which should always be passed to Chromium, even if
196                   not customized from the default.
197        prefix: A string prefix to add to all Chromium setting names.
198        switch_names: A dict mapping option names to the Chromium switch they belong to.
199                      None is used as fallback key, i.e. for settings not in the dict.
200    """
201
202    def __init__(
203            self,
204            *args: _Setting,
205            mandatory: Set[str],
206            prefix: str,
207            switch_names: Mapping[Optional[str], str] = None,
208    ) -> None:
209        self._settings = args
210        self.mandatory = mandatory
211        self.prefix = prefix
212
213        if switch_names is not None:
214            self._switch_names = switch_names
215        else:
216            self._switch_names = {None: _BLINK_SETTINGS}
217
218    def prefixed_settings(self) -> Iterator[Tuple[str, _Setting]]:
219        """Get all "prepared" settings.
220
221        Yields tuples which contain the Chromium setting key (e.g. 'blink-settings' or
222        'dark-mode-settings') and the corresponding _Settings object.
223        """
224        for setting in self._settings:
225            switch = self._switch_names.get(setting.option, self._switch_names[None])
226            yield switch, setting.with_prefix(self.prefix)
227
228    def copy_with(self, attr: str, value: Any) -> '_Definition':
229        """Get a new _Definition object with a changed attribute.
230
231        NOTE: This does *not* copy the settings list. Both objects will reference the
232        same list.
233        """
234        new = copy.copy(self)
235        setattr(new, attr, value)
236        return new
237
238
239# Our defaults for policy.images are different from Chromium's, so we mark it as
240# mandatory setting - except on Qt 5.15.0 where we don't, so we don't get the
241# workaround warning below if the setting wasn't explicitly customized.
242
243_DEFINITIONS: MutableMapping[Variant, _Definition] = {
244    Variant.qt_515_3: _Definition(
245        # Different switch for settings
246        _Setting('enabled', 'forceDarkModeEnabled', _BOOLS),
247        _Setting('algorithm', 'InversionAlgorithm', _ALGORITHMS_NEW),
248
249        _Setting('policy.images', 'ImagePolicy', _IMAGE_POLICIES),
250        _Setting('contrast', 'ContrastPercent'),
251        _Setting('grayscale.all', 'IsGrayScale', _BOOLS),
252
253        _Setting('threshold.text', 'TextBrightnessThreshold'),
254        _Setting('threshold.background', 'BackgroundBrightnessThreshold'),
255        _Setting('grayscale.images', 'ImageGrayScalePercent'),
256
257        mandatory={'enabled', 'policy.images'},
258        prefix='',
259        switch_names={'enabled': _BLINK_SETTINGS, None: 'dark-mode-settings'},
260    ),
261
262    # Qt 5.15.1 and 5.15.2 get added below, since there are only minor differences.
263
264    Variant.qt_515_0: _Definition(
265        # 'policy.images' not mandatory because it's broken
266        _Setting('enabled', 'Enabled', _BOOLS),
267        _Setting('algorithm', 'InversionAlgorithm', _ALGORITHMS),
268
269        _Setting('policy.images', 'ImagePolicy', _IMAGE_POLICIES),
270        _Setting('contrast', 'Contrast'),
271        _Setting('grayscale.all', 'Grayscale', _BOOLS),
272
273        _Setting('policy.page', 'PagePolicy', _PAGE_POLICIES),
274        _Setting('threshold.text', 'TextBrightnessThreshold'),
275        _Setting('threshold.background', 'BackgroundBrightnessThreshold'),
276        _Setting('grayscale.images', 'ImageGrayscale'),
277
278        mandatory={'enabled'},
279        prefix='darkMode',
280    ),
281
282    Variant.qt_514: _Definition(
283        _Setting('algorithm', '', _ALGORITHMS),  # new: kInvertLightnessLAB
284
285        _Setting('policy.images', 'ImagePolicy', _IMAGE_POLICIES),
286        _Setting('contrast', 'Contrast'),
287        _Setting('grayscale.all', 'Grayscale', _BOOLS),
288
289        _Setting('policy.page', 'PagePolicy', _PAGE_POLICIES),
290        _Setting('threshold.text', 'TextBrightnessThreshold'),
291        _Setting('threshold.background', 'BackgroundBrightnessThreshold'),
292        _Setting('grayscale.images', 'ImageGrayscale'),
293
294        mandatory={'algorithm', 'policy.images'},
295        prefix='darkMode',
296    ),
297
298    Variant.qt_511_to_513: _Definition(
299        _Setting('algorithm', 'Mode', _ALGORITHMS_BEFORE_QT_514),
300
301        _Setting('policy.images', 'ImagePolicy', _IMAGE_POLICIES),
302        _Setting('contrast', 'Contrast'),
303        _Setting('grayscale.all', 'Grayscale', _BOOLS),
304
305        mandatory={'algorithm', 'policy.images'},
306        prefix='highContrast',
307    ),
308}
309_DEFINITIONS[Variant.qt_515_1] = (
310    _DEFINITIONS[Variant.qt_515_0].copy_with('mandatory', {'enabled', 'policy.images'}))
311_DEFINITIONS[Variant.qt_515_2] = (
312    _DEFINITIONS[Variant.qt_515_1].copy_with('prefix', 'forceDarkMode'))
313
314
315_PREFERRED_COLOR_SCHEME_DEFINITIONS = {
316    # With older Qt versions, this is passed in qtargs.py as --force-dark-mode
317    # instead.
318
319    ## Qt 5.15.2
320    # 0: no-preference (not exposed)
321    (Variant.qt_515_2, "dark"): "1",
322    (Variant.qt_515_2, "light"): "2",
323    # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-89753
324    # Fall back to "light" instead of "no preference" (which was removed from the
325    # standard)
326    (Variant.qt_515_2, "auto"): "2",
327    (Variant.qt_515_2, usertypes.UNSET): "2",
328
329    ## Qt >= 5.15.3
330    (Variant.qt_515_3, "dark"): "0",
331    (Variant.qt_515_3, "light"): "1",
332}
333
334
335def _variant(versions: version.WebEngineVersions) -> Variant:
336    """Get the dark mode variant based on the underlying Qt version."""
337    env_var = os.environ.get('QUTE_DARKMODE_VARIANT')
338    if env_var is not None:
339        try:
340            return Variant[env_var]
341        except KeyError:
342            log.init.warning(f"Ignoring invalid QUTE_DARKMODE_VARIANT={env_var}")
343
344    if (versions.webengine == utils.VersionNumber(5, 15, 2) and
345            versions.chromium_major == 87):
346        # WORKAROUND for Gentoo packaging something newer as 5.15.2...
347        return Variant.qt_515_3
348    elif versions.webengine >= utils.VersionNumber(5, 15, 3):
349        return Variant.qt_515_3
350    elif versions.webengine >= utils.VersionNumber(5, 15, 2):
351        return Variant.qt_515_2
352    elif versions.webengine == utils.VersionNumber(5, 15, 1):
353        return Variant.qt_515_1
354    elif versions.webengine == utils.VersionNumber(5, 15):
355        return Variant.qt_515_0
356    elif versions.webengine >= utils.VersionNumber(5, 14):
357        return Variant.qt_514
358    elif versions.webengine >= utils.VersionNumber(5, 11):
359        return Variant.qt_511_to_513
360    raise utils.Unreachable(versions.webengine)
361
362
363def settings(
364        *,
365        versions: version.WebEngineVersions,
366        special_flags: Sequence[str],
367) -> Mapping[str, Sequence[Tuple[str, str]]]:
368    """Get necessary blink settings to configure dark mode for QtWebEngine.
369
370    Args:
371       Existing '--blink-settings=...' flags, if any.
372
373    Returns:
374        A dict which maps Chromium switch names (blink-settings or dark-mode-settings)
375        to a sequence of tuples, each tuple being a key/value pair to pass to that
376        setting.
377    """
378    variant = _variant(versions)
379    log.init.debug(f"Darkmode variant: {variant.name}")
380
381    result: Mapping[str, List[Tuple[str, str]]] = collections.defaultdict(list)
382
383    blink_settings_flag = f'--{_BLINK_SETTINGS}='
384    for flag in special_flags:
385        if flag.startswith(blink_settings_flag):
386            for pair in flag[len(blink_settings_flag):].split(','):
387                key, val = pair.split('=', maxsplit=1)
388                result[_BLINK_SETTINGS].append((key, val))
389
390    preferred_color_scheme_key = (
391        variant,
392        config.instance.get("colors.webpage.preferred_color_scheme", fallback=False),
393    )
394    if preferred_color_scheme_key in _PREFERRED_COLOR_SCHEME_DEFINITIONS:
395        value = _PREFERRED_COLOR_SCHEME_DEFINITIONS[preferred_color_scheme_key]
396        result[_BLINK_SETTINGS].append(("preferredColorScheme", value))
397
398    if not config.val.colors.webpage.darkmode.enabled:
399        return result
400
401    definition = _DEFINITIONS[variant]
402
403    for switch_name, setting in definition.prefixed_settings():
404        # To avoid blowing up the commandline length, we only pass modified
405        # settings to Chromium, as our defaults line up with Chromium's.
406        # However, we always pass enabled/algorithm to make sure dark mode gets
407        # actually turned on.
408        value = config.instance.get(
409            'colors.webpage.darkmode.' + setting.option,
410            fallback=setting.option in definition.mandatory)
411        if isinstance(value, usertypes.Unset):
412            continue
413
414        if (setting.option == 'policy.images' and value == 'smart' and
415                variant == Variant.qt_515_0):
416            # WORKAROUND for
417            # https://codereview.qt-project.org/c/qt/qtwebengine-chromium/+/304211
418            log.init.warning("Ignoring colors.webpage.darkmode.policy.images = smart "
419                             "because of Qt 5.15.0 bug")
420            continue
421
422        result[switch_name].append(setting.chromium_tuple(value))
423
424    return result
425