#!/usr/bin/env python3 """ Brightness control overlay — always-on-top icon in bottom-right; click to show controls. Uses Wayland layer-shell OVERLAY so it stays above Chromium fullscreen/kiosk. Requires: brightness-api.service running (listens 127.0.0.1:8765). """ import logging import sys import urllib.request import urllib.error LOG_TAG = "brightness-overlay" logging.basicConfig( stream=sys.stderr, level=logging.INFO, format=f"{LOG_TAG}: %(message)s", ) log = logging.getLogger(LOG_TAG) try: import gi gi.require_version("Gdk", "3.0") gi.require_version("Gtk", "3.0") from gi.repository import Gdk, Gtk, GLib except Exception as e: log.error("need PyGObject Gtk: %s", e) sys.exit(1) HAS_LAYER_SHELL = False try: gi.require_version("GtkLayerShell", "0.1") from gi.repository import GtkLayerShell HAS_LAYER_SHELL = True except Exception: log.warning("GtkLayerShell not available, using regular window") API_URL = "http://127.0.0.1:8765/brightness" API_STATE_URL = "http://127.0.0.1:8765/state" # Sizes ICON_SIZE = 44 EXPANDED_WIDTH = 300 PANEL_HEIGHT = 48 def get_current_state(): """Return (level 1–5, is_auto). On error return (None, True) so Auto appears active.""" try: with urllib.request.urlopen(API_STATE_URL, timeout=1) as r: text = r.read().decode() level = None override = "0" for line in text.strip().splitlines(): line = line.strip() if line.startswith("level="): level = line.split("=", 1)[1].strip() elif line.startswith("override="): override = line.split("=", 1)[1].strip() if level and level.isdigit() and 1 <= int(level) <= 5: return int(level), override == "0" return (None, True) except Exception: return (None, True) def set_brightness(level): try: req = urllib.request.Request( API_URL, data=f"level={level}".encode(), method="POST", headers={"Content-Type": "application/x-www-form-urlencoded"}, ) with urllib.request.urlopen(req, timeout=2) as r: log.info("brightness %s: %s", level, r.read().decode().strip()) except urllib.error.URLError as e: log.warning("API call failed (is brightness-api.service running?): %s", e) except Exception as e: log.warning("set_brightness failed: %s", e) def update_active_buttons(level_buttons, auto_btn, current_level, is_auto): """Set .active style on the button that matches current state.""" for i, btn in enumerate(level_buttons): ctx = btn.get_style_context() if not is_auto and current_level == i + 1: ctx.add_class("active") else: ctx.remove_class("active") if auto_btn: ctx = auto_btn.get_style_context() if is_auto: ctx.add_class("active") else: ctx.remove_class("active") def make_window_layer_shell(win): GtkLayerShell.init_for_window(win) GtkLayerShell.set_layer(win, GtkLayerShell.Layer.OVERLAY) # Anchor only to bottom and right so the surface sits in the bottom-right corner GtkLayerShell.set_anchor(win, GtkLayerShell.Edge.TOP, False) GtkLayerShell.set_anchor(win, GtkLayerShell.Edge.LEFT, False) GtkLayerShell.set_anchor(win, GtkLayerShell.Edge.BOTTOM, True) GtkLayerShell.set_anchor(win, GtkLayerShell.Edge.RIGHT, True) GtkLayerShell.set_keyboard_mode(win, GtkLayerShell.KeyboardMode.NONE) GtkLayerShell.set_margin(win, GtkLayerShell.Edge.BOTTOM, 12) GtkLayerShell.set_margin(win, GtkLayerShell.Edge.RIGHT, 12) if hasattr(GtkLayerShell, "set_namespace"): GtkLayerShell.set_namespace(win, "brightness-overlay") if hasattr(GtkLayerShell, "set_exclusive_zone"): GtkLayerShell.set_exclusive_zone(win, 0) log.info("layer-shell OVERLAY anchored bottom-right (always on top)") def position_fallback_bottom_right(win, width=None, height=None): """Position window in bottom-right (X11: only works with toplevel, not POPUP).""" if width is None: width = ICON_SIZE if height is None: height = ICON_SIZE display = Gdk.Display.get_default() if not display: return n = display.get_n_monitors() monitor = display.get_monitor(0) if n else None if not monitor: return geom = monitor.get_geometry() x = geom.x + geom.width - width - 12 y = geom.y + geom.height - height - 12 win.move(x, y) def make_window_fallback(win): # Toplevel (not POPUP) so we can position and have no decorations on X11 win.set_keep_above(True) win.set_decorated(False) win.set_type_hint(Gdk.WindowTypeHint.UTILITY) # tool-style window, often undecorated # Position after map so WM doesn't override win.connect("map-event", lambda w, e: position_fallback_bottom_right(w)) log.info("X11 fallback: keep-above, undecorated, bottom-right") def main(): # Normal toplevel: on X11 we can position it and set_decorated(False) removes title bar. # (POPUP without parent cannot be positioned on X11 and gets "temporary window" / centering.) win = Gtk.Window() win.set_decorated(False) win.set_resizable(False) win.set_default_size(ICON_SIZE, ICON_SIZE) win.set_skip_taskbar_hint(True) win.set_skip_pager_hint(True) # Transparent window: need RGBA visual and app-paintable screen = Gdk.Screen.get_default() if screen and screen.get_rgba_visual(): win.set_visual(screen.get_rgba_visual()) win.set_app_paintable(True) # CSS: transparent window and overlay; only subtle hover so icon stays visible style = """ GtkWindow, window { background-color: transparent; background-image: none; } .brightness-icon-btn { background-color: transparent; border-radius: 8px; padding: 4px; min-width: 36px; min-height: 36px; } .brightness-icon-btn:hover { background-color: rgba(0, 0, 0, 0.25); } .brightness-panel { background-color: transparent; border-radius: 8px; padding: 6px; } .brightness-panel.expanded { background-color: rgba(0, 0, 0, 0.5); } .brightness-panel button { min-width: 36px; font-weight: bold; } .brightness-panel button.level-btn.active { background-color: rgba(255, 255, 255, 0.35); border: 1px solid rgba(255, 255, 255, 0.6); } """ css = Gtk.CssProvider() css.load_from_data(style.encode()) Gtk.StyleContext.add_provider_for_screen( Gdk.Screen.get_default(), css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) # Main horizontal box: [revealer with controls] [icon button] main_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) # Left part: revealer with Brightness + 1–5 + Auto strip_box = Gtk.Box(spacing=6) strip_box.set_margin_start(8) strip_box.set_margin_top(6) strip_box.set_margin_bottom(6) strip_box.set_margin_end(4) strip_box.get_style_context().add_class("brightness-panel") strip_box.pack_start(Gtk.Label(label="Brightness "), False, False, 0) level_buttons = [] for i in range(1, 6): btn = Gtk.Button(label=str(i)) btn.get_style_context().add_class("level-btn") btn.set_can_focus(False) strip_box.pack_start(btn, False, False, 0) level_buttons.append(btn) auto_btn = Gtk.Button(label="Auto") auto_btn.get_style_context().add_class("level-btn") auto_btn.set_can_focus(False) strip_box.pack_start(auto_btn, False, False, 0) def refresh_active_state(): level, is_auto = get_current_state() update_active_buttons(level_buttons, auto_btn, level or 3, is_auto) def on_level_click(btn, level): set_brightness(level) # Optimistic update so active state shows immediately if level == "auto": update_active_buttons(level_buttons, auto_btn, 3, True) else: update_active_buttons(level_buttons, auto_btn, int(level), False) # Sync from API after a short delay (daemon updates state file periodically) def delayed_refresh(): refresh_active_state() return False GLib.timeout_add(500, delayed_refresh) for i, btn in enumerate(level_buttons): btn.connect("clicked", on_level_click, str(i + 1)) auto_btn.connect("clicked", on_level_click, "auto") revealer = Gtk.Revealer() revealer.set_reveal_child(False) revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_LEFT) revealer.set_transition_duration(150) revealer.add(strip_box) main_box.pack_start(revealer, False, False, 0) # Right part: always-visible icon button (click toggles panel) try: icon_img = Gtk.Image.new_from_icon_name("display-brightness-symbolic", Gtk.IconSize.BUTTON) except Exception: icon_img = Gtk.Label(label="☀") icon_btn = Gtk.Button() icon_btn.get_style_context().add_class("brightness-icon-btn") icon_btn.set_can_focus(False) icon_btn.add(icon_img) icon_btn.set_relief(Gtk.ReliefStyle.NONE) expanded = [False] # use list so closure can mutate use_fallback = not (HAS_LAYER_SHELL and GtkLayerShell.is_supported()) def on_icon_clicked(btn): expanded[0] = not expanded[0] revealer.set_reveal_child(expanded[0]) ctx = strip_box.get_style_context() if expanded[0]: ctx.add_class("expanded") GLib.idle_add(refresh_active_state) win.resize(EXPANDED_WIDTH, PANEL_HEIGHT) if use_fallback: position_fallback_bottom_right(win, EXPANDED_WIDTH, PANEL_HEIGHT) else: ctx.remove_class("expanded") win.resize(ICON_SIZE, ICON_SIZE) if use_fallback: position_fallback_bottom_right(win, ICON_SIZE, ICON_SIZE) icon_btn.connect("clicked", on_icon_clicked) main_box.pack_end(icon_btn, False, False, 0) win.add(main_box) if HAS_LAYER_SHELL and GtkLayerShell.is_supported(): make_window_layer_shell(win) else: make_window_fallback(win) win.connect("destroy", Gtk.main_quit) win.show_all() GLib.idle_add(refresh_active_state) log.info("brightness icon visible (click to show controls)") Gtk.main() if __name__ == "__main__": main()