From 0e88bf62f591f2b13d7168b0152a0df50397dd7c Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 16 Sep 2026 16:21:36 +0200 Subject: [PATCH] Rewrite overlay on Win32 (one borderless topmost window per monitor) + logging - fixes second monitor not fully covered (gap at top): each monitor now gets its own window sized exactly to its rcMonitor in device coords, with per-monitor V2 DPI awareness, so it covers the full screen regardless of layout/DPI - dropped pygame; black fill + 'ESC - exit' hint drawn via GDI - global ESC via low-level keyboard hook (works without window focus) - logging to %TEMP%\black_screen_dim.log (start, monitor rects, brightness, errors) - requirements.txt: pygame removed --- README.md | 3 +- black_screen_dim.py | 451 ++++++++++++++++++++++++++++++++------------ requirements.txt | 4 +- 3 files changed, 340 insertions(+), 118 deletions(-) diff --git a/README.md b/README.md index 73533b2..b1e528d 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,9 @@ pyinstaller "Black Screen Dim.spec" Готовый exe — в `dist\Black Screen Dim\`. ## Зависимости -- `pygame` — окно и отрисовка - `screen-brightness-control` — управление яркостью (только Windows) +- чёрная заливка экрана — напрямую через Win32 API (ctypes, входит в стандартную библиотеку), pygame больше не нужен +- лог пишется в `%TEMP%\black_screen_dim.log` > Управление яркостью работает только на Windows. На других ОС приложение > просто покажет чёрный экран без изменения яркости. diff --git a/black_screen_dim.py b/black_screen_dim.py index 3e25e2d..6cc09b5 100644 --- a/black_screen_dim.py +++ b/black_screen_dim.py @@ -1,33 +1,52 @@ +"""Black Screen Dim — гасит все мониторы чёрным безрамочным окном (Windows). + +Для КАЖДОГО подключённого монитора создаётся отдельное borderless topmost окно, +плотно закрывающее монитор целиком (по его rcMonitor, в device-координатах). +Яркость гасится и восстанавливается на ВСЕХ мониторах. +ESC (глобально, через low-level keyboard hook) — выход. +Подробный лог пишется в %TEMP%\\black_screen_dim.log. +""" + +import ctypes +import ctypes.wintypes as wt +import logging import os import sys -import time +import tempfile -import pygame import screen_brightness_control as sbc -# Windows-only calls are guarded so the module still imports on other OSes. -try: - import ctypes - from ctypes import wintypes -except Exception: - ctypes = None +LOG = logging.getLogger("black_screen_dim") -def resource_path(rel): - """Absolute path to a bundled resource, works both from source and from a - PyInstaller-frozen exe (sys._MEIPASS).""" - base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__))) - return os.path.join(base, rel) +# -------------------------------------------------------------------------- +# Logging +# -------------------------------------------------------------------------- +def setup_logging(): + log_path = os.path.join(tempfile.gettempdir(), "black_screen_dim.log") + try: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[ + logging.FileHandler(log_path, encoding="utf-8"), + logging.StreamHandler(sys.stderr), + ], + ) + except Exception: + logging.basicConfig(level=logging.INFO) + LOG.info("log file: %s", log_path) + return log_path -# --------------------------------------------------------------------------- -# Brightness — handle EVERY monitor, not just the first one -# --------------------------------------------------------------------------- +# -------------------------------------------------------------------------- +# Brightness — every monitor +# -------------------------------------------------------------------------- def get_all_brightness(): - """Return a list with one brightness value per monitor, or None on failure.""" try: vals = sbc.get_brightness() - except Exception: + except Exception as e: + LOG.warning("get_brightness failed: %r", e) return None if isinstance(vals, list): return list(vals) if vals else None @@ -35,129 +54,329 @@ def get_all_brightness(): def set_all_brightness(value): - """Set brightness on ALL monitors at once.""" try: sbc.set_brightness(int(value)) return True - except Exception: + except Exception as e: + LOG.warning("set_brightness(%s) failed: %r", value, e) return False def restore_all_brightness(original): - """Restore each monitor to its previously saved brightness.""" if not original: return for i, b in enumerate(original): try: sbc.set_brightness(int(b), display=i) - except Exception: - pass + except Exception as e: + LOG.warning("restore brightness monitor %d failed: %r", i, e) -# --------------------------------------------------------------------------- -# Window geometry — cover the whole virtual desktop (all monitors) -# --------------------------------------------------------------------------- -def get_virtual_screen_rect(): - """Bounding rectangle of all monitors combined (may start at negative x/y).""" +# -------------------------------------------------------------------------- +# Win32 overlay — one borderless topmost window per monitor +# -------------------------------------------------------------------------- +if sys.platform == "win32": user32 = ctypes.windll.user32 + gdi32 = ctypes.windll.gdi32 + kernel32 = ctypes.windll.kernel32 try: - user32.SetProcessDPIAware() + shcore = ctypes.windll.shcore except Exception: - pass - SM_XVIRTUALSCREEN = 76 - SM_YVIRTUALSCREEN = 77 - SM_CXVIRTUALSCREEN = 78 - SM_CYVIRTUALSCREEN = 79 - x = user32.GetSystemMetrics(SM_XVIRTUALSCREEN) - y = user32.GetSystemMetrics(SM_YVIRTUALSCREEN) - w = user32.GetSystemMetrics(SM_CXVIRTUALSCREEN) - h = user32.GetSystemMetrics(SM_CYVIRTUALSCREEN) - if w <= 0 or h <= 0: # fallback to primary monitor - w = user32.GetSystemMetrics(0) # SM_CXSCREEN - h = user32.GetSystemMetrics(1) # SM_CYSCREEN - x = y = 0 - return x, y, w, h + shcore = None - -def make_window_cover_all_monitors(hwnd, rect): - """Move/resize the window to the virtual-screen rect and pin it topmost so it - sits above the taskbar and every monitor.""" - user32 = ctypes.windll.user32 + WS_POPUP = 0x80000000 + WS_VISIBLE = 0x10000000 + WS_EX_TOPMOST = 0x00000008 + WS_EX_TOOLWINDOW = 0x00000080 + SWP_SHOWWINDOW = 0x0040 HWND_TOPMOST = -1 - x, y, w, h = rect - user32.SetWindowPos(int(hwnd), HWND_TOPMOST, x, y, w, h, 0) - try: - user32.SetForegroundWindow(int(hwnd)) - except Exception: - pass + WM_PAINT = 0x000F + WM_ERASEBKGND = 0x0014 + WM_DESTROY = 0x0002 + WM_CLOSE = 0x0010 + WM_TIMER = 0x0113 + WM_KEYDOWN_LL = 0x0100 + VK_ESCAPE = 0x1B + WH_KEYBOARD_LL = 13 + DT_CENTER = 0x00000001 + DT_VCENTER = 0x00000004 + DT_SINGLELINE = 0x00000020 + TRANSPARENT = 1 + DEFAULT_GUI_FONT = 17 + + WNDPROC = ctypes.WINFUNCTYPE(wt.LRESULT, wt.HWND, wt.UINT, wt.WPARAM, wt.LPARAM) + MONITORENUMPROC = ctypes.WINFUNCTYPE( + wt.BOOL, wt.HMONITOR, wt.HDC, ctypes.POINTER(wt.RECT), wt.LPARAM + ) + HOOKPROC = ctypes.WINFUNCTYPE(wt.LRESULT, ctypes.c_int, wt.WPARAM, wt.LPARAM) + + class WNDCLASSEXW(ctypes.Structure): + _fields_ = [ + ("cbSize", wt.UINT), + ("style", wt.UINT), + ("lpfnWndProc", WNDPROC), + ("cbClsExtra", wt.INT), + ("cbWndExtra", wt.INT), + ("hInstance", wt.HINSTANCE), + ("hIcon", wt.HICON), + ("hCursor", wt.HCURSOR), + ("hbrBackground", wt.HBRUSH), + ("lpszMenuName", wt.LPCWSTR), + ("lpszClassName", wt.LPCWSTR), + ("hIconSm", wt.HICON), + ] + + class PAINTSTRUCT(ctypes.Structure): + _fields_ = [ + ("hdc", wt.HDC), + ("fErase", wt.BOOL), + ("rcPaint", wt.RECT), + ("fRestore", wt.BOOL), + ("fIncUpdate", wt.BOOL), + ("rgbReserved", wt.BYTE * 32), + ] + + class KBDLLHOOKSTRUCT(ctypes.Structure): + _fields_ = [ + ("vkCode", wt.DWORD), + ("scanCode", wt.DWORD), + ("flags", wt.DWORD), + ("time", wt.DWORD), + ("dwExtraInfo", wt.ULONG_PTR), + ] + + # --- prototypes (64-bit safe) --- + user32.EnumDisplayMonitors.argtypes = [wt.HDC, ctypes.POINTER(wt.RECT), MONITORENUMPROC, wt.LPARAM] + user32.EnumDisplayMonitors.restype = wt.BOOL + user32.RegisterClassExW.argtypes = [ctypes.POINTER(WNDCLASSEXW)] + user32.RegisterClassExW.restype = wt.ATOM + user32.CreateWindowExW.argtypes = [ + wt.DWORD, wt.LPCWSTR, wt.LPCWSTR, wt.DWORD, + wt.INT, wt.INT, wt.INT, wt.INT, wt.HWND, wt.HMENU, wt.HINSTANCE, ctypes.c_void_p, + ] + user32.CreateWindowExW.restype = wt.HWND + user32.ShowWindow.argtypes = [wt.HWND, wt.INT] + user32.ShowWindow.restype = wt.BOOL + user32.UpdateWindow.argtypes = [wt.HWND] + user32.UpdateWindow.restype = wt.BOOL + user32.SetWindowPos.argtypes = [wt.HWND, wt.HWND, wt.INT, wt.INT, wt.INT, wt.INT, wt.UINT] + user32.SetWindowPos.restype = wt.BOOL + user32.SetTimer.argtypes = [wt.HWND, wt.UINT_PTR, wt.UINT, ctypes.c_void_p] + user32.SetTimer.restype = wt.UINT_PTR + user32.KillTimer.argtypes = [wt.HWND, wt.UINT_PTR] + user32.KillTimer.restype = wt.BOOL + user32.GetClientRect.argtypes = [wt.HWND, ctypes.POINTER(wt.RECT)] + user32.GetClientRect.restype = wt.BOOL + user32.BeginPaint.argtypes = [wt.HWND, ctypes.POINTER(PAINTSTRUCT)] + user32.BeginPaint.restype = wt.HDC + user32.EndPaint.argtypes = [wt.HWND, ctypes.POINTER(PAINTSTRUCT)] + user32.EndPaint.restype = wt.BOOL + user32.InvalidateRect.argtypes = [wt.HWND, ctypes.POINTER(wt.RECT), wt.BOOL] + user32.InvalidateRect.restype = wt.BOOL + user32.PostQuitMessage.argtypes = [wt.INT] + user32.PostQuitMessage.restype = None + user32.GetMessageW.argtypes = [ctypes.POINTER(wt.MSG), wt.HWND, wt.UINT, wt.UINT] + user32.GetMessageW.restype = wt.LONG + user32.TranslateMessage.argtypes = [ctypes.POINTER(wt.MSG)] + user32.TranslateMessage.restype = wt.BOOL + user32.DispatchMessageW.argtypes = [ctypes.POINTER(wt.MSG)] + user32.DispatchMessageW.restype = wt.LONG + user32.SetWindowsHookExW.argtypes = [wt.INT, HOOKPROC, wt.HINSTANCE, wt.DWORD] + user32.SetWindowsHookExW.restype = wt.HHOOK + user32.CallNextHookEx.argtypes = [wt.HHOOK, ctypes.c_int, wt.WPARAM, wt.LPARAM] + user32.CallNextHookEx.restype = wt.LRESULT + user32.UnhookWindowsHookEx.argtypes = [wt.HHOOK] + user32.UnhookWindowsHookEx.restype = wt.BOOL + user32.DestroyWindow.argtypes = [wt.HWND] + user32.DestroyWindow.restype = wt.BOOL + user32.ShowCursor.argtypes = [wt.BOOL] + user32.ShowCursor.restype = wt.INT + user32.DefWindowProcW.argtypes = [wt.HWND, wt.UINT, wt.WPARAM, wt.LPARAM] + user32.DefWindowProcW.restype = wt.LRESULT + user32.SetProcessDPIAware.argtypes = [] + user32.SetProcessDPIAware.restype = wt.BOOL + gdi32.CreateSolidBrush.argtypes = [wt.COLORREF] + gdi32.CreateSolidBrush.restype = wt.HBRUSH + gdi32.FillRect.argtypes = [wt.HDC, ctypes.POINTER(wt.RECT), wt.HBRUSH] + gdi32.FillRect.restype = wt.INT + gdi32.DeleteObject.argtypes = [wt.HGDIOBJ] + gdi32.DeleteObject.restype = wt.BOOL + gdi32.SetTextColor.argtypes = [wt.HDC, wt.COLORREF] + gdi32.SetTextColor.restype = wt.COLORREF + gdi32.SetBkMode.argtypes = [wt.HDC, wt.INT] + gdi32.SetBkMode.restype = wt.INT + gdi32.GetStockObject.argtypes = [wt.INT] + gdi32.GetStockObject.restype = wt.HGDIOBJ + gdi32.SelectObject.argtypes = [wt.HDC, wt.HGDIOBJ] + gdi32.SelectObject.restype = wt.HGDIOBJ + gdi32.DrawTextW.argtypes = [wt.HDC, wt.LPCWSTR, wt.INT, ctypes.POINTER(wt.RECT), wt.UINT] + gdi32.DrawTextW.restype = wt.INT + kernel32.GetModuleHandleW.argtypes = [wt.LPCWSTR] + kernel32.GetModuleHandleW.restype = wt.HINSTANCE + kernel32.GetLastError.argtypes = [] + kernel32.GetLastError.restype = wt.DWORD + + _hwnds = [] + _hint_active = True + _hook_id = None + _hint_text = "ESC - exit" + + def enum_monitor_rects(): + rects = [] + + def cb(hmon, hdc, lprect, lparam): + r = lprect.contents + rects.append((r.left, r.top, r.right, r.bottom)) + return True + + user32.EnumDisplayMonitors(0, 0, MONITORENUMPROC(cb), 0) + return rects + + def _wndproc(hwnd, msg, wparam, lparam): + if msg == WM_ERASEBKGND: + return 1 + if msg == WM_PAINT: + ps = PAINTSTRUCT() + hdc = user32.BeginPaint(hwnd, ctypes.byref(ps)) + rect = wt.RECT() + user32.GetClientRect(hwnd, ctypes.byref(rect)) + brush = gdi32.CreateSolidBrush(0x000000) + gdi32.FillRect(hdc, ctypes.byref(rect), brush) + gdi32.DeleteObject(brush) + if _hint_active: + gdi32.SetTextColor(hdc, 0xFFFFFF) + gdi32.SetBkMode(hdc, TRANSPARENT) + font = gdi32.GetStockObject(DEFAULT_GUI_FONT) + old = gdi32.SelectObject(hdc, font) + buf = ctypes.create_unicode_buffer(_hint_text) + gdi32.DrawTextW( + hdc, buf, -1, ctypes.byref(rect), + DT_CENTER | DT_VCENTER | DT_SINGLELINE, + ) + if old: + gdi32.SelectObject(hdc, old) + user32.EndPaint(hwnd, ctypes.byref(ps)) + return 0 + if msg == WM_TIMER: + global _hint_active + _hint_active = False + user32.KillTimer(hwnd, 1) + for h in _hwnds: + user32.InvalidateRect(h, None, False) + return 0 + if msg in (WM_DESTROY, WM_CLOSE): + user32.PostQuitMessage(0) + return 0 + return user32.DefWindowProcW(hwnd, msg, wparam, lparam) + + def _low_level_keyboard_proc(nCode, wParam, lParam): + if nCode >= 0 and wParam == WM_KEYDOWN_LL: + kb = ctypes.cast(lParam, ctypes.POINTER(KBDLLHOOKSTRUCT)).contents + if kb.vkCode == VK_ESCAPE: + LOG.info("ESC pressed -> exit") + user32.PostQuitMessage(0) + return user32.CallNextHookEx(_hook_id, nCode, wParam, lParam) + + def run_overlay(): + global _hwnds, _hook_id + # Per-monitor V2 DPI awareness: device-coord rects map 1:1 to each + # monitor regardless of mixed DPI / where the primary sits. + if shcore is not None: + try: + shcore.SetProcessDpiAwareness(2) # PROCESS_PER_MONITOR_DPI_AWARE_V2 + except Exception as e: + LOG.warning("SetProcessDpiAwareness failed: %r", e) + user32.SetProcessDPIAware() + else: + user32.SetProcessDPIAware() + + rects = enum_monitor_rects() + LOG.info("monitors found: %s", rects) + if not rects: + LOG.warning("no monitors enumerated, falling back to primary") + w = user32.GetSystemMetrics(0) # SM_CXSCREEN + h = user32.GetSystemMetrics(1) # SM_CYSCREEN + rects = [(0, 0, w, h)] + + wndproc = WNDPROC(_wndproc) + cls_name = "BlackScreenDimCls" + hinst = kernel32.GetModuleHandleW(None) + wc = WNDCLASSEXW() + wc.cbSize = ctypes.sizeof(WNDCLASSEXW) + wc.lpfnWndProc = wndproc + wc.hInstance = hinst + wc.hbrBackground = gdi32.CreateSolidBrush(0x000000) + wc.lpszClassName = cls_name + if not user32.RegisterClassExW(ctypes.byref(wc)): + LOG.error("RegisterClassExW failed: %d", kernel32.GetLastError()) + return + + for (left, top, right, bottom) in rects: + w = right - left + h = bottom - top + hwnd = user32.CreateWindowExW( + WS_EX_TOPMOST | WS_EX_TOOLWINDOW, + cls_name, "Black Screen Dim", + WS_POPUP | WS_VISIBLE, + left, top, w, h, + 0, 0, hinst, 0, + ) + if not hwnd: + LOG.error("CreateWindowExW failed for %s: %d", + (left, top, right, bottom), kernel32.GetLastError()) + continue + user32.ShowWindow(hwnd, 5) # SW_SHOW + user32.UpdateWindow(hwnd) + user32.SetWindowPos(hwnd, HWND_TOPMOST, left, top, w, h, SWP_SHOWWINDOW) + user32.SetTimer(hwnd, 1, 1000, None) + _hwnds.append(hwnd) + LOG.info("created %d overlay window(s)", len(_hwnds)) + + # hide cursor + for _ in range(12): + if user32.ShowCursor(False) < 0: + break + # global ESC hook + kb_proc = HOOKPROC(_low_level_keyboard_proc) + _hook_id = user32.SetWindowsHookExW(WH_KEYBOARD_LL, kb_proc, hinst, 0) + if not _hook_id: + LOG.warning("SetWindowsHookExW failed: %d", kernel32.GetLastError()) + + msg = wt.MSG() + while user32.GetMessageW(ctypes.byref(msg), 0, 0, 0) > 0: + user32.TranslateMessage(ctypes.byref(msg)) + user32.DispatchMessageW(ctypes.byref(msg)) + + if _hook_id: + user32.UnhookWindowsHookEx(_hook_id) + for h in _hwnds: + user32.DestroyWindow(h) + # restore cursor + for _ in range(12): + if user32.ShowCursor(True) >= 0: + break def main(): - # --- brightness: save every monitor, then dim them all to 0 --- - original_brightness = get_all_brightness() - if original_brightness is not None: + setup_logging() + LOG.info("starting Black Screen Dim") + original = get_all_brightness() + LOG.info("saved brightness: %s", original) + if original is not None: set_all_brightness(0) - # --- black screen over the whole virtual desktop --- - pygame.init() - - if sys.platform == "win32" and ctypes is not None: - x, y, w, h = get_virtual_screen_rect() - else: - info = pygame.display.Info() - x, y, w, h = 0, 0, info.current_w, info.current_h - - screen = pygame.display.set_mode((w, h), pygame.NOFRAME) - pygame.display.set_caption("Black Screen Dim") - try: - icon = pygame.image.load(resource_path("icon.ico")) - pygame.display.set_icon(icon) - except Exception: - pass - - pygame.mouse.set_visible(False) - - # Pin the borderless window across all monitors and bring it to front. - if sys.platform == "win32" and ctypes is not None: - try: - hwnd = pygame.display.get_wm_info().get("window") - if hwnd: - make_window_cover_all_monitors(hwnd, (x, y, w, h)) - except Exception: - pass - - font = pygame.font.SysFont(None, 48) - hint_text = "ESC - exit" - text_surf = font.render(hint_text, True, (255, 255, 255)) - text_rect = text_surf.get_rect(center=(w // 2, h // 2)) - show_hint_until = time.time() + 1.0 # ~1 second - - running = True - while running: - for event in pygame.event.get(): - if event.type == pygame.QUIT: - running = False - if event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: - running = False - - screen.fill((0, 0, 0)) - if time.time() < show_hint_until: - screen.blit(text_surf, text_rect) - - pygame.display.flip() - - # --- exit: restore every monitor's brightness and the cursor --- - if original_brightness is not None: - restore_all_brightness(original_brightness) - - try: - pygame.mouse.set_visible(True) - except Exception: - pass - - pygame.quit() - sys.exit() + if sys.platform == "win32": + run_overlay() + else: + LOG.warning("non-Windows platform; nothing to display") + except Exception as e: + LOG.exception("overlay error: %r", e) + finally: + if original is not None: + restore_all_brightness(original) + LOG.info("restored brightness: %s", original) + LOG.info("exiting") if __name__ == "__main__": diff --git a/requirements.txt b/requirements.txt index 27bf289..3fbbc70 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ -pygame>=2.5.0 +# Windows-only runtime dependency. +# The black overlay uses the Win32 API directly via ctypes (standard library), +# so pygame is no longer required. screen-brightness-control>=0.22.0