|
|
|
|
@ -3,12 +3,23 @@
|
|
|
|
|
from __future__ import annotations |
|
|
|
|
|
|
|
|
|
import json |
|
|
|
|
import os |
|
|
|
|
import sys |
|
|
|
|
import threading |
|
|
|
|
import urllib.error |
|
|
|
|
from dataclasses import asdict, dataclass |
|
|
|
|
from pathlib import Path |
|
|
|
|
from typing import Any, Callable |
|
|
|
|
|
|
|
|
|
# When running as a frozen binary (PyInstaller) on non-GNOME desktops (e.g. KDE), |
|
|
|
|
# no GTK theme is configured by default. Force Adwaita so the bundled theme is |
|
|
|
|
# used and the UI renders correctly. |
|
|
|
|
if getattr(sys, "frozen", False): |
|
|
|
|
os.environ.setdefault("GTK_THEME", "Adwaita") |
|
|
|
|
# Allow GTK4 to fall back from Wayland to X11 if the Wayland compositor |
|
|
|
|
# does not support the required protocols (common on some KDE setups). |
|
|
|
|
os.environ.setdefault("GDK_BACKEND", "wayland,x11") |
|
|
|
|
|
|
|
|
|
try: |
|
|
|
|
import gi # type: ignore[import-not-found] |
|
|
|
|
except ImportError as exc: |
|
|
|
|
@ -31,6 +42,7 @@ gi.require_version("Adw", "1")
|
|
|
|
|
from gi.repository import Adw, Gdk, GLib, Gtk # type: ignore[import-not-found] |
|
|
|
|
|
|
|
|
|
# ── Constants ─────────────────────────────────────────────────── |
|
|
|
|
APP_ID = "net.enne2.Cellar" |
|
|
|
|
CONFIG_PATH = Path.home() / ".config" / "cellar" / "gtk_client.json" |
|
|
|
|
|
|
|
|
|
APP_CSS = """\ |
|
|
|
|
@ -134,7 +146,10 @@ def normalize_name(value: str | None) -> str:
|
|
|
|
|
|
|
|
|
|
def _load_css() -> None: |
|
|
|
|
provider = Gtk.CssProvider() |
|
|
|
|
provider.load_from_string(APP_CSS) |
|
|
|
|
if hasattr(provider, "load_from_string"): |
|
|
|
|
provider.load_from_string(APP_CSS) |
|
|
|
|
else: |
|
|
|
|
provider.load_from_data(APP_CSS.encode("utf-8")) |
|
|
|
|
Gtk.StyleContext.add_provider_for_display( |
|
|
|
|
Gdk.Display.get_default(), |
|
|
|
|
provider, |
|
|
|
|
@ -142,6 +157,114 @@ def _load_css() -> None:
|
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CompatBanner(Gtk.Revealer): |
|
|
|
|
"""Fallback banner for libadwaita versions that don't provide `Adw.Banner`.""" |
|
|
|
|
|
|
|
|
|
def __init__(self) -> None: |
|
|
|
|
super().__init__() |
|
|
|
|
self.set_transition_type(Gtk.RevealerTransitionType.SLIDE_DOWN) |
|
|
|
|
|
|
|
|
|
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) |
|
|
|
|
box.set_margin_top(8) |
|
|
|
|
box.set_margin_bottom(8) |
|
|
|
|
box.set_margin_start(12) |
|
|
|
|
box.set_margin_end(12) |
|
|
|
|
box.add_css_class("toolbar") |
|
|
|
|
|
|
|
|
|
self._label = Gtk.Label(xalign=0) |
|
|
|
|
self._label.set_wrap(True) |
|
|
|
|
self._label.set_hexpand(True) |
|
|
|
|
box.append(self._label) |
|
|
|
|
|
|
|
|
|
self.set_child(box) |
|
|
|
|
|
|
|
|
|
def set_title(self, title: str) -> None: |
|
|
|
|
self._label.set_text(title) |
|
|
|
|
|
|
|
|
|
def set_revealed(self, revealed: bool) -> None: |
|
|
|
|
self.set_reveal_child(revealed) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CompatToolbarView(Gtk.Box): |
|
|
|
|
"""Fallback for `Adw.ToolbarView` (available since libadwaita 1.4).""" |
|
|
|
|
|
|
|
|
|
def __init__(self) -> None: |
|
|
|
|
super().__init__(orientation=Gtk.Orientation.VERTICAL) |
|
|
|
|
self._content: Gtk.Widget | None = None |
|
|
|
|
|
|
|
|
|
def add_top_bar(self, widget: Gtk.Widget) -> None: |
|
|
|
|
self.append(widget) |
|
|
|
|
|
|
|
|
|
def set_content(self, widget: Gtk.Widget) -> None: |
|
|
|
|
if self._content is not None: |
|
|
|
|
self.remove(self._content) |
|
|
|
|
self._content = widget |
|
|
|
|
widget.set_vexpand(True) |
|
|
|
|
self.append(widget) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CompatEntryRow(Adw.ActionRow): |
|
|
|
|
"""Fallback for `Adw.EntryRow` (available since libadwaita 1.2).""" |
|
|
|
|
|
|
|
|
|
def __init__(self, title: str = ""): |
|
|
|
|
super().__init__(title=title) |
|
|
|
|
self._entry = Gtk.Entry() |
|
|
|
|
self._entry.set_hexpand(True) |
|
|
|
|
self.add_suffix(self._entry) |
|
|
|
|
self.set_activatable_widget(self._entry) |
|
|
|
|
|
|
|
|
|
def get_text(self) -> str: |
|
|
|
|
return self._entry.get_text() |
|
|
|
|
|
|
|
|
|
def set_text(self, text: str) -> None: |
|
|
|
|
self._entry.set_text(text) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class CompatSwitchRow(Adw.ActionRow): |
|
|
|
|
"""Fallback for `Adw.SwitchRow` (available since libadwaita 1.4).""" |
|
|
|
|
|
|
|
|
|
def __init__(self, title: str = ""): |
|
|
|
|
super().__init__(title=title) |
|
|
|
|
self._switch = Gtk.Switch() |
|
|
|
|
self._switch.set_valign(Gtk.Align.CENTER) |
|
|
|
|
self.add_suffix(self._switch) |
|
|
|
|
self.set_activatable_widget(self._switch) |
|
|
|
|
|
|
|
|
|
def get_active(self) -> bool: |
|
|
|
|
return self._switch.get_active() |
|
|
|
|
|
|
|
|
|
def set_active(self, active: bool) -> None: |
|
|
|
|
self._switch.set_active(active) |
|
|
|
|
|
|
|
|
|
def connect_active_notify(self, callback: Callable[..., None]) -> None: |
|
|
|
|
self._switch.connect("notify::active", callback) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def make_toolbar_view() -> Gtk.Widget: |
|
|
|
|
if hasattr(Adw, "ToolbarView"): |
|
|
|
|
return Adw.ToolbarView() |
|
|
|
|
return CompatToolbarView() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def make_entry_row(title: str) -> Gtk.Widget: |
|
|
|
|
if hasattr(Adw, "EntryRow"): |
|
|
|
|
return Adw.EntryRow(title=title) |
|
|
|
|
return CompatEntryRow(title=title) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def make_switch_row(title: str) -> Gtk.Widget: |
|
|
|
|
if hasattr(Adw, "SwitchRow"): |
|
|
|
|
return Adw.SwitchRow(title=title) |
|
|
|
|
return CompatSwitchRow(title=title) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def connect_switch_row_active(row: Gtk.Widget, callback: Callable[..., None]) -> None: |
|
|
|
|
if hasattr(row, "connect_active_notify"): |
|
|
|
|
row.connect_active_notify(callback) # type: ignore[attr-defined] |
|
|
|
|
else: |
|
|
|
|
row.connect("notify::active", callback) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Settings Dialog ───────────────────────────────────────────── |
|
|
|
|
class SettingsWindow(Adw.Window): |
|
|
|
|
"""Preferences dialog using libadwaita widgets.""" |
|
|
|
|
@ -159,7 +282,7 @@ class SettingsWindow(Adw.Window):
|
|
|
|
|
self.set_modal(True) |
|
|
|
|
self.set_default_size(480, -1) |
|
|
|
|
|
|
|
|
|
toolbar_view = Adw.ToolbarView() |
|
|
|
|
toolbar_view = make_toolbar_view() |
|
|
|
|
|
|
|
|
|
header = Adw.HeaderBar() |
|
|
|
|
cancel_btn = Gtk.Button(label="Annulla") |
|
|
|
|
@ -180,11 +303,11 @@ class SettingsWindow(Adw.Window):
|
|
|
|
|
"locale di Bottles usata per l'installazione." |
|
|
|
|
) |
|
|
|
|
|
|
|
|
|
self.server_row = Adw.EntryRow(title="Indirizzo server") |
|
|
|
|
self.server_row = make_entry_row("Indirizzo server") |
|
|
|
|
self.server_row.set_text(config.server_url) |
|
|
|
|
group.add(self.server_row) |
|
|
|
|
|
|
|
|
|
self.bottles_row = Adw.EntryRow(title="Cartella Bottles") |
|
|
|
|
self.bottles_row = make_entry_row("Cartella Bottles") |
|
|
|
|
self.bottles_row.set_text(config.bottles_dir) |
|
|
|
|
group.add(self.bottles_row) |
|
|
|
|
|
|
|
|
|
@ -326,6 +449,7 @@ class CellarWindow(Adw.ApplicationWindow):
|
|
|
|
|
|
|
|
|
|
def __init__(self, app: Adw.Application): |
|
|
|
|
super().__init__(application=app, title="Cellar") |
|
|
|
|
self.set_icon_name(APP_ID) |
|
|
|
|
self.config = load_config() |
|
|
|
|
self.settings_window: SettingsWindow | None = None |
|
|
|
|
self.rows: list[ArchiveRow] = [] |
|
|
|
|
@ -363,7 +487,10 @@ class CellarWindow(Adw.ApplicationWindow):
|
|
|
|
|
# ── Outer box (banner + progress + scrolled content) ── |
|
|
|
|
outer_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) |
|
|
|
|
|
|
|
|
|
self.banner = Adw.Banner() |
|
|
|
|
if hasattr(Adw, "Banner"): |
|
|
|
|
self.banner = Adw.Banner() |
|
|
|
|
else: |
|
|
|
|
self.banner = CompatBanner() |
|
|
|
|
self.banner.set_revealed(False) |
|
|
|
|
outer_box.append(self.banner) |
|
|
|
|
|
|
|
|
|
@ -399,9 +526,9 @@ class CellarWindow(Adw.ApplicationWindow):
|
|
|
|
|
self.bottles_info_row.add_css_class("property") |
|
|
|
|
info_group.add(self.bottles_info_row) |
|
|
|
|
|
|
|
|
|
self.replace_row = Adw.SwitchRow(title="Sostituisci installazioni esistenti") |
|
|
|
|
self.replace_row = make_switch_row("Sostituisci installazioni esistenti") |
|
|
|
|
self.replace_row.set_active(self.config.replace_existing) |
|
|
|
|
self.replace_row.connect("notify::active", self._store_replace_preference) |
|
|
|
|
connect_switch_row_active(self.replace_row, self._store_replace_preference) |
|
|
|
|
info_group.add(self.replace_row) |
|
|
|
|
|
|
|
|
|
content_box.append(info_group) |
|
|
|
|
@ -447,7 +574,7 @@ class CellarWindow(Adw.ApplicationWindow):
|
|
|
|
|
self.toast_overlay.set_child(outer_box) |
|
|
|
|
|
|
|
|
|
# ── ToolbarView ── |
|
|
|
|
toolbar_view = Adw.ToolbarView() |
|
|
|
|
toolbar_view = make_toolbar_view() |
|
|
|
|
toolbar_view.add_top_bar(header) |
|
|
|
|
toolbar_view.set_content(self.toast_overlay) |
|
|
|
|
|
|
|
|
|
@ -699,7 +826,7 @@ class CellarWindow(Adw.ApplicationWindow):
|
|
|
|
|
# ── Application ───────────────────────────────────────────────── |
|
|
|
|
class CellarApplication(Adw.Application): |
|
|
|
|
def __init__(self) -> None: |
|
|
|
|
super().__init__(application_id="com.cellar.gtkclient") |
|
|
|
|
super().__init__(application_id=APP_ID) |
|
|
|
|
|
|
|
|
|
def do_activate(self) -> None: |
|
|
|
|
_load_css() |
|
|
|
|
|