Skip to content

Instantly share code, notes, and snippets.

@59de44955ebd
Last active September 29, 2024 12:45
Show Gist options
  • Save 59de44955ebd/175fc0af5047be2eddb31ebb6c065720 to your computer and use it in GitHub Desktop.
Save 59de44955ebd/175fc0af5047be2eddb31ebb6c065720 to your computer and use it in GitHub Desktop.
Dark mode for Python's IDLE in Windows 10/11 (hackish proof of concept)
'''
Dark mode for IDLE in Windows 10/11 (hackish proof of concept)
Usage: python idle_win_dark.py <some_file.py>
This will open <some_file.py> in IDLE, using dark mode for the GUI.
Note: the screenshot also uses a custom dark theme inside the editor,
which is just a copy of the official "IDLE Dark" theme with all background
colors changed to dark grey (#282828), and the font changed to Cascadia Code.
'''
import sys
from ctypes import *
from ctypes.wintypes import *
from tkinter import NSEW
from tkinter.ttk import Style
########################################
# Additional used data types
########################################
is_64_bit = sys.maxsize > 2**32
LONG_PTR = c_longlong if is_64_bit else c_long
ULONG_PTR = c_uint64 if is_64_bit else c_ulong
DWORD_PTR = ULONG_PTR
INT_PTR = c_int64 if is_64_bit else c_int
UINT_PTR = WPARAM
WNDPROC = WINFUNCTYPE(LONG_PTR, HWND, UINT, WPARAM, LPARAM)
########################################
# Used constants
########################################
DT_CENTER = 1
DT_SINGLELINE = 32
DT_VCENTER = 4
DWMWA_USE_IMMERSIVE_DARK_MODE = 20
GWL_WNDPROC = -4
MAX_PATH = 260
MIIM_STRING = 64
MIM_APPLYTOSUBMENUS = -2147483648
MIM_BACKGROUND = 2
OBJID_MENU = -3
ODS_HOTLIGHT = 64
ODS_SELECTED = 1
RDW_ALLCHILDREN = 128
RDW_ERASE = 4
RDW_FRAME = 1024
RDW_INVALIDATE = 1
TRANSPARENT = 1
WM_DRAWITEM = 43
WM_DROPFILES = 563
WM_NCACTIVATE = 134
WM_NCPAINT = 133
WM_UAHDRAWMENU = 0x0091
WM_UAHDRAWMENUITEM = 0x0092
########################################
# Used structures
########################################
class MENUINFO(Structure):
def __init__(self, *args, **kwargs):
super(MENUINFO, self).__init__(*args, **kwargs)
self.cbSize = sizeof(self)
_fields_ = [
('cbSize', DWORD),
('fMask', DWORD),
('dwStyle', DWORD),
('cyMax', UINT),
('hbrBack', HBRUSH),
('dwContextHelpID', DWORD),
('dwMenuData', ULONG_PTR),
]
class MENUITEMINFOW(Structure):
def __init__(self, *args, **kwargs):
super(MENUITEMINFOW, self).__init__(*args, **kwargs)
self.cbSize = sizeof(self)
_fields_ = [
('cbSize', UINT),
('fMask', UINT),
('fType', UINT),
('fState', UINT),
('wID', UINT),
('hSubMenu', HMENU),
('hbmpChecked', HBITMAP),
('hbmpUnchecked', HBITMAP),
('dwItemData', HANDLE), #ULONG_PTR
('dwTypeData', LPWSTR),
('cch', UINT),
('hbmpItem', HANDLE),
]
class MENUBARINFO(Structure):
def __init__(self, *args, **kwargs):
super(MENUBARINFO, self).__init__(*args, **kwargs)
self.cbSize = sizeof(self)
_pack_ = 4
_fields_ = [
('cbSize', DWORD),
('rcBar', RECT),
('hMenu', HMENU),
('hwndMenu', HWND),
('fBarFocused', BOOL),
('fFocused', BOOL),
('fUnused', BOOL),
]
class MEASUREITEMSTRUCT(Structure):
_fields_ = [
('CtlType', UINT),
('CtlID', UINT),
('itemID', UINT),
('itemWidth', UINT),
('itemHeight', UINT),
('lItemlParam', ULONG_PTR),
]
class DRAWITEMSTRUCT(Structure):
_fields_ = [
('CtlType', UINT),
('CtlID', UINT),
('itemID', UINT),
('itemAction', UINT),
('itemState', UINT),
('hwndItem', HWND),
('hDC', HDC),
('rcItem', RECT),
('itemData', ULONG_PTR),
]
class _METRICS(Structure):
_fields_ = [
('cx', DWORD),
('cy', DWORD),
]
# Describes the sizes of the menu bar or menu item
class UAHMENUITEMMETRICS(Structure):
_fields_ = [
('rgsizeBar', _METRICS * 2),
('rgsizePopup', _METRICS * 4),
]
# Not really used in our case but part of the other structures
class UAHMENUPOPUPMETRICS(Structure):
_fields_ = [
('rgcx', DWORD * 4),
('fUpdateMaxWidths', DWORD),
]
# hmenu is the main window menu; hdc is the context to draw in
class UAHMENU(Structure):
_fields_ = [
('hmenu', HMENU),
('hdc', HDC),
('dwFlags', DWORD),
]
# Menu items are always referred to by iPosition here
class UAHMENUITEM(Structure):
_fields_ = [
('iPosition', INT),
('umim', UAHMENUITEMMETRICS),
('umpm', UAHMENUPOPUPMETRICS),
]
# The DRAWITEMSTRUCT contains the states of the menu items, as well as
# the position index of the item in the menu, which is duplicated in
# the UAHMENUITEM's iPosition as well
class UAHDRAWMENUITEM(Structure):
_fields_ = [
('dis', DRAWITEMSTRUCT),
('um', UAHMENU),
('umi', UAHMENUITEM),
]
########################################
# Used winapi functions
########################################
dwmapi = windll.dwmapi
dwmapi.DwmSetWindowAttribute.argtypes = (HWND, DWORD, LPCVOID, DWORD)
gdi32 = windll.Gdi32
gdi32.CreateSolidBrush.argtypes = (COLORREF,)
gdi32.CreateSolidBrush.restype = HBRUSH
gdi32.SetBkMode.argtypes = (HDC, INT)
gdi32.SetTextColor.argtypes = (HDC, COLORREF)
shell32 = windll.shell32
shell32.DragAcceptFiles.argtypes = (HWND, BOOL)
shell32.DragQueryFileW.argtypes = (WPARAM, UINT, LPWSTR, UINT)
shell32.DragFinish.argtypes = (WPARAM, )
shell32.DragQueryPoint.argtypes = (WPARAM, LPPOINT)
user32 = windll.user32
user32.DefWindowProcW.argtypes = (HWND, UINT, WPARAM, LPARAM)
user32.DefWindowProcW.restype = LPARAM
user32.DrawTextW.argtypes = (HDC, LPCWSTR, INT, POINTER(RECT), UINT)
user32.FillRect.argtypes = (HDC, POINTER(RECT), HBRUSH)
user32.GetClientRect.argtypes = (HWND, POINTER(RECT))
user32.GetMenu.argtypes = (HWND,)
user32.GetMenuBarInfo.argtypes = (HWND, LONG, LONG, LPVOID) # PMENUBARINFO
user32.GetMenuItemInfoW.argtypes = (HMENU, UINT, BOOL, LPVOID) # LPMENUITEMINFOW
user32.GetWindowDC.argtypes = (HWND,)
user32.GetWindowRect.argtypes = (HWND, POINTER(RECT))
user32.MapWindowPoints.argtypes = (HWND, HWND, LPVOID, UINT)
user32.OffsetRect.argtypes = (POINTER(RECT), INT, INT)
user32.ReleaseDC.argtypes = (HWND, HDC)
user32.SetMenuInfo.argtypes = (HMENU, POINTER(MENUINFO))
user32.SetWindowLongPtrW.argtypes = (HWND, LONG_PTR, WNDPROC)
user32.SetWindowLongPtrW.restype = WNDPROC
uxtheme = windll.UxTheme
uxtheme.SetWindowTheme.argtypes = (HANDLE, LPCWSTR, LPCWSTR)
########################################
# Dark colors/brushes
########################################
MENU_BG_BRUSH = gdi32.CreateSolidBrush(0x202020)
MENUBAR_BG_BRUSH = gdi32.CreateSolidBrush(0x2b2b2b)
MENUBAR_BG_HOT_BRUSH = gdi32.CreateSolidBrush(0x3e3e3e)
MENUBAR_SEP_BRUSH = gdi32.CreateSolidBrush(0xa0a0a0)
MENUBAR_TEXT_COLOR = 0xe0e0e0
########################################
# Helper class for Win32 subclassing
########################################
class WindowWrapper():
def __init__(self, hwnd):
self.__hwnd = hwnd
self.__old_proc = None
self.__new_proc = None
self.__message_map = {}
def window_proc_callback(self, hwnd, msg, wparam, lparam):
if msg in self.__message_map:
for callback in self.__message_map[msg]:
res = callback(hwnd, wparam, lparam)
if res is not None:
return res
return self.__old_proc(hwnd, msg, wparam, lparam)
def register_message_callback(self, msg, callback):
if msg not in self.__message_map:
self.__message_map[msg] = []
self.__message_map[msg].append(callback)
if self.__new_proc is None:
self.__new_proc = WNDPROC(self.window_proc_callback)
self.__old_proc = user32.SetWindowLongPtrW(self.__hwnd, GWL_WNDPROC, self.__new_proc)
########################################
# subclass ShellSidebar, remove horizontal padding
########################################
import idlelib.sidebar as _sidebar
class MyShellSidebar(_sidebar.ShellSidebar):
def grid(self):
self.canvas.grid(row=1, column=0, sticky=NSEW, padx=0, pady=0)
setattr(_sidebar, 'ShellSidebar', MyShellSidebar)
########################################
# subclass LineNumbers, increase horizontal padding
########################################
class MyLineNumbers(_sidebar.LineNumbers):
def init_widgets(self):
res = super().init_widgets()
self.sidebar_text['padx'] = 10
return res
setattr(_sidebar, 'LineNumbers', MyLineNumbers)
########################################
# subclass EditorWindow
########################################
import idlelib.editor as _editor
class MyEditorWindow(_editor.EditorWindow):
def __init__(self, *args):
super().__init__(*args)
# remove border around text widget
self.text['borderwidth'] = 0
self.top.wait_visibility()
self.set_dark()
def __del__(self):
''' This fixes IDLE/tkinter issue https://github.com/python/cpython/issues/84632 '''
user32.OpenClipboard(0)
user32.GetClipboardData(1)
user32.CloseClipboard()
def createmenubar(self):
super().createmenubar()
# change menu colors
for m in list(self.menudict.values()) + [self.recent_files_menu]:
m['foreground'] = '#FFFFFF'
m['background'] = '#202020'
m['activebackground'] = '#3E3E3E'
m['disabledforeground'] = '#666666'
def set_status_bar(self):
super().set_status_bar()
# remove the separator line (optional)
self.top.winfo_children()[-1].pack_forget()
# change statusbar bgcolor
style = Style(master=self.root)
style.configure('My.TFrame', background='#171717')
self.status_bar['style'] = 'My.TFrame'
# change label colors
for label in self.status_bar.labels.values():
label['background'] = '#171717'
label['foreground'] = '#FFFFFF'
def set_dark(self, *args):
hwnd_main = int(self.top.frame(), 16)
########################################
# dark title bar
########################################
dwmapi.DwmSetWindowAttribute(hwnd_main, DWMWA_USE_IMMERSIVE_DARK_MODE, byref(INT(1)), sizeof(INT))
########################################
# menu background (frame around the menu drawn by tkinter)
########################################
mi = MENUINFO()
mi.fMask = MIM_BACKGROUND | MIM_APPLYTOSUBMENUS
mi.hbrBack = MENU_BG_BRUSH
user32.SetMenuInfo(user32.GetMenu(hwnd_main), byref(mi))
########################################
# force dark scrollbar
########################################
uxtheme.SetWindowTheme(self.vbar.winfo_id(), 'DarkMode_Explorer', None)
########################################
# force dark menubar
########################################
self.wrapper = WindowWrapper(hwnd_main)
def _on_WM_UAHDRAWMENU(hwnd, wparam, lparam):
pUDM = cast(lparam, POINTER(UAHMENU)).contents
mbi = MENUBARINFO()
ok = user32.GetMenuBarInfo(hwnd, OBJID_MENU, 0, byref(mbi))
rc_win = RECT()
user32.GetWindowRect(hwnd, byref(rc_win))
rc = mbi.rcBar
user32.OffsetRect(byref(rc), -rc_win.left, -rc_win.top)
res = user32.FillRect(pUDM.hdc, byref(rc), MENUBAR_BG_BRUSH)
return 1
self.wrapper.register_message_callback(WM_UAHDRAWMENU, _on_WM_UAHDRAWMENU)
def _on_WM_UAHDRAWMENUITEM(hwnd, wparam, lparam):
pUDMI = cast(lparam, POINTER(UAHDRAWMENUITEM)).contents
mii = MENUITEMINFOW()
mii.fMask = MIIM_STRING
buf = create_unicode_buffer('', 256)
mii.dwTypeData = cast(buf, LPWSTR)
mii.cch = 256
ok = user32.GetMenuItemInfoW(pUDMI.um.hmenu, pUDMI.umi.iPosition, 1, byref(mii))
if pUDMI.dis.itemState & ODS_HOTLIGHT or pUDMI.dis.itemState & ODS_SELECTED:
user32.FillRect(pUDMI.um.hdc, byref(pUDMI.dis.rcItem), MENUBAR_BG_HOT_BRUSH)
else:
user32.FillRect(pUDMI.um.hdc, byref(pUDMI.dis.rcItem), MENUBAR_BG_BRUSH)
gdi32.SetBkMode(pUDMI.um.hdc, TRANSPARENT)
gdi32.SetTextColor(pUDMI.um.hdc, MENUBAR_TEXT_COLOR)
user32.DrawTextW(pUDMI.um.hdc, mii.dwTypeData, len(mii.dwTypeData), byref(pUDMI.dis.rcItem), DT_CENTER | DT_SINGLELINE | DT_VCENTER)
return 1
self.wrapper.register_message_callback(WM_UAHDRAWMENUITEM, _on_WM_UAHDRAWMENUITEM)
def UAHDrawMenuNCBottomLine(hwnd, wparam, lparam):
rcClient = RECT()
user32.GetClientRect(hwnd, byref(rcClient))
user32.MapWindowPoints(hwnd, None, byref(rcClient), 2)
rcWindow = RECT()
user32.GetWindowRect(hwnd, byref(rcWindow))
user32.OffsetRect(byref(rcClient), -rcWindow.left, -rcWindow.top)
rcAnnoyingLine = rcClient
rcAnnoyingLine.bottom = rcAnnoyingLine.top
rcAnnoyingLine.top -= 1
hdc = user32.GetWindowDC(hwnd)
user32.FillRect(hdc, byref(rcAnnoyingLine), MENU_BG_BRUSH)
user32.ReleaseDC(hwnd, hdc)
def _on_WM_NCPAINT(hwnd, wparam, lparam):
user32.DefWindowProcW(hwnd, WM_NCPAINT, wparam, lparam)
UAHDrawMenuNCBottomLine(hwnd, wparam, lparam)
return 1
self.wrapper.register_message_callback(WM_NCPAINT, _on_WM_NCPAINT)
def _on_WM_NCACTIVATE(hwnd, wparam, lparam):
user32.DefWindowProcW(hwnd, WM_NCACTIVATE, wparam, lparam)
UAHDrawMenuNCBottomLine(hwnd, wparam, lparam)
return 1
self.wrapper.register_message_callback(WM_NCACTIVATE, _on_WM_NCACTIVATE)
user32.RedrawWindow(hwnd_main, 0, 0, RDW_ERASE | RDW_INVALIDATE | RDW_FRAME | RDW_ALLCHILDREN)
########################################
# OPTIONAL: make menu separators look a little nicer (no shadow)
########################################
def _on_WM_DRAWITEM(hwnd, wparam, lparam):
lpdis = cast(lparam, POINTER(DRAWITEMSTRUCT)).contents
# Tk's itemData reverse engineered, integer 4 in the first 4 bytes means that
# menu item is a separator, so just draw a vertically centered gray 1px line.
if cast(lpdis.itemData, POINTER(INT)).contents.value == 4:
lpdis.rcItem.top = lpdis.rcItem.top + (lpdis.rcItem.bottom - lpdis.rcItem.top) // 2
lpdis.rcItem.bottom = lpdis.rcItem.top + 1
lpdis.rcItem.left += 8
lpdis.rcItem.right -= 8
user32.FillRect(lpdis.hDC, byref(lpdis.rcItem), MENUBAR_SEP_BRUSH)
return 1
self.wrapper.register_message_callback(WM_DRAWITEM, _on_WM_DRAWITEM)
########################################
# while we are it, let's add support for dropping files from Explorer into the IDLE window
########################################
def _on_WM_DROPFILES(hwnd, wparam, lparam):
dropped_items = []
cnt = shell32.DragQueryFileW(wparam, 0xFFFFFFFF, None, 0)
for i in range(cnt):
file_buffer = create_unicode_buffer('', MAX_PATH)
shell32.DragQueryFileW(wparam, i, file_buffer, MAX_PATH)
dropped_items.append(file_buffer[:].split('\0', 1)[0])
shell32.DragFinish(wparam)
if self.get_saved():
self.root.after(1, self.io.loadfile, dropped_items[0])
dropped_items = dropped_items[1:]
for dropped_item in dropped_items:
self.root.after(1, self.flist.open, dropped_item)
self.wrapper.register_message_callback(WM_DROPFILES, _on_WM_DROPFILES)
shell32.DragAcceptFiles(hwnd_main, True)
setattr(_editor, 'EditorWindow', MyEditorWindow)
########################################
# subclass AutoCompleteWindow, change scrollbar and colors
########################################
import idlelib.autocomplete_w as _autocomplete_w
class MyAutoCompleteWindow(_autocomplete_w.AutoCompleteWindow):
def show_window(self, *args):
super().show_window(*args)
# force dark scrollbar
uxtheme.SetWindowTheme(self.scrollbar.winfo_id(), 'DarkMode_Explorer', None)
# change listbox colors
self.listbox['background'] = '#202020'
self.listbox['foreground'] = '#ffffff'
self.listbox['relief'] = 'flat'
self.listbox['highlightcolor'] = '#666666'
self.listbox['highlightbackground'] = '#666666'
self.listbox['activestyle'] = 'none'
setattr(_autocomplete_w, 'AutoCompleteWindow', MyAutoCompleteWindow)
########################################
# subclass CalltipWindow, change colors
########################################
import idlelib.calltip_w as _calltip_w
class MyCalltipWindoww(_calltip_w.CalltipWindow):
def showcontents(self):
super().showcontents()
# change label colors
self.label['background'] = '#202020'
self.label['foreground'] = '#ffffff'
self.label['highlightcolor'] = '#666666'
self.label['highlightbackground'] = '#666666'
self.label['highlightthickness'] = 1
self.label['relief'] = 'flat'
setattr(_calltip_w, 'CalltipWindow', MyCalltipWindoww)
########################################
# Run dark IDLE
########################################
if __name__ == '__main__':
import idlelib.__main__
@59de44955ebd
Copy link
Author

