#!/usr/bin/env python3
"""
Corner Rounder
A lightweight utility that draws rounded "mask" corners over your screen(s),
similar to Displaperture on macOS. Works on X11-based Debian/Deepin desktops.

It does NOT actually round your monitor's hardware corners (impossible) -
instead it paints small opaque quarter-circle masks in the four corners of
each display, using small, click-through, always-on-top windows sized to
just the corner radius, so the screen visually appears to have rounded
corners without ever being able to block input elsewhere on screen.

SAFETY NOTE: each overlay window is intentionally kept tiny (radius x radius
pixels, positioned exactly at one screen corner) rather than full-screen.
Even if click-through fails to apply for some reason, the only area that
could ever be affected is a small square in a screen corner - never the
whole desktop. Do not change this back to full-screen overlay windows.
"""

import gettext
import json
import locale
import math
import os
import signal
import sys
import traceback

import gi

gi.require_version("Gtk", "3.0")
gi.require_version("Gdk", "3.0")
try:
    gi.require_version("GdkPixbuf", "2.0")
except Exception:
    pass

from gi.repository import Gtk, Gdk, GLib, Gio, GdkPixbuf  # noqa: E402

try:
    import cairo
    HAVE_CAIRO = True
except Exception:
    HAVE_CAIRO = False

APP_ID = "org.deepinenespaol.cornerrounder"

# --- Internationalization -------------------------------------------------
# Picks a translation based on the system locale (LANGUAGE / LC_ALL /
# LC_MESSAGES / LANG environment variables, in that order - this is
# standard gettext behavior, no manual detection needed). Falls back to
# English automatically if no matching translation is installed.
GETTEXT_DOMAIN = "corner-rounder"
LOCALE_DIR = "/usr/share/locale"

try:
    locale.setlocale(locale.LC_ALL, "")
except locale.Error:
    pass

try:
    locale.bindtextdomain(GETTEXT_DOMAIN, LOCALE_DIR)
    locale.textdomain(GETTEXT_DOMAIN)
except AttributeError:
    # Not all platforms expose locale.bindtextdomain (e.g. some Windows
    # Python builds); gettext.translation below works regardless.
    pass

gettext.bindtextdomain(GETTEXT_DOMAIN, LOCALE_DIR)
gettext.textdomain(GETTEXT_DOMAIN)
_translation = gettext.translation(
    GETTEXT_DOMAIN, LOCALE_DIR, fallback=True
)
_ = _translation.gettext
# ---------------------------------------------------------------------------
CONFIG_DIR = os.path.join(GLib.get_user_config_dir(), "corner-rounder")
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
AUTOSTART_DIR = os.path.join(GLib.get_user_config_dir(), "autostart")
AUTOSTART_OVERRIDE = os.path.join(AUTOSTART_DIR, "corner-rounder-autostart.desktop")
ICON_PATH = "/usr/share/corner-rounder/corner-rounder.svg"
LOG_PATH = os.path.join(CONFIG_DIR, "corner-rounder.log")

DEFAULTS = {
    "enabled": True,
    "radius": 25,
    "color": "#000000",
    "opacity": 1.0,
    "autostart": True,
}

CORNERS = ("tl", "tr", "bl", "br")

# CSS applied to the tray's right-click popup menu so it gets rounded
# corners instead of the theme's default square ones. This is cosmetic
# polish layered on top of the real fix (see round_window_corners below):
# some desktop themes ship their own GTK CSS at a priority that can beat
# a plain APPLICATION-priority provider, so this is installed at USER
# priority (the highest available) to give it the best chance of winning
# even against a theme's own menu styling.
TRAY_MENU_CSS = b"""
menu, .menu, .cr-tray-menu, window.popup, GtkMenu {
    border-radius: 10px;
    padding: 4px 0px;
}
menu menuitem, .menu menuitem, .cr-tray-menu menuitem {
    border-radius: 6px;
}
"""

_menu_css_installed = False


