-
-
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)
