├── .gitignore ├── MANIFEST.in ├── darkdetect ├── __main__.py ├── _dummy.py ├── __init__.py ├── _linux_detect.py ├── _mac_detect.py └── _windows_detect.py ├── pyproject.toml ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | .*.sw[po] 3 | venv 4 | 5 | /build 6 | /dist 7 | /*.egg-info 8 | .* 9 | !.travis.yml 10 | !.appveyor.yml 11 | __pycache__ 12 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | include setup.py 4 | graft darkdetect 5 | 6 | # Patterns to exclude from any directory 7 | global-exclude *~ 8 | global-exclude *.pyc 9 | global-exclude __pycache__ 10 | global-exclude .git 11 | -------------------------------------------------------------------------------- /darkdetect/__main__.py: -------------------------------------------------------------------------------- 1 | #----------------------------------------------------------------------------- 2 | # Copyright (C) 2019 Alberto Sottile 3 | # 4 | # Distributed under the terms of the 3-clause BSD License. 5 | #----------------------------------------------------------------------------- 6 | 7 | import darkdetect 8 | 9 | print('Current theme: {}'.format(darkdetect.theme())) 10 | -------------------------------------------------------------------------------- /darkdetect/_dummy.py: -------------------------------------------------------------------------------- 1 | #----------------------------------------------------------------------------- 2 | # Copyright (C) 2019 Alberto Sottile 3 | # 4 | # Distributed under the terms of the 3-clause BSD License. 5 | #----------------------------------------------------------------------------- 6 | 7 | import typing 8 | 9 | def theme(): 10 | return None 11 | 12 | def isDark(): 13 | return None 14 | 15 | def isLight(): 16 | return None 17 | 18 | def listener(callback: typing.Callable[[str], None]) -> None: 19 | raise NotImplementedError() 20 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.2"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | # Project Config 6 | 7 | [project] 8 | name = "darkdetect" 9 | description = "Detect OS Dark Mode from Python" 10 | readme = "README.md" 11 | requires-python = ">=3.6" 12 | dynamic = [ "version" ] 13 | classifiers = [ 14 | "License :: OSI Approved :: BSD License", 15 | "Operating System :: MacOS :: MacOS X", 16 | "Operating System :: Microsoft :: Windows :: Windows 10", 17 | "Operating System :: POSIX :: Linux", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.6", 20 | "Programming Language :: Python :: 3.7", 21 | "Programming Language :: Python :: 3.8", 22 | "Programming Language :: Python :: 3.9", 23 | "Programming Language :: Python :: 3.10", 24 | ] 25 | 26 | [project.license] 27 | text = "BSD-3-Clause" 28 | 29 | [[project.authors]] 30 | name = "Alberto Sottile" 31 | email = "asottile@gmail.com" 32 | 33 | [project.urls] 34 | homepage = "http://github.com/albertosottile/darkdetect" 35 | download = "http://github.com/albertosottile/darkdetect/releases" 36 | 37 | [project.optional-dependencies] 38 | macos-listener = [ "pyobjc-framework-Cocoa; platform_system == 'Darwin'" ] 39 | 40 | # Tool Config 41 | 42 | [tool.setuptools] 43 | include-package-data = true 44 | 45 | [tool.setuptools.dynamic.version] 46 | attr = "darkdetect.__version__" 47 | 48 | [tool.setuptools.packages.find] 49 | namespaces = false 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019, Alberto Sottile 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | * Redistributions of source code must retain the above copyright 7 | notice, this list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright 9 | notice, this list of conditions and the following disclaimer in the 10 | documentation and/or other materials provided with the distribution. 11 | * Neither the name of "darkdetect" nor the 12 | names of its contributors may be used to endorse or promote products 13 | derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL "Alberto Sottile" BE LIABLE FOR ANY 19 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 22 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | -------------------------------------------------------------------------------- /darkdetect/__init__.py: -------------------------------------------------------------------------------- 1 | #----------------------------------------------------------------------------- 2 | # Copyright (C) 2019 Alberto Sottile 3 | # 4 | # Distributed under the terms of the 3-clause BSD License. 5 | #----------------------------------------------------------------------------- 6 | 7 | __version__ = '0.8.0' 8 | 9 | import sys 10 | import platform 11 | 12 | def macos_supported_version(): 13 | sysver = platform.mac_ver()[0] #typically 10.14.2 or 12.3 14 | major = int(sysver.split('.')[0]) 15 | if major < 10: 16 | return False 17 | elif major >= 11: 18 | return True 19 | else: 20 | minor = int(sysver.split('.')[1]) 21 | if minor < 14: 22 | return False 23 | else: 24 | return True 25 | 26 | if sys.platform == "darwin": 27 | if macos_supported_version(): 28 | from ._mac_detect import * 29 | else: 30 | from ._dummy import * 31 | elif sys.platform == "win32" and platform.release().isdigit() and int(platform.release()) >= 10: 32 | # Checks if running Windows 10 version 10.0.14393 (Anniversary Update) OR HIGHER. The getwindowsversion method returns a tuple. 33 | # The third item is the build number that we can use to check if the user has a new enough version of Windows. 34 | winver = int(platform.version().split('.')[2]) 35 | if winver >= 14393: 36 | from ._windows_detect import * 37 | else: 38 | from ._dummy import * 39 | elif sys.platform == "linux": 40 | from ._linux_detect import * 41 | else: 42 | from ._dummy import * 43 | 44 | del sys, platform 45 | -------------------------------------------------------------------------------- /darkdetect/_linux_detect.py: -------------------------------------------------------------------------------- 1 | #----------------------------------------------------------------------------- 2 | # Copyright (C) 2019 Alberto Sottile, Eric Larson 3 | # 4 | # Distributed under the terms of the 3-clause BSD License. 5 | #----------------------------------------------------------------------------- 6 | 7 | import subprocess 8 | 9 | def theme(): 10 | try: 11 | #Using the freedesktop specifications for checking dark mode 12 | out = subprocess.run( 13 | ['gsettings', 'get', 'org.gnome.desktop.interface', 'color-scheme'], 14 | capture_output=True) 15 | stdout = out.stdout.decode() 16 | #If not found then trying older gtk-theme method 17 | if len(stdout)<1: 18 | out = subprocess.run( 19 | ['gsettings', 'get', 'org.gnome.desktop.interface', 'gtk-theme'], 20 | capture_output=True) 21 | stdout = out.stdout.decode() 22 | except Exception: 23 | return 'Light' 24 | # we have a string, now remove start and end quote 25 | theme = stdout.lower().strip()[1:-1] 26 | if '-dark' in theme.lower(): 27 | return 'Dark' 28 | else: 29 | return 'Light' 30 | 31 | def isDark(): 32 | return theme() == 'Dark' 33 | 34 | def isLight(): 35 | return theme() == 'Light' 36 | 37 | # def listener(callback: typing.Callable[[str], None]) -> None: 38 | def listener(callback): 39 | with subprocess.Popen( 40 | ('gsettings', 'monitor', 'org.gnome.desktop.interface', 'gtk-theme'), 41 | stdout=subprocess.PIPE, 42 | universal_newlines=True, 43 | ) as p: 44 | for line in p.stdout: 45 | callback('Dark' if '-dark' in line.strip().removeprefix("gtk-theme: '").removesuffix("'").lower() else 'Light') 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Darkdetect 2 | 3 | This package allows to detect if the user is using Dark Mode on: 4 | 5 | - [macOS 10.14+](https://support.apple.com/en-us/HT208976) 6 | - [Windows 10 1607+](https://blogs.windows.com/windowsexperience/2016/08/08/windows-10-tip-personalize-your-pc-by-enabling-the-dark-theme/) 7 | - Linux with [a dark GTK theme](https://www.gnome-look.org/browse/cat/135/ord/rating/?tag=dark). 8 | 9 | The main application of this package is to detect the Dark mode from your GUI Python application (Tkinter/wx/pyqt/qt for python (pyside)/...) and apply the needed adjustments to your interface. Darkdetect is particularly useful if your GUI library **does not** provide a public API for this detection (I am looking at you, Qt). In addition, this package does not depend on other modules or packages that are not already included in standard Python distributions. 10 | 11 | 12 | ## Usage 13 | 14 | ``` 15 | import darkdetect 16 | 17 | >>> darkdetect.theme() 18 | 'Dark' 19 | 20 | >>> darkdetect.isDark() 21 | True 22 | 23 | >>> darkdetect.isLight() 24 | False 25 | ``` 26 | It's that easy. 27 | 28 | You can create a dark mode switch listener daemon thread with `darkdetect.listener` and pass a callback function. The function will be called with string "Dark" or "Light" when the OS switches the dark mode setting. 29 | 30 | ``` python 31 | import threading 32 | import darkdetect 33 | 34 | # def listener(callback: typing.Callable[[str], None]) -> None: ... 35 | 36 | t = threading.Thread(target=darkdetect.listener, args=(print,)) 37 | t.daemon = True 38 | t.start() 39 | ``` 40 | 41 | ## Install 42 | 43 | The preferred channel is PyPI: 44 | ``` 45 | pip install darkdetect 46 | ``` 47 | 48 | Alternatively, you are free to vendor directly a copy of Darkdetect in your app. Further information on vendoring can be found [here](https://medium.com/underdog-io-engineering/vendoring-python-dependencies-with-pip-b9eb6078b9c0). 49 | 50 | ## Optional Installs 51 | 52 | To enable the macOS listener, additional components are required, these can be installed via: 53 | ```bash 54 | pip install darkdetect[macos-listener] 55 | ``` 56 | 57 | ## Notes 58 | 59 | - This software is licensed under the terms of the 3-clause BSD License. 60 | - This package can be installed on any operative system, but it will always return `None` unless executed on a OS that supports Dark Mode, including older versions of macOS and Windows. 61 | - On macOS, detection of the dark menu bar and dock option (available from macOS 10.10) is not supported. 62 | - [Details](https://stackoverflow.com/questions/25207077/how-to-detect-if-os-x-is-in-dark-mode) on the detection method used on macOS. 63 | - [Details](https://askubuntu.com/questions/1261366/detecting-dark-mode#comment2132694_1261366) on the experimental detection method used on Linux. 64 | -------------------------------------------------------------------------------- /darkdetect/_mac_detect.py: -------------------------------------------------------------------------------- 1 | #----------------------------------------------------------------------------- 2 | # Copyright (C) 2019 Alberto Sottile 3 | # 4 | # Distributed under the terms of the 3-clause BSD License. 5 | #----------------------------------------------------------------------------- 6 | 7 | import ctypes 8 | import ctypes.util 9 | import subprocess 10 | import sys 11 | import os 12 | from pathlib import Path 13 | from typing import Callable 14 | 15 | try: 16 | from Foundation import NSObject, NSKeyValueObservingOptionNew, NSKeyValueChangeNewKey, NSUserDefaults 17 | from PyObjCTools import AppHelper 18 | _can_listen = True 19 | except ModuleNotFoundError: 20 | _can_listen = False 21 | 22 | 23 | try: 24 | # macOS Big Sur+ use "a built-in dynamic linker cache of all system-provided libraries" 25 | appkit = ctypes.cdll.LoadLibrary('AppKit.framework/AppKit') 26 | objc = ctypes.cdll.LoadLibrary('libobjc.dylib') 27 | except OSError: 28 | # revert to full path for older OS versions and hardened programs 29 | appkit = ctypes.cdll.LoadLibrary(ctypes.util.find_library('AppKit')) 30 | objc = ctypes.cdll.LoadLibrary(ctypes.util.find_library('objc')) 31 | 32 | void_p = ctypes.c_void_p 33 | ull = ctypes.c_uint64 34 | 35 | objc.objc_getClass.restype = void_p 36 | objc.sel_registerName.restype = void_p 37 | 38 | # See https://docs.python.org/3/library/ctypes.html#function-prototypes for arguments description 39 | MSGPROTOTYPE = ctypes.CFUNCTYPE(void_p, void_p, void_p, void_p) 40 | msg = MSGPROTOTYPE(('objc_msgSend', objc), ((1 ,'', None), (1, '', None), (1, '', None))) 41 | 42 | def _utf8(s): 43 | if not isinstance(s, bytes): 44 | s = s.encode('utf8') 45 | return s 46 | 47 | def n(name): 48 | return objc.sel_registerName(_utf8(name)) 49 | 50 | def C(classname): 51 | return objc.objc_getClass(_utf8(classname)) 52 | 53 | def theme(): 54 | NSAutoreleasePool = objc.objc_getClass('NSAutoreleasePool') 55 | pool = msg(NSAutoreleasePool, n('alloc')) 56 | pool = msg(pool, n('init')) 57 | 58 | NSUserDefaults = C('NSUserDefaults') 59 | stdUserDef = msg(NSUserDefaults, n('standardUserDefaults')) 60 | 61 | NSString = C('NSString') 62 | 63 | key = msg(NSString, n("stringWithUTF8String:"), _utf8('AppleInterfaceStyle')) 64 | appearanceNS = msg(stdUserDef, n('stringForKey:'), void_p(key)) 65 | appearanceC = msg(appearanceNS, n('UTF8String')) 66 | 67 | if appearanceC is not None: 68 | out = ctypes.string_at(appearanceC) 69 | else: 70 | out = None 71 | 72 | msg(pool, n('release')) 73 | 74 | if out is not None: 75 | return out.decode('utf-8') 76 | else: 77 | return 'Light' 78 | 79 | def isDark(): 80 | return theme() == 'Dark' 81 | 82 | def isLight(): 83 | return theme() == 'Light' 84 | 85 | 86 | def _listen_child(): 87 | """ 88 | Run by a child process, install an observer and print theme on change 89 | """ 90 | import signal 91 | signal.signal(signal.SIGINT, signal.SIG_IGN) 92 | 93 | OBSERVED_KEY = "AppleInterfaceStyle" 94 | 95 | class Observer(NSObject): 96 | def observeValueForKeyPath_ofObject_change_context_( 97 | self, path, object, changeDescription, context 98 | ): 99 | result = changeDescription[NSKeyValueChangeNewKey] 100 | try: 101 | print(f"{'Light' if result is None else result}", flush=True) 102 | except IOError: 103 | os._exit(1) 104 | 105 | observer = Observer.new() # Keep a reference alive after installing 106 | defaults = NSUserDefaults.standardUserDefaults() 107 | defaults.addObserver_forKeyPath_options_context_( 108 | observer, OBSERVED_KEY, NSKeyValueObservingOptionNew, 0 109 | ) 110 | 111 | AppHelper.runConsoleEventLoop() 112 | 113 | 114 | def listener(callback: Callable[[str], None]) -> None: 115 | if not _can_listen: 116 | raise NotImplementedError() 117 | with subprocess.Popen( 118 | (sys.executable, "-c", "import _mac_detect as m; m._listen_child()"), 119 | stdout=subprocess.PIPE, 120 | universal_newlines=True, 121 | cwd=Path(__file__).parent, 122 | ) as p: 123 | for line in p.stdout: 124 | callback(line.strip()) 125 | -------------------------------------------------------------------------------- /darkdetect/_windows_detect.py: -------------------------------------------------------------------------------- 1 | from winreg import HKEY_CURRENT_USER as hkey, QueryValueEx as getSubkeyValue, OpenKey as getKey 2 | 3 | import ctypes 4 | import ctypes.wintypes 5 | 6 | advapi32 = ctypes.windll.advapi32 7 | 8 | # LSTATUS RegOpenKeyExA( 9 | # HKEY hKey, 10 | # LPCSTR lpSubKey, 11 | # DWORD ulOptions, 12 | # REGSAM samDesired, 13 | # PHKEY phkResult 14 | # ); 15 | advapi32.RegOpenKeyExA.argtypes = ( 16 | ctypes.wintypes.HKEY, 17 | ctypes.wintypes.LPCSTR, 18 | ctypes.wintypes.DWORD, 19 | ctypes.wintypes.DWORD, 20 | ctypes.POINTER(ctypes.wintypes.HKEY), 21 | ) 22 | advapi32.RegOpenKeyExA.restype = ctypes.wintypes.LONG 23 | 24 | # LSTATUS RegQueryValueExA( 25 | # HKEY hKey, 26 | # LPCSTR lpValueName, 27 | # LPDWORD lpReserved, 28 | # LPDWORD lpType, 29 | # LPBYTE lpData, 30 | # LPDWORD lpcbData 31 | # ); 32 | advapi32.RegQueryValueExA.argtypes = ( 33 | ctypes.wintypes.HKEY, 34 | ctypes.wintypes.LPCSTR, 35 | ctypes.wintypes.LPDWORD, 36 | ctypes.wintypes.LPDWORD, 37 | ctypes.wintypes.LPBYTE, 38 | ctypes.wintypes.LPDWORD, 39 | ) 40 | advapi32.RegQueryValueExA.restype = ctypes.wintypes.LONG 41 | 42 | # LSTATUS RegNotifyChangeKeyValue( 43 | # HKEY hKey, 44 | # WINBOOL bWatchSubtree, 45 | # DWORD dwNotifyFilter, 46 | # HANDLE hEvent, 47 | # WINBOOL fAsynchronous 48 | # ); 49 | advapi32.RegNotifyChangeKeyValue.argtypes = ( 50 | ctypes.wintypes.HKEY, 51 | ctypes.wintypes.BOOL, 52 | ctypes.wintypes.DWORD, 53 | ctypes.wintypes.HANDLE, 54 | ctypes.wintypes.BOOL, 55 | ) 56 | advapi32.RegNotifyChangeKeyValue.restype = ctypes.wintypes.LONG 57 | 58 | def theme(): 59 | """ Uses the Windows Registry to detect if the user is using Dark Mode """ 60 | # Registry will return 0 if Windows is in Dark Mode and 1 if Windows is in Light Mode. This dictionary converts that output into the text that the program is expecting. 61 | valueMeaning = {0: "Dark", 1: "Light"} 62 | # In HKEY_CURRENT_USER, get the Personalisation Key. 63 | try: 64 | key = getKey(hkey, "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize") 65 | # In the Personalisation Key, get the AppsUseLightTheme subkey. This returns a tuple. 66 | # The first item in the tuple is the result we want (0 or 1 indicating Dark Mode or Light Mode); the other value is the type of subkey e.g. DWORD, QWORD, String, etc. 67 | subkey = getSubkeyValue(key, "AppsUseLightTheme")[0] 68 | except FileNotFoundError: 69 | # some headless Windows instances (e.g. GitHub Actions or Docker images) do not have this key 70 | return None 71 | return valueMeaning[subkey] 72 | 73 | def isDark(): 74 | if theme() is not None: 75 | return theme() == 'Dark' 76 | 77 | def isLight(): 78 | if theme() is not None: 79 | return theme() == 'Light' 80 | 81 | #def listener(callback: typing.Callable[[str], None]) -> None: 82 | def listener(callback): 83 | hKey = ctypes.wintypes.HKEY() 84 | advapi32.RegOpenKeyExA( 85 | ctypes.wintypes.HKEY(0x80000001), # HKEY_CURRENT_USER 86 | ctypes.wintypes.LPCSTR(b'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize'), 87 | ctypes.wintypes.DWORD(), 88 | ctypes.wintypes.DWORD(0x00020019), # KEY_READ 89 | ctypes.byref(hKey), 90 | ) 91 | 92 | dwSize = ctypes.wintypes.DWORD(ctypes.sizeof(ctypes.wintypes.DWORD)) 93 | queryValueLast = ctypes.wintypes.DWORD() 94 | queryValue = ctypes.wintypes.DWORD() 95 | advapi32.RegQueryValueExA( 96 | hKey, 97 | ctypes.wintypes.LPCSTR(b'AppsUseLightTheme'), 98 | ctypes.wintypes.LPDWORD(), 99 | ctypes.wintypes.LPDWORD(), 100 | ctypes.cast(ctypes.byref(queryValueLast), ctypes.wintypes.LPBYTE), 101 | ctypes.byref(dwSize), 102 | ) 103 | 104 | while True: 105 | advapi32.RegNotifyChangeKeyValue( 106 | hKey, 107 | ctypes.wintypes.BOOL(True), 108 | ctypes.wintypes.DWORD(0x00000004), # REG_NOTIFY_CHANGE_LAST_SET 109 | ctypes.wintypes.HANDLE(None), 110 | ctypes.wintypes.BOOL(False), 111 | ) 112 | advapi32.RegQueryValueExA( 113 | hKey, 114 | ctypes.wintypes.LPCSTR(b'AppsUseLightTheme'), 115 | ctypes.wintypes.LPDWORD(), 116 | ctypes.wintypes.LPDWORD(), 117 | ctypes.cast(ctypes.byref(queryValue), ctypes.wintypes.LPBYTE), 118 | ctypes.byref(dwSize), 119 | ) 120 | if queryValueLast.value != queryValue.value: 121 | queryValueLast.value = queryValue.value 122 | callback('Light' if queryValue.value else 'Dark') 123 | --------------------------------------------------------------------------------