diff --git a/cellar b/cellar new file mode 100755 index 0000000..f44b376 Binary files /dev/null and b/cellar differ diff --git a/cellar.spec b/cellar.spec new file mode 100644 index 0000000..7338f98 --- /dev/null +++ b/cellar.spec @@ -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, +) diff --git a/gtk_client.py b/gtk_client.py index 063e467..2bff4e8 100644 --- a/gtk_client.py +++ b/gtk_client.py @@ -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) - - 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) - - description = Gtk.Label( - label="Imposta il server Cellar e la cartella locale di Bottles usata per installazione e upload.", - wrap=True, - xalign=0, + self.set_default_size(480, -1) + + toolbar_view = Adw.ToolbarView() + + 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) - - 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) - - 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) + self.server_row = Adw.EntryRow(title="Indirizzo server") + self.server_row.set_text(config.server_url) + group.add(self.server_row) - button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) - button_box.set_halign(Gtk.Align.END) + self.bottles_row = Adw.EntryRow(title="Cartella Bottles") + self.bottles_row.set_text(config.bottles_dir) + group.add(self.bottles_row) - 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=14) + root.add_css_class("archive-row") - 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) + # ── 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"{GLib.markup_escape_text(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( + title_label.set_text(title) + title_label.add_css_class("archive-title") + + # 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("Già installata sul sistema") - else: - status_label.set_markup("Non installata localmente") + # 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) + self.progress_bar = Gtk.ProgressBar() + self.progress_bar.set_visible(False) + outer_box.append(self.progress_bar) - search_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) - search_box.append(Gtk.Label(label="Cerca", xalign=0)) + # ── Scrolled area ── + scroller = Gtk.ScrolledWindow() + scroller.set_vexpand(True) - 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) + clamp = Adw.Clamp() + clamp.set_maximum_size(720) + clamp.set_tightening_threshold(500) - clear_search_button = Gtk.Button(label="Pulisci") - clear_search_button.connect("clicked", lambda *_: self.search_entry.set_text("")) - search_box.append(clear_search_button) + 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) - self.summary_label = Gtk.Label(xalign=0) + # ── Info group (PreferencesGroup) ── + info_group = Adw.PreferencesGroup() + info_group.set_title("Connessione") - 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.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) - status_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) - status_box.append(self.spinner) - status_box.append(self.progress_bar) + 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) - root.append(info_box) - root.append(search_box) - root.append(self.summary_label) - root.append(self.status_label) - root.append(status_box) + 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) - 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.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)