feat: add PyInstaller spec for creating portable executable of Cellar GTK
This commit is contained in:
+71
@@ -0,0 +1,71 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
"""PyInstaller spec for Cellar GTK – portable all-in-one executable."""
|
||||
|
||||
a = Analysis(
|
||||
["gtk_client.py"],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[],
|
||||
hiddenimports=[
|
||||
"gi",
|
||||
"gi.repository.Gtk",
|
||||
"gi.repository.Gdk",
|
||||
"gi.repository.Adw",
|
||||
"gi.repository.GLib",
|
||||
"gi.repository.GObject",
|
||||
"gi.repository.Gio",
|
||||
"gi.repository.Pango",
|
||||
"gi.repository.PangoCairo",
|
||||
"gi.repository.GdkPixbuf",
|
||||
"gi.repository.Graphene",
|
||||
"gi.repository.Gsk",
|
||||
"gi.repository.HarfBuzz",
|
||||
"gi.repository.cairo",
|
||||
"gi.repository.freetype2",
|
||||
"client",
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={
|
||||
"gi": {
|
||||
"module-versions": {
|
||||
"Gtk": "4.0",
|
||||
"Gdk": "4.0",
|
||||
"Gsk": "4.0",
|
||||
},
|
||||
"icons": ["Adwaita"],
|
||||
"themes": ["Adwaita"],
|
||||
"languages": [],
|
||||
},
|
||||
},
|
||||
runtime_hooks=[],
|
||||
excludes=[
|
||||
"tkinter",
|
||||
"_tkinter",
|
||||
"unittest",
|
||||
"test",
|
||||
"xmlrpc",
|
||||
"pydoc",
|
||||
],
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.datas,
|
||||
[],
|
||||
name="cellar",
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=True,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=False,
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
icon=None,
|
||||
)
|
||||
+360
-235
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cellar GTK – client grafico per Bottle Archive Server (libadwaita)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -12,8 +13,7 @@ try:
|
||||
import gi # type: ignore[import-not-found]
|
||||
except ImportError as exc:
|
||||
raise SystemExit(
|
||||
"PyGObject / GTK4 non trovato. Installa i pacchetti di sistema 'python3-gobject' e 'gtk4' "
|
||||
"oppure l'equivalente della tua distribuzione."
|
||||
"PyGObject non trovato. Installa 'python3-gobject', 'gtk4' e 'libadwaita'."
|
||||
) from exc
|
||||
|
||||
from client import (
|
||||
@@ -25,11 +25,48 @@ from client import (
|
||||
)
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
from gi.repository import GLib, Gtk # type: ignore[import-not-found]
|
||||
gi.require_version("Adw", "1")
|
||||
from gi.repository import Adw, Gdk, GLib, Gtk # type: ignore[import-not-found]
|
||||
|
||||
# ── Constants ───────────────────────────────────────────────────
|
||||
CONFIG_PATH = Path.home() / ".config" / "cellar" / "gtk_client.json"
|
||||
|
||||
APP_CSS = """\
|
||||
.archive-row {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.archive-title {
|
||||
font-weight: bold;
|
||||
font-size: 1.05em;
|
||||
}
|
||||
.archive-meta {
|
||||
font-size: 0.88em;
|
||||
}
|
||||
.status-chip {
|
||||
font-size: 0.82em;
|
||||
font-weight: 600;
|
||||
padding: 2px 10px;
|
||||
border-radius: 99px;
|
||||
}
|
||||
.status-chip.installed {
|
||||
color: @success_color;
|
||||
background: alpha(@success_color, 0.12);
|
||||
}
|
||||
.status-chip.not-installed {
|
||||
color: alpha(@window_fg_color, 0.45);
|
||||
background: alpha(@window_fg_color, 0.06);
|
||||
}
|
||||
progressbar trough {
|
||||
min-height: 4px;
|
||||
}
|
||||
progressbar progress {
|
||||
min-height: 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
# ── Config ──────────────────────────────────────────────────────
|
||||
@dataclass
|
||||
class AppConfig:
|
||||
server_url: str = DEFAULT_SERVER
|
||||
@@ -40,12 +77,10 @@ class AppConfig:
|
||||
def load_config() -> AppConfig:
|
||||
if not CONFIG_PATH.exists():
|
||||
return AppConfig()
|
||||
|
||||
try:
|
||||
raw = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return AppConfig()
|
||||
|
||||
return AppConfig(
|
||||
server_url=str(raw.get("server_url") or DEFAULT_SERVER).rstrip("/"),
|
||||
bottles_dir=str(raw.get("bottles_dir") or DEFAULT_BOTTLES_DIR),
|
||||
@@ -58,13 +93,13 @@ def save_config(config: AppConfig) -> None:
|
||||
CONFIG_PATH.write_text(json.dumps(asdict(config), indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
# ── Utility ─────────────────────────────────────────────────────
|
||||
def format_size(size_bytes: int | None) -> str:
|
||||
if not size_bytes:
|
||||
return "-"
|
||||
return "—"
|
||||
size = float(size_bytes)
|
||||
units = ["B", "KB", "MB", "GB"]
|
||||
for unit in units:
|
||||
if size < 1024 or unit == units[-1]:
|
||||
for unit in ("B", "KB", "MB", "GB"):
|
||||
if size < 1024 or unit == "GB":
|
||||
return f"{size:.1f} {unit}"
|
||||
size /= 1024
|
||||
return f"{size_bytes} B"
|
||||
@@ -74,8 +109,7 @@ def archive_matches_query(archive: dict[str, Any], query: str) -> bool:
|
||||
normalized = query.strip().lower()
|
||||
if not normalized:
|
||||
return True
|
||||
|
||||
searchable_fields = (
|
||||
searchable = (
|
||||
archive.get("name"),
|
||||
archive.get("bottle_name"),
|
||||
archive.get("description"),
|
||||
@@ -84,7 +118,7 @@ def archive_matches_query(archive: dict[str, Any], query: str) -> bool:
|
||||
archive.get("arch"),
|
||||
archive.get("windows_version"),
|
||||
)
|
||||
haystack = " ".join(str(value or "") for value in searchable_fields).lower()
|
||||
haystack = " ".join(str(v or "") for v in searchable).lower()
|
||||
return normalized in haystack
|
||||
|
||||
|
||||
@@ -92,79 +126,87 @@ def normalize_name(value: str | None) -> str:
|
||||
return (value or "").strip().lower()
|
||||
|
||||
|
||||
class SettingsWindow(Gtk.Window):
|
||||
def __init__(self, parent: "CellarWindow", config: AppConfig, on_save: Callable[[AppConfig], None]):
|
||||
def _load_css() -> None:
|
||||
provider = Gtk.CssProvider()
|
||||
provider.load_from_string(APP_CSS)
|
||||
Gtk.StyleContext.add_provider_for_display(
|
||||
Gdk.Display.get_default(),
|
||||
provider,
|
||||
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
)
|
||||
|
||||
|
||||
# ── Settings Dialog ─────────────────────────────────────────────
|
||||
class SettingsWindow(Adw.Window):
|
||||
"""Preferences dialog using libadwaita widgets."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: "CellarWindow",
|
||||
config: AppConfig,
|
||||
on_save: Callable[[AppConfig], None],
|
||||
):
|
||||
super().__init__(title="Configurazione")
|
||||
self.parent = parent
|
||||
self.on_save = on_save
|
||||
self._parent_win = parent
|
||||
self._on_save = on_save
|
||||
self.set_transient_for(parent)
|
||||
self.set_modal(True)
|
||||
self.set_default_size(520, 180)
|
||||
self.set_default_size(480, -1)
|
||||
|
||||
root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
||||
root.set_margin_top(16)
|
||||
root.set_margin_bottom(16)
|
||||
root.set_margin_start(16)
|
||||
root.set_margin_end(16)
|
||||
toolbar_view = Adw.ToolbarView()
|
||||
|
||||
description = Gtk.Label(
|
||||
label="Imposta il server Cellar e la cartella locale di Bottles usata per installazione e upload.",
|
||||
wrap=True,
|
||||
xalign=0,
|
||||
header = Adw.HeaderBar()
|
||||
cancel_btn = Gtk.Button(label="Annulla")
|
||||
cancel_btn.connect("clicked", lambda *_: self.close())
|
||||
save_btn = Gtk.Button(label="Salva")
|
||||
save_btn.add_css_class("suggested-action")
|
||||
save_btn.connect("clicked", self._save)
|
||||
header.pack_start(cancel_btn)
|
||||
header.pack_end(save_btn)
|
||||
toolbar_view.add_top_bar(header)
|
||||
|
||||
page = Adw.PreferencesPage()
|
||||
|
||||
group = Adw.PreferencesGroup()
|
||||
group.set_title("Connessione")
|
||||
group.set_description(
|
||||
"Configura l'indirizzo del server Cellar e la cartella "
|
||||
"locale di Bottles usata per l'installazione."
|
||||
)
|
||||
root.append(description)
|
||||
|
||||
grid = Gtk.Grid(column_spacing=12, row_spacing=12)
|
||||
self.server_row = Adw.EntryRow(title="Indirizzo server")
|
||||
self.server_row.set_text(config.server_url)
|
||||
group.add(self.server_row)
|
||||
|
||||
server_label = Gtk.Label(label="Server", xalign=0)
|
||||
self.server_entry = Gtk.Entry(text=config.server_url)
|
||||
self.server_entry.set_hexpand(True)
|
||||
grid.attach(server_label, 0, 0, 1, 1)
|
||||
grid.attach(self.server_entry, 1, 0, 1, 1)
|
||||
self.bottles_row = Adw.EntryRow(title="Cartella Bottles")
|
||||
self.bottles_row.set_text(config.bottles_dir)
|
||||
group.add(self.bottles_row)
|
||||
|
||||
bottles_label = Gtk.Label(label="Cartella Bottles", xalign=0)
|
||||
self.bottles_entry = Gtk.Entry(text=config.bottles_dir)
|
||||
self.bottles_entry.set_hexpand(True)
|
||||
grid.attach(bottles_label, 0, 1, 1, 1)
|
||||
grid.attach(self.bottles_entry, 1, 1, 1, 1)
|
||||
|
||||
root.append(grid)
|
||||
|
||||
button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||
button_box.set_halign(Gtk.Align.END)
|
||||
|
||||
cancel_button = Gtk.Button(label="Annulla")
|
||||
cancel_button.connect("clicked", lambda *_: self.close())
|
||||
|
||||
save_button = Gtk.Button(label="Salva")
|
||||
save_button.add_css_class("suggested-action")
|
||||
save_button.connect("clicked", self._save)
|
||||
|
||||
button_box.append(cancel_button)
|
||||
button_box.append(save_button)
|
||||
root.append(button_box)
|
||||
|
||||
self.set_child(root)
|
||||
page.add(group)
|
||||
toolbar_view.set_content(page)
|
||||
self.set_content(toolbar_view)
|
||||
|
||||
def _save(self, *_args: object) -> None:
|
||||
server_url = self.server_entry.get_text().strip().rstrip("/")
|
||||
bottles_dir = self.bottles_entry.get_text().strip()
|
||||
|
||||
server_url = self.server_row.get_text().strip().rstrip("/")
|
||||
bottles_dir = self.bottles_row.get_text().strip()
|
||||
if not server_url:
|
||||
self.parent.show_message("L'indirizzo del server non può essere vuoto.")
|
||||
return
|
||||
|
||||
config = AppConfig(
|
||||
server_url=server_url,
|
||||
bottles_dir=str(Path(bottles_dir).expanduser()) if bottles_dir else str(DEFAULT_BOTTLES_DIR),
|
||||
replace_existing=self.parent.replace_switch.get_active(),
|
||||
bottles_dir=(
|
||||
str(Path(bottles_dir).expanduser()) if bottles_dir else str(DEFAULT_BOTTLES_DIR)
|
||||
),
|
||||
replace_existing=self._parent_win.replace_row.get_active(),
|
||||
)
|
||||
save_config(config)
|
||||
self.on_save(config)
|
||||
self._on_save(config)
|
||||
self.close()
|
||||
|
||||
|
||||
# ── Archive Row ─────────────────────────────────────────────────
|
||||
class ArchiveRow(Gtk.ListBoxRow):
|
||||
"""A single archive entry in the list, with status icon, info and action button."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
archive: dict[str, Any],
|
||||
@@ -174,67 +216,96 @@ class ArchiveRow(Gtk.ListBoxRow):
|
||||
):
|
||||
super().__init__()
|
||||
self.archive = archive
|
||||
self.set_selectable(False)
|
||||
self.set_activatable(False)
|
||||
|
||||
root = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||
root.set_margin_top(10)
|
||||
root.set_margin_bottom(10)
|
||||
root.set_margin_start(12)
|
||||
root.set_margin_end(12)
|
||||
root = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=14)
|
||||
root.add_css_class("archive-row")
|
||||
|
||||
# ── Status icon ──
|
||||
icon = Gtk.Image()
|
||||
icon.set_pixel_size(32)
|
||||
icon.set_valign(Gtk.Align.CENTER)
|
||||
if is_installed:
|
||||
icon.set_from_icon_name("object-select-symbolic")
|
||||
icon.add_css_class("success")
|
||||
else:
|
||||
icon.set_from_icon_name("folder-download-symbolic")
|
||||
icon.add_css_class("dim-label")
|
||||
|
||||
# ── Text column ──
|
||||
text_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||
text_box.set_hexpand(True)
|
||||
text_box.set_valign(Gtk.Align.CENTER)
|
||||
|
||||
title = archive.get("name") or archive.get("bottle_name") or "Archivio senza nome"
|
||||
bottle_name = archive.get("bottle_name") or "-"
|
||||
runner = archive.get("runner") or "-"
|
||||
arch = archive.get("arch") or "-"
|
||||
windows_version = archive.get("windows_version") or "-"
|
||||
created_at = archive.get("created_at") or "-"
|
||||
tags = archive.get("tags") or "-"
|
||||
description = archive.get("description") or ""
|
||||
size = format_size(archive.get("size_bytes"))
|
||||
|
||||
title_label = Gtk.Label(xalign=0)
|
||||
title_label.set_markup(f"<b>{GLib.markup_escape_text(title)}</b>")
|
||||
title_label.set_text(title)
|
||||
title_label.add_css_class("archive-title")
|
||||
|
||||
details_text = (
|
||||
f"Bottle: {bottle_name} • Runner: {runner} • Arch: {arch} • "
|
||||
f"Windows: {windows_version} • Size: {size} • Tag: {tags} • Creato: {created_at}"
|
||||
)
|
||||
if description:
|
||||
details_text = f"{details_text}\n{description}"
|
||||
|
||||
details_label = Gtk.Label(
|
||||
# Info line: bottle · runner · arch · windows · size
|
||||
bottle_name = archive.get("bottle_name") or "—"
|
||||
runner = archive.get("runner") or "—"
|
||||
arch = archive.get("arch") or "—"
|
||||
win_ver = archive.get("windows_version") or "—"
|
||||
size = format_size(archive.get("size_bytes"))
|
||||
info_label = Gtk.Label(
|
||||
xalign=0,
|
||||
wrap=True,
|
||||
selectable=True,
|
||||
label=details_text,
|
||||
label=f"{bottle_name} · {runner} · {arch} · {win_ver} · {size}",
|
||||
)
|
||||
details_label.add_css_class("dim-label")
|
||||
info_label.add_css_class("dim-label")
|
||||
info_label.add_css_class("archive-meta")
|
||||
|
||||
status_label = Gtk.Label(xalign=0)
|
||||
if is_installed:
|
||||
status_label.set_markup("<span foreground='#2e7d32'><b>Già installata sul sistema</b></span>")
|
||||
else:
|
||||
status_label.set_markup("<span foreground='#666666'>Non installata localmente</span>")
|
||||
# Meta line: tags · date · description
|
||||
tags = archive.get("tags") or ""
|
||||
created = (archive.get("created_at") or "")[:10]
|
||||
description = archive.get("description") or ""
|
||||
meta_parts = [p for p in (tags, created, description) if p]
|
||||
|
||||
text_box.append(title_label)
|
||||
text_box.append(details_label)
|
||||
text_box.append(status_label)
|
||||
text_box.append(info_label)
|
||||
if meta_parts:
|
||||
meta_label = Gtk.Label(
|
||||
xalign=0, wrap=True, label=" · ".join(meta_parts)
|
||||
)
|
||||
meta_label.add_css_class("dim-label")
|
||||
meta_label.add_css_class("archive-meta")
|
||||
text_box.append(meta_label)
|
||||
|
||||
button_label = "Scarica e installa"
|
||||
# Status chip
|
||||
status_label = Gtk.Label(xalign=0)
|
||||
status_label.add_css_class("status-chip")
|
||||
if is_installed:
|
||||
status_label.set_label("✓ Installata")
|
||||
status_label.add_css_class("installed")
|
||||
else:
|
||||
status_label.set_label("Non installata")
|
||||
status_label.add_css_class("not-installed")
|
||||
chip_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
chip_box.set_margin_top(2)
|
||||
chip_box.append(status_label)
|
||||
text_box.append(chip_box)
|
||||
|
||||
# ── Install button ──
|
||||
if is_installed and replace_enabled:
|
||||
button_label = "Reinstalla"
|
||||
btn_label = "Reinstalla"
|
||||
elif is_installed:
|
||||
button_label = "Già installata"
|
||||
btn_label = "Già installata"
|
||||
else:
|
||||
btn_label = "Installa"
|
||||
|
||||
self.install_button = Gtk.Button(label=button_label)
|
||||
self.install_button = Gtk.Button(label=btn_label)
|
||||
self.install_button.set_valign(Gtk.Align.CENTER)
|
||||
self.install_button.add_css_class("pill")
|
||||
if not is_installed or replace_enabled:
|
||||
self.install_button.add_css_class("suggested-action")
|
||||
self.install_button.set_valign(Gtk.Align.CENTER)
|
||||
else:
|
||||
self.install_button.add_css_class("flat")
|
||||
self.install_button.connect("clicked", lambda *_: on_install(self.archive))
|
||||
self.install_button.set_sensitive((not is_installed) or replace_enabled)
|
||||
|
||||
root.append(icon)
|
||||
root.append(text_box)
|
||||
root.append(self.install_button)
|
||||
self.set_child(root)
|
||||
@@ -243,9 +314,12 @@ class ArchiveRow(Gtk.ListBoxRow):
|
||||
self.install_button.set_sensitive(enabled)
|
||||
|
||||
|
||||
class CellarWindow(Gtk.ApplicationWindow):
|
||||
def __init__(self, app: Gtk.Application):
|
||||
super().__init__(application=app, title="Cellar GTK")
|
||||
# ── Main Window ─────────────────────────────────────────────────
|
||||
class CellarWindow(Adw.ApplicationWindow):
|
||||
"""Primary application window using Adw layout widgets."""
|
||||
|
||||
def __init__(self, app: Adw.Application):
|
||||
super().__init__(application=app, title="Cellar")
|
||||
self.config = load_config()
|
||||
self.settings_window: SettingsWindow | None = None
|
||||
self.rows: list[ArchiveRow] = []
|
||||
@@ -253,130 +327,157 @@ class CellarWindow(Gtk.ApplicationWindow):
|
||||
self.local_bottle_names: set[str] = set()
|
||||
self.busy = False
|
||||
|
||||
self.set_default_size(980, 700)
|
||||
self.set_default_size(800, 680)
|
||||
|
||||
root = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
||||
root.set_margin_top(12)
|
||||
root.set_margin_bottom(12)
|
||||
root.set_margin_start(12)
|
||||
root.set_margin_end(12)
|
||||
# ── Header bar ──
|
||||
header = Adw.HeaderBar()
|
||||
self._window_title = Adw.WindowTitle(title="Cellar", subtitle="Bottle Archive")
|
||||
header.set_title_widget(self._window_title)
|
||||
|
||||
header = Gtk.HeaderBar()
|
||||
header.set_title_widget(Gtk.Label(label="Cellar"))
|
||||
|
||||
self.refresh_button = Gtk.Button(label="Aggiorna")
|
||||
self.refresh_button = Gtk.Button(
|
||||
icon_name="view-refresh-symbolic",
|
||||
tooltip_text="Aggiorna catalogo",
|
||||
)
|
||||
self.refresh_button.connect("clicked", lambda *_: self.refresh_archives())
|
||||
header.pack_start(self.refresh_button)
|
||||
|
||||
self.settings_button = Gtk.Button(label="Configurazione")
|
||||
self.spinner = Gtk.Spinner()
|
||||
header.pack_start(self.spinner)
|
||||
|
||||
self.settings_button = Gtk.Button(
|
||||
icon_name="emblem-system-symbolic",
|
||||
tooltip_text="Configurazione",
|
||||
)
|
||||
self.settings_button.connect("clicked", lambda *_: self.open_settings())
|
||||
header.pack_end(self.settings_button)
|
||||
self.set_titlebar(header)
|
||||
|
||||
info_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||
info_box.add_css_class("card")
|
||||
info_box.set_margin_bottom(6)
|
||||
info_box.set_margin_top(6)
|
||||
info_box.set_margin_start(6)
|
||||
info_box.set_margin_end(6)
|
||||
# ── Toast overlay ──
|
||||
self.toast_overlay = Adw.ToastOverlay()
|
||||
|
||||
self.server_label = Gtk.Label(xalign=0, selectable=True)
|
||||
self.bottles_label = Gtk.Label(xalign=0, selectable=True)
|
||||
self.replace_switch = Gtk.Switch(active=self.config.replace_existing)
|
||||
self.replace_switch.connect("notify::active", self._store_replace_preference)
|
||||
# ── Outer box (banner + progress + scrolled content) ──
|
||||
outer_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
replace_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||
replace_box.append(Gtk.Label(label="Sostituisci installazioni esistenti", xalign=0))
|
||||
replace_box.append(self.replace_switch)
|
||||
self.banner = Adw.Banner()
|
||||
self.banner.set_revealed(False)
|
||||
outer_box.append(self.banner)
|
||||
|
||||
info_box.append(self.server_label)
|
||||
info_box.append(self.bottles_label)
|
||||
info_box.append(replace_box)
|
||||
|
||||
search_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||
search_box.append(Gtk.Label(label="Cerca", xalign=0))
|
||||
|
||||
self.search_entry = Gtk.SearchEntry()
|
||||
self.search_entry.set_hexpand(True)
|
||||
self.search_entry.set_placeholder_text("Nome, bottle, tag, runner, descrizione…")
|
||||
self.search_entry.connect("search-changed", self._on_search_changed)
|
||||
search_box.append(self.search_entry)
|
||||
|
||||
clear_search_button = Gtk.Button(label="Pulisci")
|
||||
clear_search_button.connect("clicked", lambda *_: self.search_entry.set_text(""))
|
||||
search_box.append(clear_search_button)
|
||||
|
||||
self.summary_label = Gtk.Label(xalign=0)
|
||||
|
||||
self.status_label = Gtk.Label(xalign=0, wrap=True)
|
||||
self.progress_bar = Gtk.ProgressBar()
|
||||
self.progress_bar.set_hexpand(True)
|
||||
self.progress_bar.set_show_text(False)
|
||||
self.spinner = Gtk.Spinner()
|
||||
self.progress_bar.set_visible(False)
|
||||
outer_box.append(self.progress_bar)
|
||||
|
||||
status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||
status_box.append(self.spinner)
|
||||
status_box.append(self.progress_bar)
|
||||
# ── Scrolled area ──
|
||||
scroller = Gtk.ScrolledWindow()
|
||||
scroller.set_vexpand(True)
|
||||
|
||||
root.append(info_box)
|
||||
root.append(search_box)
|
||||
root.append(self.summary_label)
|
||||
root.append(self.status_label)
|
||||
root.append(status_box)
|
||||
clamp = Adw.Clamp()
|
||||
clamp.set_maximum_size(720)
|
||||
clamp.set_tightening_threshold(500)
|
||||
|
||||
self.placeholder_label = Gtk.Label(
|
||||
label="Nessun archivio disponibile. Premi 'Aggiorna' per leggere il catalogo dal server.",
|
||||
wrap=True,
|
||||
justify=Gtk.Justification.CENTER,
|
||||
content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18)
|
||||
content_box.set_margin_top(18)
|
||||
content_box.set_margin_bottom(24)
|
||||
content_box.set_margin_start(12)
|
||||
content_box.set_margin_end(12)
|
||||
|
||||
# ── Info group (PreferencesGroup) ──
|
||||
info_group = Adw.PreferencesGroup()
|
||||
info_group.set_title("Connessione")
|
||||
|
||||
self.server_info_row = Adw.ActionRow(title="Server")
|
||||
self.server_info_row.set_subtitle(self.config.server_url)
|
||||
self.server_info_row.add_css_class("property")
|
||||
info_group.add(self.server_info_row)
|
||||
|
||||
self.bottles_info_row = Adw.ActionRow(title="Cartella locale")
|
||||
self.bottles_info_row.set_subtitle(self.config.bottles_dir)
|
||||
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.set_active(self.config.replace_existing)
|
||||
self.replace_row.connect("notify::active", self._store_replace_preference)
|
||||
info_group.add(self.replace_row)
|
||||
|
||||
content_box.append(info_group)
|
||||
|
||||
# ── Search entry ──
|
||||
self.search_entry = Gtk.SearchEntry()
|
||||
self.search_entry.set_placeholder_text(
|
||||
"Cerca per nome, bottle, tag, runner, descrizione…"
|
||||
)
|
||||
self.search_entry.connect("search-changed", self._on_search_changed)
|
||||
content_box.append(self.search_entry)
|
||||
|
||||
# ── Summary ──
|
||||
self.summary_label = Gtk.Label(xalign=0)
|
||||
self.summary_label.add_css_class("dim-label")
|
||||
self.summary_label.add_css_class("caption")
|
||||
content_box.append(self.summary_label)
|
||||
|
||||
# ── Archive list / empty state ──
|
||||
self.status_page = Adw.StatusPage()
|
||||
self.status_page.set_icon_name("system-software-install-symbolic")
|
||||
self.status_page.set_title("Nessun archivio")
|
||||
self.status_page.set_description(
|
||||
"Premi il pulsante ↻ per caricare il catalogo dal server."
|
||||
)
|
||||
self.placeholder_label.set_vexpand(True)
|
||||
|
||||
self.listbox = Gtk.ListBox()
|
||||
self.listbox.set_selection_mode(Gtk.SelectionMode.NONE)
|
||||
self.listbox.add_css_class("boxed-list")
|
||||
|
||||
scroller = Gtk.ScrolledWindow()
|
||||
scroller.set_vexpand(True)
|
||||
scroller.set_hexpand(True)
|
||||
scroller.set_child(self.listbox)
|
||||
|
||||
self.content_stack = Gtk.Stack()
|
||||
self.content_stack.add_named(self.placeholder_label, "placeholder")
|
||||
self.content_stack.add_named(scroller, "list")
|
||||
self.content_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
|
||||
self.content_stack.set_transition_duration(200)
|
||||
self.content_stack.add_named(self.status_page, "placeholder")
|
||||
self.content_stack.add_named(self.listbox, "list")
|
||||
|
||||
root.append(self.content_stack)
|
||||
self.set_child(root)
|
||||
content_box.append(self.content_stack)
|
||||
|
||||
self.update_info_labels()
|
||||
clamp.set_child(content_box)
|
||||
scroller.set_child(clamp)
|
||||
outer_box.append(scroller)
|
||||
|
||||
self.toast_overlay.set_child(outer_box)
|
||||
|
||||
# ── ToolbarView ──
|
||||
toolbar_view = Adw.ToolbarView()
|
||||
toolbar_view.add_top_bar(header)
|
||||
toolbar_view.set_content(self.toast_overlay)
|
||||
|
||||
self.set_content(toolbar_view)
|
||||
|
||||
# ── Init state ──
|
||||
self.refresh_local_bottles()
|
||||
self.set_status("Pronto.", 0.0)
|
||||
self.update_summary(0, 0)
|
||||
self.refresh_archives()
|
||||
|
||||
# ── Preferences ─────────────────────────────────────────────
|
||||
def _store_replace_preference(self, *_args: object) -> None:
|
||||
self.config.replace_existing = self.replace_switch.get_active()
|
||||
self.config.replace_existing = self.replace_row.get_active()
|
||||
save_config(self.config)
|
||||
self.apply_archive_filter()
|
||||
|
||||
def update_info_labels(self) -> None:
|
||||
self.server_label.set_text(f"Server: {self.config.server_url}")
|
||||
self.bottles_label.set_text(f"Cartella locale: {self.config.bottles_dir}")
|
||||
self.replace_switch.set_active(self.config.replace_existing)
|
||||
self.server_info_row.set_subtitle(self.config.server_url)
|
||||
self.bottles_info_row.set_subtitle(self.config.bottles_dir)
|
||||
self.replace_row.set_active(self.config.replace_existing)
|
||||
|
||||
def update_summary(self, visible_count: int, total_count: int) -> None:
|
||||
if total_count == 0:
|
||||
self.summary_label.set_text("Nessun archivio caricato.")
|
||||
return
|
||||
if visible_count == total_count:
|
||||
elif visible_count == total_count:
|
||||
self.summary_label.set_text(f"Archivi visibili: {visible_count}")
|
||||
return
|
||||
self.summary_label.set_text(f"Archivi visibili: {visible_count} su {total_count}")
|
||||
else:
|
||||
self.summary_label.set_text(
|
||||
f"Archivi visibili: {visible_count} su {total_count}"
|
||||
)
|
||||
|
||||
# ── Settings window ─────────────────────────────────────────
|
||||
def open_settings(self) -> None:
|
||||
if self.settings_window is not None:
|
||||
self.settings_window.present()
|
||||
return
|
||||
|
||||
self.settings_window = SettingsWindow(self, self.config, self.apply_config)
|
||||
self.settings_window.connect("close-request", self._on_settings_closed)
|
||||
self.settings_window.present()
|
||||
@@ -392,14 +493,15 @@ class CellarWindow(Gtk.ApplicationWindow):
|
||||
self.refresh_local_bottles()
|
||||
self.refresh_archives()
|
||||
|
||||
# ── Local bottles ───────────────────────────────────────────
|
||||
def refresh_local_bottles(self) -> None:
|
||||
bottles_dir = Path(self.config.bottles_dir).expanduser()
|
||||
local_bottles = collect_local_bottles(bottles_dir)
|
||||
known_names: set[str] = set()
|
||||
for bottle in local_bottles:
|
||||
known_names.add(normalize_name(bottle.get("directory")))
|
||||
known_names.add(normalize_name(bottle.get("name")))
|
||||
self.local_bottle_names = {name for name in known_names if name}
|
||||
known: set[str] = set()
|
||||
for b in local_bottles:
|
||||
known.add(normalize_name(b.get("directory")))
|
||||
known.add(normalize_name(b.get("name")))
|
||||
self.local_bottle_names = {n for n in known if n}
|
||||
|
||||
def is_archive_installed(self, archive: dict[str, Any]) -> bool:
|
||||
candidates = {
|
||||
@@ -407,8 +509,9 @@ class CellarWindow(Gtk.ApplicationWindow):
|
||||
normalize_name(archive.get("name")),
|
||||
}
|
||||
candidates.discard("")
|
||||
return any(candidate in self.local_bottle_names for candidate in candidates)
|
||||
return any(c in self.local_bottle_names for c in candidates)
|
||||
|
||||
# ── Busy / status ───────────────────────────────────────────
|
||||
def set_busy(self, busy: bool) -> None:
|
||||
self.busy = busy
|
||||
self.refresh_button.set_sensitive(not busy)
|
||||
@@ -422,34 +525,35 @@ class CellarWindow(Gtk.ApplicationWindow):
|
||||
self.spinner.stop()
|
||||
|
||||
def set_status(self, message: str, fraction: float | None = None) -> None:
|
||||
self.status_label.set_text(message)
|
||||
self.banner.set_title(message)
|
||||
self.banner.set_revealed(True)
|
||||
self.progress_bar.set_visible(True)
|
||||
if fraction is None:
|
||||
self.progress_bar.pulse()
|
||||
else:
|
||||
self.progress_bar.set_fraction(max(0.0, min(1.0, fraction)))
|
||||
|
||||
def show_message(self, message: str, title: str = "Cellar") -> None:
|
||||
dialog = Gtk.MessageDialog(
|
||||
transient_for=self,
|
||||
modal=True,
|
||||
buttons=Gtk.ButtonsType.OK,
|
||||
text=title,
|
||||
secondary_text=message,
|
||||
)
|
||||
dialog.connect("response", lambda d, *_: d.close())
|
||||
dialog.present()
|
||||
def _hide_status(self) -> None:
|
||||
self.banner.set_revealed(False)
|
||||
self.progress_bar.set_visible(False)
|
||||
|
||||
def _toast(self, message: str, timeout: int = 3) -> None:
|
||||
toast = Adw.Toast(title=message)
|
||||
toast.set_timeout(timeout)
|
||||
self.toast_overlay.add_toast(toast)
|
||||
|
||||
# ── Archive list ────────────────────────────────────────────
|
||||
def clear_rows(self) -> None:
|
||||
self.rows.clear()
|
||||
child = self.listbox.get_first_child()
|
||||
while child is not None:
|
||||
next_child = child.get_next_sibling()
|
||||
nxt = child.get_next_sibling()
|
||||
self.listbox.remove(child)
|
||||
child = next_child
|
||||
child = nxt
|
||||
|
||||
def render_archives(self, archives: list[dict[str, Any]]) -> None:
|
||||
self.clear_rows()
|
||||
replace_enabled = self.replace_switch.get_active()
|
||||
replace_enabled = self.replace_row.get_active()
|
||||
for archive in archives:
|
||||
row = ArchiveRow(
|
||||
archive,
|
||||
@@ -462,31 +566,42 @@ class CellarWindow(Gtk.ApplicationWindow):
|
||||
self.listbox.append(row)
|
||||
|
||||
if self.all_archives and not archives:
|
||||
self.placeholder_label.set_text("Nessun archivio corrisponde al filtro corrente.")
|
||||
self.status_page.set_icon_name("edit-find-symbolic")
|
||||
self.status_page.set_title("Nessun risultato")
|
||||
self.status_page.set_description(
|
||||
"Nessun archivio corrisponde al filtro corrente."
|
||||
)
|
||||
elif not self.all_archives:
|
||||
self.placeholder_label.set_text("Nessun archivio disponibile. Premi 'Aggiorna' per leggere il catalogo dal server.")
|
||||
self.status_page.set_icon_name("system-software-install-symbolic")
|
||||
self.status_page.set_title("Nessun archivio")
|
||||
self.status_page.set_description(
|
||||
"Premi il pulsante ↻ per caricare il catalogo dal server."
|
||||
)
|
||||
|
||||
self.content_stack.set_visible_child_name("list" if archives else "placeholder")
|
||||
self.content_stack.set_visible_child_name(
|
||||
"list" if archives else "placeholder"
|
||||
)
|
||||
self.update_summary(len(archives), len(self.all_archives))
|
||||
|
||||
def apply_archive_filter(self) -> None:
|
||||
query = self.search_entry.get_text()
|
||||
filtered = [archive for archive in self.all_archives if archive_matches_query(archive, query)]
|
||||
filtered = [
|
||||
a for a in self.all_archives if archive_matches_query(a, query)
|
||||
]
|
||||
self.render_archives(filtered)
|
||||
|
||||
def _on_search_changed(self, *_args: object) -> None:
|
||||
self.apply_archive_filter()
|
||||
|
||||
# ── Background work ─────────────────────────────────────────
|
||||
def run_background(self, worker: Callable[[], None]) -> None:
|
||||
thread = threading.Thread(target=worker, daemon=True)
|
||||
thread.start()
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
def refresh_archives(self) -> None:
|
||||
if self.busy:
|
||||
return
|
||||
|
||||
self.set_busy(True)
|
||||
self.set_status("Caricamento catalogo dal server…", None)
|
||||
self.set_status("Caricamento catalogo dal server…")
|
||||
|
||||
def worker() -> None:
|
||||
try:
|
||||
@@ -494,7 +609,6 @@ class CellarWindow(Gtk.ApplicationWindow):
|
||||
except Exception as exc:
|
||||
GLib.idle_add(self._handle_error, exc)
|
||||
return
|
||||
|
||||
GLib.idle_add(self._finish_refresh, archives)
|
||||
|
||||
self.run_background(worker)
|
||||
@@ -503,20 +617,31 @@ class CellarWindow(Gtk.ApplicationWindow):
|
||||
self.all_archives = archives
|
||||
self.refresh_local_bottles()
|
||||
self.apply_archive_filter()
|
||||
message = f"Trovati {len(archives)} archivi sul server." if archives else "Nessun archivio trovato sul server."
|
||||
self.set_status(message, 1.0)
|
||||
self._hide_status()
|
||||
n = len(archives)
|
||||
self._window_title.set_subtitle(
|
||||
f"{n} archivi{'o' if n == 1 else ''}" if n else "Bottle Archive"
|
||||
)
|
||||
if archives:
|
||||
self._toast(f"Catalogo aggiornato: {n} archivi{'o' if n == 1 else ''}")
|
||||
else:
|
||||
self._toast("Nessun archivio trovato sul server.")
|
||||
self.set_busy(False)
|
||||
return False
|
||||
|
||||
# ── Install ─────────────────────────────────────────────────
|
||||
def install_archive(self, archive: dict[str, Any]) -> None:
|
||||
if self.busy:
|
||||
return
|
||||
|
||||
archive_name = archive.get("name") or archive.get("bottle_name") or f"#{archive.get('id', '?')}"
|
||||
archive_name = (
|
||||
archive.get("name")
|
||||
or archive.get("bottle_name")
|
||||
or f"#{archive.get('id', '?')}"
|
||||
)
|
||||
bottles_dir = Path(self.config.bottles_dir).expanduser()
|
||||
replace_existing = self.replace_switch.get_active()
|
||||
replace_existing = self.replace_row.get_active()
|
||||
self.set_busy(True)
|
||||
self.set_status(f"Avvio installazione di {archive_name}…", 0.0)
|
||||
self.set_status(f"Installazione di {archive_name}…", 0.0)
|
||||
|
||||
def progress(message: str, fraction: float | None) -> None:
|
||||
GLib.idle_add(self.set_status, message, fraction)
|
||||
@@ -538,40 +663,40 @@ class CellarWindow(Gtk.ApplicationWindow):
|
||||
|
||||
def _finish_install(self, archive_name: str, result: int) -> bool:
|
||||
self.set_busy(False)
|
||||
self._hide_status()
|
||||
if result == 0:
|
||||
self.refresh_local_bottles()
|
||||
self.apply_archive_filter()
|
||||
self.set_status(f"Installazione completata: {archive_name}", 1.0)
|
||||
self.show_message(f"Installazione completata con successo: {archive_name}")
|
||||
self._toast(f"✓ {archive_name} installata con successo")
|
||||
else:
|
||||
self.set_status(f"Installazione non completata: {archive_name}", 0.0)
|
||||
self.show_message(
|
||||
"L'installazione non è stata completata. Controlla se la bottle esiste già oppure abilita la sostituzione.",
|
||||
title="Installazione interrotta",
|
||||
self._toast(
|
||||
"Installazione non completata – abilita la sostituzione se la bottle esiste già.",
|
||||
timeout=5,
|
||||
)
|
||||
return False
|
||||
|
||||
# ── Error handling ──────────────────────────────────────────
|
||||
def _handle_error(self, exc: Exception) -> bool:
|
||||
self.set_busy(False)
|
||||
self.set_status("Operazione non riuscita.", 0.0)
|
||||
|
||||
self._hide_status()
|
||||
if isinstance(exc, urllib.error.HTTPError):
|
||||
detail = exc.read().decode("utf-8", errors="ignore")
|
||||
message = f"HTTP {exc.code}: {detail or exc.reason}"
|
||||
msg = f"HTTP {exc.code}: {detail or exc.reason}"
|
||||
elif isinstance(exc, urllib.error.URLError):
|
||||
message = f"Errore di connessione: {exc.reason}"
|
||||
msg = f"Errore di connessione: {exc.reason}"
|
||||
else:
|
||||
message = str(exc)
|
||||
|
||||
self.show_message(message, title="Errore")
|
||||
msg = str(exc)
|
||||
self._toast(msg, timeout=5)
|
||||
return False
|
||||
|
||||
|
||||
class CellarApplication(Gtk.Application):
|
||||
# ── Application ─────────────────────────────────────────────────
|
||||
class CellarApplication(Adw.Application):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(application_id="com.cellar.gtkclient")
|
||||
|
||||
def do_activate(self) -> None:
|
||||
_load_css()
|
||||
window = self.props.active_window
|
||||
if window is None:
|
||||
window = CellarWindow(self)
|
||||
|
||||
Reference in New Issue
Block a user