Screenshot (Windows 11)
idle_dark_windows

@59de44955ebd
Copy link
Author

59de44955ebd commented Sep 27, 2024

# %USERPROFILE%\.idlerc\config-main.cfg

[Theme]
name2 = IDLE Dark
name = Dark
default = False

[General]
editor-on-startup = 1

[EditorWindow]
font = cascadia code
font-bold = False
line-numbers-default = True

# %USERPROFILE%\.idlerc\config-highlight.cfg:

[Dark]
normal-foreground = #FFFFFF
normal-background = #282828
keyword-foreground = #ff8000
keyword-background = #282828
builtin-foreground = #ff00ff
builtin-background = #282828
comment-foreground = #dd0000
comment-background = #282828
string-foreground = #02ff02
string-background = #282828
definition-foreground = #5e5eff
definition-background = #282828
hilite-foreground = #FFFFFF
hilite-background = #3e3e3e
break-foreground = #FFFFFF
break-background = #808000
hit-foreground = #002240
hit-background = #fbfbfb
error-foreground = #FFFFFF
error-background = #c86464
context-foreground = #ffffff
context-background = #282828
linenumber-foreground = gray
linenumber-background = #000000
cursor-foreground = #ffffff
stdout-foreground = #c2d1fa
stdout-background = #002240
stderr-foreground = #ffb3b3
stderr-background = #002240
console-foreground = #ff4d4d
console-background = #002240

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment