Last active
September 29, 2024 12:45
-
-
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
''' | |
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__ |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
# %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