-
-
Save 59de44955ebd/175fc0af5047be2eddb31ebb6c065720 to your computer and use it in GitHub Desktop.
''' | |
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__ |
# %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
Screenshot (Windows 11)
