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