def install_menu_css():
    """Install the rounded-corner CSS for popup menus, once per process."""
    global _menu_css_installed
    if _menu_css_installed:
        return
    screen = Gdk.Screen.get_default()
    if screen is None:
        return
    provider = Gtk.CssProvider()
    try:
        provider.load_from_data(TRAY_MENU_CSS)
    except Exception:
        log("corner-rounder: failed to load tray menu CSS:\n" + traceback.format_exc())
        return
    Gtk.StyleContext.add_provider_for_screen(
        screen, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER
    )
    _menu_css_installed = True


def rounded_rect_region(width, height, radius):
    """Build a Gdk.Region shaped like a filled rounded rectangle.

    Used to set a popup window's actual X11 shape (not just its visual
    appearance), so the window is genuinely non-rectangular at the
    window-manager/X-server level. This works regardless of whether a
    compositor is running and regardless of what the desktop theme's own
    CSS does to menu styling - unlike CSS border-radius alone, which a
    theme can override or which does nothing without ARGB compositing.
    """
    width = max(1, int(width))
    height = max(1, int(height))
    radius = max(0, min(radius, width // 2, height // 2))
    surface = cairo.ImageSurface(cairo.FORMAT_A8, width, height)
    cr = cairo.Context(surface)
    cr.set_source_rgba(0, 0, 0, 0)
    cr.paint()
    cr.set_source_rgba(0, 0, 0, 1)
    if radius <= 0:
        cr.rectangle(0, 0, width, height)
    else:
        degrees = math.pi / 180.0
        cr.new_sub_path()
        cr.arc(width - radius, radius, radius, -90 * degrees, 0)
        cr.arc(width - radius, height - radius, radius, 0, 90 * degrees)
        cr.arc(radius, height - radius, radius, 90 * degrees, 180 * degrees)
        cr.arc(radius, radius, radius, 180 * degrees, 270 * degrees)
        cr.close_path()
    cr.fill()
    surface.flush()
    return Gdk.cairo_region_create_from_surface(surface)


def round_window_corners(gdk_win, width, height, radius=10):
    """Apply a rounded-rectangle X11 shape to a popup window's GdkWindow.

    This is the primary fix for square popup-menu corners: it clips the
    window itself, not just what's painted inside it, so it works even
    when a desktop theme's CSS overrides our own menu styling and even
    without a compositor running.
    """
    try:
        region = rounded_rect_region(width, height, radius)
        gdk_win.shape_combine_region(region, 0, 0)
    except Exception:
        log("corner-rounder: shape_combine_region failed:\n" + traceback.format_exc())


def log(msg):
    try:
        os.makedirs(CONFIG_DIR, exist_ok=True)
        with open(LOG_PATH, "a") as f:
            f.write(msg.rstrip("\n") + "\n")
    except OSError:
        pass
    sys.stderr.write(msg.rstrip("\n") + "\n")


def load_config():
    cfg = dict(DEFAULTS)
    try:
        with open(CONFIG_FILE, "r") as f:
            data = json.load(f)
        if isinstance(data, dict):
            cfg.update({k: v for k, v in data.items() if k in DEFAULTS})
    except (FileNotFoundError, json.JSONDecodeError, OSError):
        pass
    # Clamp to sane bounds so a corrupt config can never produce a
    # huge overlay window.
    try:
        cfg["radius"] = max(0, min(150, int(cfg.get("radius", 25))))
    except (TypeError, ValueError):
        cfg["radius"] = 25
    return cfg


def save_config(cfg):
    os.makedirs(CONFIG_DIR, exist_ok=True)
    tmp = CONFIG_FILE + ".tmp"
    with open(tmp, "w") as f:
        json.dump(cfg, f, indent=2)
    os.replace(tmp, CONFIG_FILE)


def hex_to_rgb(hexstr):
    hexstr = (hexstr or "#000000").lstrip("#")
    if len(hexstr) != 6:
        return (0.0, 0.0, 0.0)
    r = int(hexstr[0:2], 16) / 255.0
    g = int(hexstr[2:4], 16) / 255.0
    b = int(hexstr[4:6], 16) / 255.0
    return (r, g, b)


def make_empty_region():
    """Build a guaranteed-empty cairo region for input-shape click-through.

    An empty cairo.Region() (no rectangles at all) means "no input area" -
    every pointer event passes straight through the window to whatever is
    behind it. This is the verified, correct construction (a previous
    version of this app used a nonexistent GDK helper here, which failed
    silently and left the window fully clickable - never do that again).
    """
    return cairo.Region()


def draw_corner_mask(cr, corner, r):
    """Paint the opaque corner mask into an r x r window, local coords.

    Each window covers exactly one screen corner. The mask is opaque right
    at the true screen-corner pixel and fades (via a quarter-circle arc) to
    fully transparent at the inner corner of the window (the point nearest
    the middle of the screen), so the screen appears to have a rounded
    corner of radius r.
    """
    if r <= 0:
        return
    cr.new_path()
    if corner == "tl":
        cr.move_to(0, 0)
        cr.line_to(r, 0)
        cr.arc_negative(r, r, r, -math.pi / 2, -math.pi)
        cr.line_to(0, r)
    elif corner == "tr":
        cr.move_to(r, 0)
        cr.line_to(0, 0)
        cr.arc(0, r, r, -math.pi / 2, 0)
        cr.line_to(r, r)
    elif corner == "bl":
        cr.move_to(0, r)
        cr.line_to(0, 0)
        cr.arc_negative(r, 0, r, math.pi, math.pi / 2)
        cr.line_to(r, r)
    elif corner == "br":
        cr.move_to(r, r)
        cr.line_to(0, r)
        cr.arc_negative(0, 0, r, math.pi / 2, 0)
        cr.line_to(r, 0)
    cr.close_path()
    cr.fill()


class CornerOverlay(Gtk.Window):
    """A small (radius x radius) transparent, click-through window sitting
    exactly on top of one corner of one monitor."""

    def __init__(self, monitor_geo, corner, app):
        super().__init__(type=Gtk.WindowType.POPUP)
        self.app = app
        self.corner = corner
        self.geo = monitor_geo
        self.size = max(1, int(app.config.get("radius", 25)))

        screen = self.get_screen()
        visual = screen.get_rgba_visual()
        if visual is not None:
            self.set_visual(visual)
        self.set_app_paintable(True)
        self.set_decorated(False)
        self.set_resizable(False)
        self.set_skip_taskbar_hint(True)
        self.set_skip_pager_hint(True)
        self.set_accept_focus(False)
        self.set_focus_on_map(False)
        self.set_can_focus(False)
        self.set_deletable(False)

        x, y = self._position_for(monitor_geo, corner, self.size)
        self.move(x, y)
        self.set_default_size(self.size, self.size)
        self.set_size_request(self.size, self.size)

        self.connect("draw", self.on_draw)
        self.connect("realize", self.on_realize)

    @staticmethod
    def _position_for(geo, corner, size):
        if corner == "tl":
            return geo.x, geo.y
        if corner == "tr":
            return geo.x + geo.width - size, geo.y
        if corner == "bl":
            return geo.x, geo.y + geo.height - size
        if corner == "br":
            return geo.x + geo.width - size, geo.y + geo.height - size
        return geo.x, geo.y

    def on_realize(self, *_a):
        gdk_win = self.get_window()
        if gdk_win is None:
            return
        gdk_win.set_override_redirect(True)
        self.set_keep_above(True)
        self.stick()

        # Belt-and-suspenders click-through: try the modern pass_through
        # API first (GTK 3.24+), and always also apply an empty input
        # shape. Either one alone is normally sufficient; applying both
        # means a failure in one mechanism can't leave the window opaque
        # to input. Every call is wrapped so a failure here can NEVER take
        # down the app or, more importantly, can never leave an
        # undetected click-blocking window behind unnoticed (it's logged).
        try:
            gdk_win.set_pass_through(True)
        except AttributeError:
            pass
        except Exception:
            log("corner-rounder: set_pass_through failed:\n" + traceback.format_exc())

        try:
            gdk_win.input_shape_combine_region(make_empty_region(), 0, 0)
        except Exception:
            log(
                "corner-rounder: input_shape_combine_region failed:\n"
                + traceback.format_exc()
            )

    def on_draw(self, widget, cr):
        cfg = self.app.config
        r = self.size
        cr.save()
        cr.set_operator(cairo.OPERATOR_SOURCE)
        cr.set_source_rgba(0, 0, 0, 0)
        cr.paint()
        cr.restore()

        if not cfg.get("enabled", True):
            return False

        rr, gg, bb = hex_to_rgb(cfg.get("color", "#000000"))
        a = float(cfg.get("opacity", 1.0))

        cr.save()
        cr.set_operator(cairo.OPERATOR_SOURCE)
        cr.set_source_rgba(rr, gg, bb, a)
        draw_corner_mask(cr, self.corner, r)
        cr.restore()
        return False

    def refresh(self):
        win = self.get_window()
        if win is not None:
            win.invalidate_rect(None, False)


def _find_widget(widget, predicate):
    """Depth-first search for the first descendant matching predicate."""
    if predicate(widget):
        return widget
    if isinstance(widget, Gtk.Container):
        for child in widget.get_children():
            found = _find_widget(child, predicate)
            if found is not None:
                return found
    return None


def _style_about_credits(dialog):
    """Add breathing room below "Created by Eli Linares" and center the
    realact.net link under it, in the About dialog's Credits page.

    GtkAboutDialog builds its Credits grid lazily, the first time that
    page becomes the internal Stack's visible child. We briefly switch to
    it and back to "main" here, before the dialog is ever shown, so the
    grid exists and can be adjusted with no visible flicker - it stays
    adjusted for the rest of the dialog's life once the user clicks
    Credits themselves. Looked up structurally (Stack page name, widget
    type) rather than by button label text, so this keeps working
    regardless of the system's GTK translation.
    """
    try:
        stack = _find_widget(dialog, lambda w: isinstance(w, Gtk.Stack))
        if stack is None:
            return
        previous = stack.get_visible_child_name()
        stack.set_visible_child_name("credits")

        grid = _find_widget(dialog, lambda w: isinstance(w, Gtk.Grid))
        if grid is not None:
            url_label = _find_widget(
                grid,
                lambda w: isinstance(w, Gtk.Label)
                and w.get_text() == "https://realact.net",
            )
            if url_label is not None:
                url_row = grid.child_get_property(url_label, "top-attach")

                # Blank spacer row for breathing room below the author name.
                grid.insert_row(url_row)
                spacer = Gtk.Label(label="")
                spacer.set_size_request(-1, 10)
                grid.attach(spacer, 0, url_row, 2, 1)
                spacer.show()

                # Span both columns and center the link across the grid.
                grid.child_set_property(url_label, "left-attach", 0)
                grid.child_set_property(url_label, "width", 2)
                url_label.set_halign(Gtk.Align.CENTER)
                url_label.set_xalign(0.5)

        if previous:
            stack.set_visible_child_name(previous)
    except Exception:
        log(
            "corner-rounder: failed to style About Credits section:\n"
            + traceback.format_exc()
        )


def show_about_dialog(parent=None):
    dialog = Gtk.AboutDialog()
    if parent is not None:
        dialog.set_transient_for(parent)
    dialog.set_modal(True)
    dialog.set_program_name("Corner Rounder")
    dialog.set_version("1.2.8")
    dialog.set_comments(_("Round your screen's corners with a click-through overlay."))
    dialog.set_copyright(_("Created by Eli Linares for Deepin En Español"))
    dialog.set_website("https://deepinenespañol.org")
    dialog.set_website_label("https://deepinenespañol.org")
    dialog.set_authors(["Eli Linares", "https://realact.net"])
    dialog.set_license_type(Gtk.License.MIT_X11)
    try:
        pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(ICON_PATH, 96, 96)
        dialog.set_logo(pixbuf)
    except Exception:
        pass
    _style_about_credits(dialog)
    dialog.connect("response", lambda d, *_a: d.destroy())
    dialog.show()
    return dialog


class SettingsWindow(Gtk.Window):
    def __init__(self, app):
        super().__init__(title=_("Corner Rounder Settings"))
        self.app = app
        self.set_default_size(380, -1)
        self.set_border_width(16)
        self.set_resizable(False)
        try:
            self.set_icon_from_file(ICON_PATH)
        except Exception:
            pass
        self.connect("delete-event", self.on_close)

        cfg = app.config
        box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
        self.add(box)

        header = Gtk.Label()
        header.set_markup("<b>Corner Rounder</b>")
        header.set_xalign(0)
        box.pack_start(header, False, False, 0)

        sub = Gtk.Label(
            label=_("Round your screen's corners with a click-through overlay.")
        )
        sub.set_xalign(0)
        sub.set_line_wrap(True)
        box.pack_start(sub, False, False, 0)

        row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        lbl = Gtk.Label(label=_("Enable rounded corners"))
        lbl.set_xalign(0)
        lbl.set_hexpand(True)
        self.switch = Gtk.Switch()
        self.switch.set_active(bool(cfg.get("enabled", True)))
        self.switch.connect("notify::active", self.on_enable_toggle)
        row.pack_start(lbl, True, True, 0)
        row.pack_start(self.switch, False, False, 0)
        box.pack_start(row, False, False, 0)

        rlbl = Gtk.Label(label=_("Corner radius (px)"))
        rlbl.set_xalign(0)
        box.pack_start(rlbl, False, False, 0)
        self.radius_adj = Gtk.Adjustment(
            value=cfg.get("radius", 25), lower=0, upper=150, step_increment=1
        )
        self.radius_scale = Gtk.Scale(
            orientation=Gtk.Orientation.HORIZONTAL, adjustment=self.radius_adj
        )
        self.radius_scale.set_digits(0)
        self.radius_scale.set_value_pos(Gtk.PositionType.RIGHT)
        self.radius_scale.connect("value-changed", self.on_radius_changed)
        box.pack_start(self.radius_scale, False, False, 0)

        olbl = Gtk.Label(label=_("Mask opacity"))
        olbl.set_xalign(0)
        box.pack_start(olbl, False, False, 0)
        self.opacity_adj = Gtk.Adjustment(
            value=cfg.get("opacity", 1.0) * 100, lower=10, upper=100, step_increment=1
        )
        self.opacity_scale = Gtk.Scale(
            orientation=Gtk.Orientation.HORIZONTAL, adjustment=self.opacity_adj
        )
        self.opacity_scale.set_digits(0)
        self.opacity_scale.set_value_pos(Gtk.PositionType.RIGHT)
        self.opacity_scale.connect("value-changed", self.on_opacity_changed)
        box.pack_start(self.opacity_scale, False, False, 0)

        crow = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        clbl = Gtk.Label(label=_("Mask color"))
        clbl.set_xalign(0)
        clbl.set_hexpand(True)
        self.color_btn = Gtk.ColorButton()
        rgba = Gdk.RGBA()
        rgba.parse(cfg.get("color", "#000000"))
        self.color_btn.set_rgba(rgba)
        self.color_btn.connect("color-set", self.on_color_changed)
        crow.pack_start(clbl, True, True, 0)
        crow.pack_start(self.color_btn, False, False, 0)
        box.pack_start(crow, False, False, 0)

        box.pack_start(Gtk.Separator(), False, False, 4)

        arow = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        albl = Gtk.Label(label=_("Start automatically on login"))
        albl.set_xalign(0)
        albl.set_hexpand(True)
        self.autostart_switch = Gtk.Switch()
        self.autostart_switch.set_active(bool(cfg.get("autostart", True)))
        self.autostart_switch.connect("notify::active", self.on_autostart_toggle)
        arow.pack_start(albl, True, True, 0)
        arow.pack_start(self.autostart_switch, False, False, 0)
        box.pack_start(arow, False, False, 0)

        box.pack_start(Gtk.Separator(), False, False, 4)

        reset_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        reset_btn = Gtk.Button(label=_("Reset to Defaults"))
        reset_btn.connect("clicked", self.on_reset_defaults)
        reset_row.pack_start(reset_btn, False, False, 0)
        box.pack_start(reset_row, False, False, 0)

        box.pack_start(Gtk.Separator(), False, False, 4)

        brow = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
        about_btn = Gtk.Button(label=_("About"))
        about_btn.connect("clicked", lambda *_: show_about_dialog(self))
        quit_btn = Gtk.Button(label=_("Quit Corner Rounder"))
        quit_btn.connect("clicked", lambda *_: self.app.quit_app())
        close_btn = Gtk.Button(label=_("Close"))
        close_btn.connect("clicked", lambda *_: self.hide())
        brow.pack_start(about_btn, False, False, 0)
        brow.pack_end(close_btn, False, False, 0)
        brow.pack_end(quit_btn, False, False, 0)
        box.pack_start(brow, False, False, 8)

        self.show_all()

    def on_close(self, *_a):
        self.hide()
        return True

    def on_enable_toggle(self, sw, _param):
        self.app.config["enabled"] = sw.get_active()
        self.app.persist_and_redraw()

    def on_radius_changed(self, scale):
        self.app.config["radius"] = int(scale.get_value())
        self.app.persist_and_rebuild()

    def on_opacity_changed(self, scale):
        self.app.config["opacity"] = scale.get_value() / 100.0
        self.app.persist_and_redraw()

    def on_color_changed(self, btn):
        rgba = btn.get_rgba()
        hexcol = "#%02x%02x%02x" % (
            round(rgba.red * 255),
            round(rgba.green * 255),
            round(rgba.blue * 255),
        )
        self.app.config["color"] = hexcol
        self.app.persist_and_redraw()

    def on_autostart_toggle(self, sw, _param):
        self.app.set_autostart(sw.get_active())

    def on_reset_defaults(self, *_a):
        """Restore every setting to its factory default and reflect that
        in the UI. Widget values are updated first so the user sees the
        change; app.set_autostart() and persist_and_rebuild() are then
        called explicitly so the config file, the autostart entry and the
        overlays are all guaranteed to match even if a widget was already
        sitting at its default value (in which case its own change signal
        would never fire)."""
        defaults = dict(DEFAULTS)
        self.app.config.update(defaults)

        self.switch.set_active(bool(defaults["enabled"]))
        self.radius_adj.set_value(defaults["radius"])
        self.opacity_adj.set_value(defaults["opacity"] * 100)
        rgba = Gdk.RGBA()
        rgba.parse(defaults["color"])
        self.color_btn.set_rgba(rgba)
        self.autostart_switch.set_active(bool(defaults["autostart"]))

        self.app.set_autostart(defaults["autostart"])
        self.app.persist_and_rebuild()


class CornerRounderApp(Gtk.Application):
    def __init__(self):
        super().__init__(
            application_id=APP_ID,
            flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE,
        )
        self.config = load_config()
        self.overlays = []
        self.settings_window = None
        self.tray = None
        self.show_settings_on_activate = True
        self.add_main_option(
            "autostart",
            0,
            GLib.OptionFlags.NONE,
            GLib.OptionArg.NONE,
            _("Start silently (used by the autostart entry)"),
            None,
        )

    def do_command_line(self, command_line):
        options = command_line.get_options_dict()
        options = options.end().unpack()
        self.show_settings_on_activate = not options.get("autostart", False)
        self.activate()
        return 0

    def do_startup(self):
        Gtk.Application.do_startup(self)
        install_menu_css()

    def do_activate(self):
        if not self.overlays:
            self.build_overlays()
            self.build_tray()
            display = Gdk.Display.get_default()
            if display is not None:
                try:
                    display.connect(
                        "monitor-added", lambda *_: self.rebuild_overlays()
                    )
                    display.connect(
                        "monitor-removed", lambda *_: self.rebuild_overlays()
                    )
                except TypeError:
                    pass
        else:
            self.refresh_overlays()

        if self.show_settings_on_activate:
            self.show_settings()
        self.hold()

    def build_overlays(self):
        self.overlays = []
        display = Gdk.Display.get_default()
        if display is None:
            return
        n = display.get_n_monitors()
        for i in range(n):
            monitor = display.get_monitor(i)
            geo = monitor.get_geometry()
            for corner in CORNERS:
                overlay = CornerOverlay(geo, corner, self)
                overlay.show_all()
                overlay.refresh()
                self.overlays.append(overlay)

    def rebuild_overlays(self):
        old = self.overlays
        self.overlays = []
        self.build_overlays()
        for ov in old:
            ov.destroy()

    def refresh_overlays(self):
        for ov in self.overlays:
            ov.refresh()

    def persist_and_redraw(self):
        """Use when only color/opacity/enabled changed - no resize needed."""
        save_config(self.config)
        self.refresh_overlays()

    def persist_and_rebuild(self):
        """Use when radius changed - window sizes/positions must change."""
        save_config(self.config)
        self.rebuild_overlays()

    def build_tray(self):
        try:
            self.tray = Gtk.StatusIcon()
            if os.path.exists(ICON_PATH):
                self.tray.set_from_file(ICON_PATH)
            else:
                self.tray.set_from_icon_name("preferences-desktop-display")
            self.tray.set_tooltip_text("Corner Rounder")
            self.tray.connect("activate", lambda *_: self.show_settings())
            self.tray.connect("popup-menu", self.on_tray_menu)
        except Exception:
            self.tray = None

    def on_tray_menu(self, icon, button, activate_time):
        menu = Gtk.Menu()

        # Give the popup its own RGBA visual so the CSS border-radius
        # below (see TRAY_MENU_CSS / install_menu_css) actually clips
        # the corners to transparent instead of leaving opaque square
        # corners behind the rounded background.
        screen = menu.get_screen()
        if screen is not None:
            visual = screen.get_rgba_visual()
            if visual is not None:
                menu.set_visual(visual)
        menu.get_style_context().add_class("cr-tray-menu")

        # Real fix for square popup-menu corners: clip the window's own
        # X11 shape to a rounded rect whenever its size is finalized (see
        # round_window_corners). This works regardless of the desktop
        # theme's own menu CSS and regardless of compositing state, so
        # it's the primary mechanism; the CSS above is just polish for
        # themes/compositors that blend it smoothly.
        def _reshape_menu(widget, allocation):
            win = widget.get_window()
            if win is not None:
                round_window_corners(win, allocation.width, allocation.height, radius=10)

        menu.connect("size-allocate", _reshape_menu)

        toggle_item = Gtk.CheckMenuItem(label=_("Rounded corners enabled"))
        toggle_item.set_active(bool(self.config.get("enabled", True)))
        toggle_item.connect("toggled", self.on_tray_toggle)
        menu.append(toggle_item)

        settings_item = Gtk.MenuItem(label=_("Settings…"))
        settings_item.connect("activate", lambda *_: self.show_settings())
        menu.append(settings_item)

        menu.append(Gtk.SeparatorMenuItem())

        about_item = Gtk.MenuItem(label=_("About"))
        about_item.connect("activate", lambda *_: show_about_dialog())
        menu.append(about_item)

        menu.append(Gtk.SeparatorMenuItem())

        quit_item = Gtk.MenuItem(label=_("Quit"))
        quit_item.connect("activate", lambda *_: self.quit_app())
        menu.append(quit_item)

        menu.show_all()
        menu.popup(None, None, Gtk.StatusIcon.position_menu, icon, button, activate_time)

    def on_tray_toggle(self, item):
        self.config["enabled"] = item.get_active()
        self.persist_and_redraw()
        if self.settings_window:
            self.settings_window.switch.set_active(item.get_active())

    def show_settings(self):
        if self.settings_window is None:
            self.settings_window = SettingsWindow(self)
        self.settings_window.present()

    def set_autostart(self, enabled):
        self.config["autostart"] = enabled
        save_config(self.config)
        os.makedirs(AUTOSTART_DIR, exist_ok=True)
        if enabled:
            if os.path.exists(AUTOSTART_OVERRIDE):
                try:
                    os.remove(AUTOSTART_OVERRIDE)
                except OSError:
                    pass
        else:
            with open(AUTOSTART_OVERRIDE, "w") as f:
                f.write(
                    "[Desktop Entry]\n"
                    "Type=Application\n"
                    "Name=Corner Rounder\n"
                    "Exec=corner-rounder --autostart\n"
                    "Hidden=true\n"
                )

    def quit_app(self):
        save_config(self.config)
        for ov in self.overlays:
            ov.destroy()
        if self.tray:
            self.tray.set_visible(False)
        self.release()
        sys.exit(0)


def main():
    signal.signal(signal.SIGINT, signal.SIG_DFL)
    if not HAVE_CAIRO:
        sys.stderr.write(
            "Corner Rounder requires python3-gi-cairo. "
            "Install it with: sudo apt install python3-gi-cairo\n"
        )
        sys.exit(1)
    app = CornerRounderApp()
    app.run(sys.argv)


if __name__ == "__main__":
    main()
