From 30899de5a3b97cb7e3e0ac85db52a3580a6d67c8 Mon Sep 17 00:00:00 2001 From: Karim Date: Thu, 2 Jul 2026 21:21:52 +0200 Subject: [PATCH] gui: native-backend session work + fix shim crashes; add page smoke test Bundles the pending native-UI session (dark shell, style overhaul, audio page) with review fixes: - native Row: set_subtitle_lines + set_activatable_widget (store and online-accounts pages crashed with AttributeError under TANINUX_UI=native) - wine page routed through the ui seam (last direct Adw import removed) - console: delete no-op log_expander/collapsible_log and .clear() call sites - ui/__init__ docstring now states the actual default backend (native) - tests/test_pages_native.py builds every registered page under the native backend (29 pass; skips headless) Co-Authored-By: Claude Fable 5 --- HANDOVER.md | 114 +++++ src/taninux/gui/app.py | 2 +- src/taninux/gui/console.py | 16 +- src/taninux/gui/pages/audio.py | 2 - src/taninux/gui/pages/network.py | 3 - src/taninux/gui/pages/store.py | 1 - src/taninux/gui/pages/wine.py | 11 +- src/taninux/gui/style.py | 35 +- src/taninux/gui/ui/__init__.py | 3 +- src/taninux/gui/ui/native.py | 93 +++- src/taninux/gui/ui/shell.py | 70 ++- src/taninux/gui/ui/style_apple.css | 731 +++++++++++++++++++++++++++-- tests/test_pages_native.py | 71 +++ 13 files changed, 1058 insertions(+), 94 deletions(-) create mode 100644 tests/test_pages_native.py diff --git a/HANDOVER.md b/HANDOVER.md index b8550da..5574ca4 100644 --- a/HANDOVER.md +++ b/HANDOVER.md @@ -177,3 +177,117 @@ Installed to `~/.local/share/icons/Tanin-*` (user dir, not pacman). - `camel.toml` `rebuilt_aur` list is stale (missing librewolf-bin, arch-update, timeshift-autosnap, xdg-terminal-exec). - Manual icon picker in `desktop.py` still lists all 5 `Tanin-*` themes (minor). + +--- + +## SESSION 2026-06-27 — TANINUX Music scaffold (native TIDAL client, "Weg 3") + +New package `src/taninux/music/` — a native Apple-Music-style TIDAL client. Strategy: +**reuse High Tide's UI-free backend, build our own native shell** (no libadwaita fork). +High Tide's shell is built on heavy Adw layout widgets (NavigationView, OverlaySplitView, +MultiLayoutView, Breakpoints, Carousel) that have no plain-GTK4 equivalent and that our +`native.py` does NOT cover — so forking its UI was rejected. Its *backend* (~1650 LOC: +GStreamer player, tidalapi session, token store) is cleanly decoupled and worth reusing. + +### Structure +- `backend/player.py` — copied verbatim from High Tide's `lib/player_object.py` (GPL-3.0), + except ONE edit: `from . import discord_rpc, utils` → `from . import discord_rpc` + + `from . import state as utils`. Player's only coupling to the old god-`utils` was 4 names + (`CACHE_DIR`, `MUSIC_DIR`, `session`, `send_toast`) — all provided by `state.py`. +- `backend/state.py` — slim shim providing those 4 names + `init()`/`set_session()`/`set_toast_sink()`. +- `backend/secret_store.py` — copied from High Tide (LGPL-3.0), schema/key renamed to TANINUX. +- `backend/auth.py` — extracted PKCE flow (was in an Adw.Dialog): `Auth.try_restore()` / + `pkce_login_url()` / `finish_pkce_login()` + `login_async()`. +- `backend/discord_rpc.py` — no-op stub (player calls `set_activity`/`disconnect`). +- `ui/transport.py` — bottom now-playing bar, binds to player GObject signals + (`song-changed`, `update-slider`, `notify::playing`, `can-go-*`). Async cover fetch. +- `ui/library.py` — favorites-tracks list; row-activate → `player.play_this(tracks, i)`. +- `ui/login_dialog.py` — native `ui.Dialog` PKCE login (tidalapi imported lazily inside). +- `window.py` — shell: sidebar (nav) + `Gtk.Stack` content + transport bar. `tn-dark`. +- `app.py` — entry `tmusic`. Lazy-imports player/auth (so app loads without tidalapi → shows + "backend missing" StatusPage). Flow: CSS → state.init → Auth → try_restore? → shell ±login. +- CSS appended to `gui/ui/style_apple.css` (section "TANINUX Music"): `.tn-transport`, + `.tn-content`, `.tn-track-list`, `.tn-music-sidebar`. Reuses existing `.tn-sidebar`. +- Entry point added to `pyproject.toml`: `tmusic = "taninux.music.app:main"`. + +### Verified +- All new files `py_compile` clean. Backend `state`/`discord_rpc` import without tidalapi/Adw. +- All UI+app modules import without tidalapi (lazy imports hold). + +### NOT verified (needs real env) +- End-to-end playback: `tidalapi` is NOT installed here, and there's no TIDAL account/display + in the sandbox. **To run:** `pacman -S python-tidalapi` (or `pip install tidalapi`), then + `PYTHONPATH=src python -m taninux.music` (or `tmusic` after install). Expect: login dialog → + paste redirect URL → library loads → click track → GStreamer plays → transport updates. + +### Next building blocks (all go into the content `Gtk.Stack`) +- Home page (mixes/recommendations carousels) and Search page (currently placeholders). +- The "few missing native layout widgets" if richer nav is wanted: a NavigationStack + (push/pop with back button) and horizontal carousels. v1 deliberately uses a flat `Gtk.Stack`. +- Toast sink: `state.set_toast_sink(...)` is wired but app currently leaves the default (logs). + +--- + +## SESSION 2026-06-27 (cont.) — TANINUX Music FULL build + playback verified + +User gave full autonomy ("entwickle die app komplett aus"). The scaffold above is now a +working app. **Playback verified end-to-end** via a headless test (logged-in PKCE session → +favorite track → GStreamer pipeline reached PLAYING, position advanced to 8.2s). + +### Login: must be PKCE, not device-flow (IMPORTANT) +- tidalapi has TWO clients. The OAuth **device-flow** (`session.login_oauth()`, the smooth + `link.tidal.com/XXXXX` flow) logs in and can BROWSE, but **cannot stream** — playback hits + `429 Too Many Requests` on `playbackinfopostpaywall`. tidalapi docstring: PKCE "is the only + way" to stream. +- So login uses **PKCE** (`auth.pkce_login_url()` → browser → copy the redirect/"Oops" URL → + `finish_pkce_login`). `LoginDialog` auto-opens the browser, shows a clickable link, AND + watches the clipboard (`Gdk.Clipboard.read_text_async` poll) so pasting is automatic. +- `auth.try_restore()` discards any stored NON-pkce token (`is-pkce` flag in keyring) to force + a PKCE re-login. Tokens persist in libsecret → normally no dialog on subsequent launches. + +### Runtime deps installed this session (pacman) +- `python-tidalapi` (extra) — the API/player lib. +- `gst-libav` + `gst-plugins-bad` + `gst-plugins-ugly` — **required for audio**. Without them + GStreamer can't decode TIDAL's AAC/DASH streams ("No suitable plugins found", no sound). + Key elements now present: `avdec_aac`, `avdec_flac`, `faad`, `dashdemux2`. +- These are NOT expressible in pyproject; package them as distro deps for the [tanin] repo. + +### Architecture (all under src/taninux/music/) +- `backend/api.py` — thin tidalapi data layer (UI-free): `home/explore/for_you_categories`, + `search`, favorites/playlists, `album_tracks`/`playlist_tracks`/`mix_items`/`artist_*`, + item classification (`item_kind/title/subtitle`), `IdCache`. NOTE: `session.home()` returns + 400 sometimes → `home_categories()` falls back to legacy endpoint, and HomePage also pulls + `explore()` + `for_you()`. +- `ui/images.py` — async cover loader, disk cache under `state.IMG_DIR`, sets `Gdk.Texture` + on `Gtk.Picture`. +- `ui/nav.py` — `NavigationStack` (back button + title + content; set_root/push/pop). Replaces + Adw.NavigationView. +- `ui/context.py` — `MusicContext(session, player, nav, toast)`; `open_item(item)` classifies + + pushes the right detail page; `play/shuffle`. Passed to all pages/widgets (no globals). +- `ui/widgets.py` — `Card` (cover+title+sub, click→open_item), `Carousel` (titled h-scroll), + `TrackList` (row-activate → play). +- `ui/pages_base.py` — `LoadablePage` (spinner → thread fetch() → main-thread build()). +- `ui/pages_home.py`, `pages_search.py` (live clipboard-less SearchEntry → debounced search), + `pages_detail.py` (Album/Playlist/Mix share `_TrackDetailPage`; ArtistPage), `pages_collection.py` + (Bibliothek: playlists/albums/artists/tracks), `pages_queue.py`. +- `window.py` — shell: sidebar (Home/Suchen/Bibliothek) → `nav.set_root`; Gtk.Overlay for + toasts; `TransportBar` at bottom. `reload_after_login()`. +- `ui/transport.py` — now-playing bar: cover, prev/play/next, seek, shuffle/repeat/queue, volume. +- `backend/mpris.py` — ported from High Tide, DECOUPLED: `MPRIS(player, app_id, raise_cb, quit_cb)`, + cover path via `state.IMG_DIR`. Verified on bus as `org.mpris.MediaPlayer2.ch.gabrielevarano.Taninux.Music`. +- `app.py` — entry `tmusic`. CSS → state.init → Auth → try_restore? → shell ±PKCE login; wires + toast sink + starts MPRIS (both non-fatal). +- CSS: "TANINUX Music" section in `gui/ui/style_apple.css` (cards/covers/carousel/transport/ + toast/nav + title-1/title-3/dim-label/error fallbacks). + +### Known minor issues / TODO +- Cosmetic GTK warning "GtkGizmo (slider) reported min width -4" from a `Gtk.Scale` — harmless. +- Per-section nav stacks are not preserved: switching sidebar resets that section to its root + (Apple keeps a stack per tab). Acceptable for v1. +- Queue page is read-only (no reorder/remove). No lyrics page. `discord_rpc` is a no-op stub. +- DONE (2026-06-27 cont.): track→album/artist navigation — TrackRow has a "⋯" MenuButton + + right-click popover (Zum Album / Zum Interpreten / Als Nächstes / Zur Warteschlange); the + transport bar's title→album and artist→artist are clickable (`.tn-link`). Carousel cards + already opened detail pages. Sidebar switched to the Settings `.tn-nav` look (glassy accent + wash + a frost gradient on `window.tn-dark .tn-sidebar`). +- Search is debounced via SearchEntry's `search-changed`; could add a small delay/throttle. diff --git a/src/taninux/gui/app.py b/src/taninux/gui/app.py index 8f6a184..69fd8be 100644 --- a/src/taninux/gui/app.py +++ b/src/taninux/gui/app.py @@ -35,7 +35,7 @@ class _ShellApp(ui.Application): def main_settings(argv: list[str] | None = None) -> int: - app = _ShellApp(f"{APP_ID}.Settings", "System Settings", SETTINGS_SECTIONS) + app = _ShellApp(f"{APP_ID}.Settings", "Settings", SETTINGS_SECTIONS) return app.run(argv if argv is not None else sys.argv) diff --git a/src/taninux/gui/console.py b/src/taninux/gui/console.py index ee0dbe6..ccc6c75 100644 --- a/src/taninux/gui/console.py +++ b/src/taninux/gui/console.py @@ -1,9 +1,8 @@ """Log console — writes to ~/.local/share/taninux/taninux.log (no UI widget). All LogConsole.append_line() calls now write timestamped lines to the log file -instead of displaying them in a scrollable in-app widget. The log_expander() -and collapsible_log() helpers are kept as no-ops so callers can be cleaned up -incrementally, but they return None and must not be added to the page. +instead of displaying them in a scrollable in-app widget. LogConsole.clear() is +a no-op kept only for API stability (the file log is append-only). """ from __future__ import annotations @@ -41,11 +40,6 @@ class LogConsole: _write_log(text) -def collapsible_log(console: LogConsole, *, title: str = "Log") -> None: - """Removed — log is now written to file. Returns None.""" - return None - - class RunnerCard(Gtk.Box): """Konsole + Action-Button, verdrahtet mit einem ProcessRunner.""" @@ -87,9 +81,3 @@ class RunnerCard(Gtk.Box): _write_log("") _write_log(f"✔ done (exit {code})" if code == 0 else f"✘ exit {code}") self._button.set_sensitive(True) - - -def log_expander(console: LogConsole, *, title: str = "Log", - expanded: bool = False) -> None: - """Removed — log is now written to file. Returns None.""" - return None diff --git a/src/taninux/gui/pages/audio.py b/src/taninux/gui/pages/audio.py index d727a2e..24a983f 100644 --- a/src/taninux/gui/pages/audio.py +++ b/src/taninux/gui/pages/audio.py @@ -84,7 +84,6 @@ def _stream_row(st: audio.Stream) -> ui.Row: scale.set_size_request(200, -1) scale.set_draw_value(False) scale.set_valign(Gtk.Align.CENTER) - scale.add_mark(100, Gtk.PositionType.BOTTOM, None) mute_btn = Gtk.ToggleButton() mute_btn.add_css_class("flat") @@ -138,7 +137,6 @@ def _audio_group(kind: str, title: str, icon_on: str, icon_off: str) -> ui.Group scale.set_size_request(220, -1) scale.set_draw_value(False) scale.set_valign(Gtk.Align.CENTER) - scale.add_mark(100, Gtk.PositionType.BOTTOM, None) # 100%-Markierung pct = Gtk.Label() pct.add_css_class("dim-label") diff --git a/src/taninux/gui/pages/network.py b/src/taninux/gui/pages/network.py index 4e00600..e27c948 100644 --- a/src/taninux/gui/pages/network.py +++ b/src/taninux/gui/pages/network.py @@ -82,7 +82,6 @@ def build() -> Gtk.Widget: def _connect(net: network.WifiNet, saved: bool) -> None: def do(pw: str) -> None: - console.clear() runner.run(network.connect_argv(net.ssid, pw)) # Gesichert, aber schon gespeichert -> ohne Passwort-Dialog verbinden. if net.security and not saved: @@ -94,12 +93,10 @@ def build() -> Gtk.Widget: argv = network.disconnect_argv() if not argv: return - console.clear() runner.run(argv) def _forget(net: network.WifiNet) -> None: def do() -> None: - console.clear() runner.run(network.forget_argv(net.ssid)) confirm.confirm( refresh, diff --git a/src/taninux/gui/pages/store.py b/src/taninux/gui/pages/store.py index 0abbd2d..dbea1a9 100644 --- a/src/taninux/gui/pages/store.py +++ b/src/taninux/gui/pages/store.py @@ -63,7 +63,6 @@ def build() -> Gtk.Widget: def _act(item: pkgsearch.Result, btn: Gtk.Button) -> None: argv, priv = (pkgsearch.remove_argv if item.installed else pkgsearch.install_argv)(item) - console.clear() btn.set_sensitive(False) runner.run(argv, privileged=priv) diff --git a/src/taninux/gui/pages/wine.py b/src/taninux/gui/pages/wine.py index c5228ec..4b4c087 100644 --- a/src/taninux/gui/pages/wine.py +++ b/src/taninux/gui/pages/wine.py @@ -16,8 +16,7 @@ from pathlib import Path import gi gi.require_version("Gtk", "4.0") -gi.require_version("Adw", "1") -from gi.repository import Adw, GLib, Gio, Gtk # noqa: E402 +from gi.repository import GLib, Gio, Gtk # noqa: E402 from taninux.gui import ui from taninux.core import pacman @@ -239,14 +238,12 @@ def build() -> Gtk.Widget: # ── Actions ──────────────────────────────────────────────────────────── def _run_privileged(argv: list[str]) -> None: - console.clear() runner.run(argv, privileged=True) def _run_winetricks(verb: str) -> None: prefix = _state["prefix"] if prefix is None: return - console.clear() runner.run(winecore.winetricks_argv(prefix, verb)) def _pick_exe(prefix: Path) -> None: @@ -277,13 +274,12 @@ def build() -> Gtk.Widget: if f is None: return exe = Path(f.get_path()) - console.clear() runner.run(winecore.run_exe_argv(prefix, exe)) dialog.open(page.get_root(), None, _done) def _new_prefix_dialog(_btn: Gtk.Widget) -> None: - dlg = Adw.AlertDialog.new("New Wine Prefix", None) + dlg = ui.AlertDialog(heading="New Wine Prefix", body="") box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8) box.set_margin_top(12) @@ -304,7 +300,7 @@ def build() -> Gtk.Widget: dlg.set_extra_child(box) dlg.add_response("cancel", "Cancel") dlg.add_response("create", "Create") - dlg.set_response_appearance("create", Adw.ResponseAppearance.SUGGESTED) + dlg.set_response_appearance("create", ui.ResponseAppearance.SUGGESTED) dlg.set_default_response("create") dlg.set_close_response("cancel") @@ -320,7 +316,6 @@ def build() -> Gtk.Widget: return arch = "win32" if arch_combo.get_selected() == 1 else "win64" ppath = winecore.new_prefix_path(name) - console.clear() runner.run(winecore.create_prefix_argv(ppath, arch)) dlg.connect("response", _on_response) diff --git a/src/taninux/gui/style.py b/src/taninux/gui/style.py index 949f704..19434dd 100644 --- a/src/taninux/gui/style.py +++ b/src/taninux/gui/style.py @@ -56,10 +56,11 @@ def _build_css(acc: Accent) -> bytes: # Akzent-Override + Swatch-Styling (Swatches sind statisch, aber billig # mitzubauen). Swatch = runder Farbknopf, aktiver bekommt einen Ring. swatches = "\n".join( + # Kein !important — GTK4 verwirft Deklarationen damit (siehe style_apple.css). f"button.fuji-swatch.swatch-{a.key} {{" - f" background-color: {a.hex} !important;" - f" background: {a.hex} !important;" - f" border: none !important; }}" + f" background-color: {a.hex};" + f" background: {a.hex};" + f" border: none; }}" for a in FUJI_ACCENTS ) return f""" @@ -67,6 +68,15 @@ def _build_css(acc: Accent) -> bytes: @define-color accent_fg_color {fg}; @define-color accent_color {acc.hex}; +/* libadwaita/GTK4 nutzen für Fokus-Ringe die CSS-Variable --accent-color + * (Default-Blau), nicht die alte @accent_color-Named-Color. Variable auf den + * Fuji-Akzent setzen, damit ALLE Fokus-Outlines (Entry, Scale, Button …) folgen. */ +:root {{ + --accent-color: {acc.hex}; + --accent-bg-color: {acc.hex}; + --accent-fg-color: {fg}; +}} + /* pill controls — all buttons & dropdowns are stadium pills */ button, menubutton > button, dropdown, dropdown > button, combobox button, splitbutton > button, entry, spinbutton, @@ -135,12 +145,15 @@ def apply_persisted() -> None: # existing `@import "libadwaita.css"`; GTK3 rides on adw-gtk3. # Monochrome surfaces derived from the two base values (#0f0f0f / #f0f0f0). -_DARK_SURF = dict(bg="#0f0f0f", bg_alt="#191919", bg_hi="#262626", view="#141414", - card="#191919", headerbar="#0f0f0f", fg="#f0f0f0", - border="rgba(240,240,240,0.10)") -_LIGHT_SURF = dict(bg="#f0f0f0", bg_alt="#e4e4e4", bg_hi="#d6d6d6", view="#fafafa", - card="#ffffff", headerbar="#e8e8e8", fg="#0f0f0f", - border="rgba(15,15,15,0.12)") +# Töne 1:1 mit tanin-libadwaita/gtk-4.0/{dark,light}.css (= eww-Palette). +# Shell-Fläche einheitlich: window/headerbar/sidebar alle #0f0f0f, nur Cards +# heben sich mit bg_alt (#191919) ab. +_DARK_SURF = dict(bg="#0f0f0f", bg_alt="#191919", bg_hi="#242424", view="#141414", + card="#191919", headerbar="#0f0f0f", sidebar="#0f0f0f", + fg="#f0f0f0", border="rgba(240,240,240,0.10)") +_LIGHT_SURF = dict(bg="#f3f1ec", bg_alt="#e4e4e4", bg_hi="#d6d6d6", view="#faf8f4", + card="#ffffff", headerbar="#f3f1ec", sidebar="#f3f1ec", + fg="#0f0f0f", border="rgba(15,15,15,0.12)") _PILLS_GTK4 = ( "button, menubutton > button, dropdown, dropdown > button, combobox button,\n" @@ -195,7 +208,7 @@ def _gtk4_full(acc: Accent, dark: bool) -> str: @define-color dialog_fg_color {p['fg']}; @define-color popover_bg_color {p['bg_alt']}; @define-color popover_fg_color {p['fg']}; -@define-color sidebar_bg_color {p['bg_alt']}; +@define-color sidebar_bg_color {p['sidebar']}; @define-color sidebar_fg_color {p['fg']}; {_PILLS_GTK4}""" @@ -302,7 +315,7 @@ placessidebar, placessidebar > viewport, .sidebar > viewport > contents, placessidebar > viewport > contents {{ - background-color: {p['bg_alt']}; + background-color: {p['sidebar']}; color: {p['fg']}; }} .sidebar, diff --git a/src/taninux/gui/ui/__init__.py b/src/taninux/gui/ui/__init__.py index 0fc07d4..f036628 100644 --- a/src/taninux/gui/ui/__init__.py +++ b/src/taninux/gui/ui/__init__.py @@ -7,7 +7,8 @@ EIN Schalter: TANINUX_UI=adw → libadwaita (Adw.PreferencesPage/Group/ActionRow) TANINUX_UI=native → eigene GTK4-Widgets mit Apple-Optik (ui/native.py) -Default ist ``adw`` — heute ändert sich also nichts. Der Sinn der Naht: die +Default ist ``native`` — ohne gesetzte Umgebungsvariable läuft die App auf den +eigenen GTK4-Widgets. Der Sinn der Naht: die ~430 Adw-Zeilen-/Gruppen-Instanzen über 34 Dateien laufen ab jetzt durch EINE Stelle. Damit wird „eigene Design-Sprache statt libadwaita“ ein Umbau hier statt überall — und Seite für Seite migrierbar, weil beide Backends dieselbe diff --git a/src/taninux/gui/ui/native.py b/src/taninux/gui/ui/native.py index cab2cbc..a0133da 100644 --- a/src/taninux/gui/ui/native.py +++ b/src/taninux/gui/ui/native.py @@ -15,10 +15,15 @@ import gi gi.require_version("Gdk", "4.0") gi.require_version("Gtk", "4.0") -from gi.repository import Gdk, GObject, Gtk # noqa: E402 +from gi.repository import Gdk, GObject, Gtk, Pango # noqa: E402 -# Apples Content-Spalte ist fix-ish; wir clampen statt voll zu dehnen. -CLAMP_WIDTH = 640 +# Content-Spalte: responsiv. Wächst mit dem Fenster, aber nie schmaler als +# CLAMP_MIN und nie breiter als CLAMP_MAX — dazwischen füllt sie die verfügbare +# Breite und sitzt zentriert (siehe _Clamp). +CLAMP_MIN = 640 +CLAMP_MAX = 960 +# Rückwärtskompatibler Alias (falls irgendwo referenziert). +CLAMP_WIDTH = CLAMP_MAX _css_loaded = False @@ -41,8 +46,12 @@ def ensure_css() -> None: provider = Gtk.CssProvider() provider.load_from_path(str(Path(__file__).with_name("style_apple.css"))) + # Über PRIORITY_USER (800): GTK lädt ~/.config/gtk-4.0/gtk.css (adw-gtk3) + # automatisch auf USER-Priorität. Dessen sehr spezifische headerbar-/ + # sidebar-Regeln würden unsere bei gleicher Priorität schlagen — darum + # eins drüber, damit unsere Apple-Optik zuverlässig gewinnt. Gtk.StyleContext.add_provider_for_display( - display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1 ) _css_loaded = True @@ -64,7 +73,8 @@ class Row(Gtk.ListBoxRow): def __init__(self, title: str = "", subtitle: str = "") -> None: super().__init__() self.set_activatable(True) - self.connect("activate", lambda _: self.emit("activated")) + self._activatable_widget: Gtk.Widget | None = None + self.connect("activate", self._on_activate) box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) box.add_css_class("tn-row") @@ -91,6 +101,17 @@ class Row(Gtk.ListBoxRow): self._suffix.set_valign(Gtk.Align.CENTER) box.append(self._suffix) + def _on_activate(self, _row: "Row") -> None: + # Adw.ActionRow forwards activation to its activatable widget (button → + # clicked, everything else → activate); otherwise emit our own signal. + widget = self._activatable_widget + if widget is not None: + if isinstance(widget, Gtk.Button): + widget.emit("clicked") + else: + widget.activate() + self.emit("activated") + # -- Adw.ActionRow-kompatible API ------------------------------------ def set_title(self, title: str) -> None: self._title.set_label(title) @@ -99,6 +120,15 @@ class Row(Gtk.ListBoxRow): self._subtitle.set_label(subtitle or "") self._subtitle.set_visible(bool(subtitle)) + def set_subtitle_lines(self, n: int) -> None: + """Adw.ActionRow.set_subtitle_lines: wrap subtitle to n lines, ellipsise.""" + self._subtitle.set_lines(n) + self._subtitle.set_ellipsize(Pango.EllipsizeMode.END) + + def set_activatable_widget(self, widget: Gtk.Widget | None) -> None: + """Adw.ActionRow.set_activatable_widget: activating the row triggers it.""" + self._activatable_widget = widget + def add_prefix(self, widget: Gtk.Widget) -> None: self._prefix.append(widget) @@ -286,6 +316,51 @@ class Group(Gtk.Box): self._header.set_visible(True) +class _Clamp(Gtk.Widget): + """Adw.Clamp-Ersatz: ein Kind, responsiv zwischen CLAMP_MIN und CLAMP_MAX. + + Das Kind füllt die verfügbare Content-Breite, bleibt dabei aber nie schmaler + als CLAMP_MIN und nie breiter als CLAMP_MAX, und sitzt zentriert. So wächst + der Inhalt mit dem Fenster, behält aber lesbare Grenzen. + + Basiert auf Gtk.Widget statt Gtk.Box, weil Boxen über ihren LayoutManager + messen und das do_measure-Override umgehen würden. + """ + + def __init__(self, child: Gtk.Widget) -> None: + super().__init__(hexpand=True) + self._child = child + child.set_parent(self) + + @staticmethod + def _target(width: int) -> int: + """Spaltenbreite für eine gegebene verfügbare Breite (geklemmt).""" + return max(CLAMP_MIN, min(width, CLAMP_MAX)) + + def do_measure(self, orientation, for_size): # noqa: ANN001 + if orientation == Gtk.Orientation.HORIZONTAL: + # Mindestens CLAMP_MIN anfordern (Fenster darf nicht enger werden), + # natürlich CLAMP_MAX — hexpand füllt dazwischen alles auf. + return CLAMP_MIN, CLAMP_MAX, -1, -1 + width = self._target(for_size) if for_size >= 0 else for_size + return self._child.measure(orientation, width) + + def do_size_allocate(self, width, height, baseline): # noqa: ANN001 + w = self._target(width) + alloc = Gdk.Rectangle() + alloc.x = max(0, (width - w) // 2) + alloc.y = 0 + alloc.width = w + alloc.height = height + self._child.size_allocate(alloc, baseline) + + def do_dispose(self) -> None: + child, self._child = self._child, None + if child is not None: + child.unparent() + Gtk.Widget.do_dispose(self) + + class Page(Gtk.ScrolledWindow): """Adw.PreferencesPage-Ersatz: scrollende, zentrierte Gruppen-Spalte.""" @@ -295,13 +370,11 @@ class Page(Gtk.ScrolledWindow): self.add_css_class("tn-page") self._box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=28) - self._box.set_halign(Gtk.Align.CENTER) - self._box.set_size_request(CLAMP_WIDTH, -1) self._box.set_margin_top(28) self._box.set_margin_bottom(28) self._box.set_margin_start(18) self._box.set_margin_end(18) - self.set_child(self._box) + self.set_child(_Clamp(self._box)) # -- Adw.PreferencesPage-kompatible API ------------------------------ def add(self, group: Gtk.Widget) -> None: @@ -528,6 +601,10 @@ class AlertDialog(GObject.Object): dlg = Gtk.Window(modal=True, resizable=False, decorated=False) dlg.add_css_class("tn-alert-dialog") + # Dunkel-Modus des Eltern-Fensters übernehmen, sonst rendert der Dialog hell. + if win is not None and win.has_css_class("tn-dark"): + dlg.add_css_class("tn") + dlg.add_css_class("tn-dark") if win: dlg.set_transient_for(win) dlg.set_default_size(380, -1) diff --git a/src/taninux/gui/ui/shell.py b/src/taninux/gui/ui/shell.py index 3735c3f..0e69ae0 100644 --- a/src/taninux/gui/ui/shell.py +++ b/src/taninux/gui/ui/shell.py @@ -39,20 +39,16 @@ class MainWindow(Gtk.ApplicationWindow): self._stack = Gtk.Stack(hexpand=True, vexpand=True) self._stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) - # Content-Titel lebt in der Fenster-HeaderBar (statt Adw.WindowTitle). - self._title = Gtk.Label(label=title) - self._title.add_css_class("tn-content-title") - header = Gtk.HeaderBar() - header.set_title_widget(self._title) - self.set_titlebar(header) - + # Kein Topbar/HeaderBar (wie die Music-App): der Seitentitel lebt gross + # oben im Content, der App-Name als Brand oben in der Sidebar. split = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) split.add_css_class("tn-shell") split.append(self._build_sidebar()) sep = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL) split.append(sep) - split.append(self._stack) + split.append(self._build_content()) self.set_child(split) + self._install_search_shortcut() if self._pages: self._listbox.select_row(self._rows[self._pages[0].key]) @@ -82,21 +78,61 @@ class MainWindow(Gtk.ApplicationWindow): scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) scroller.set_child(self._listbox) - search = Gtk.SearchEntry() - search.set_placeholder_text("Search settings") - search.add_css_class("tn-search") - search.set_margin_top(8) - search.set_margin_bottom(4) - search.set_margin_start(8) - search.set_margin_end(8) - search.connect("search-changed", self._on_search) + # Suche ist kein Dauer-Feld: eingeklappte SearchBar, erscheint per Ctrl+F + # (Esc schliesst). Bewusst ohne Pill-Look (keine tn-search-Klasse). + self._search = Gtk.SearchEntry() + self._search.set_placeholder_text("Search settings") + self._search.connect("search-changed", self._on_search) + self._search.connect("stop-search", lambda *_: self._close_search()) + + self._searchbar = Gtk.SearchBar() + self._searchbar.set_child(self._search) + self._searchbar.connect_entry(self._search) + + brand = Gtk.Label(label=self._app_title, xalign=0.0) + brand.add_css_class("tn-brand") + brand.set_wrap(True) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) box.add_css_class("tn-sidebar") - box.append(search) + box.append(brand) + box.append(self._searchbar) box.append(scroller) return box + # -- Suche per Ctrl+F ein-/ausblenden ------------------------------ + def _install_search_shortcut(self) -> None: + controller = Gtk.ShortcutController() + controller.set_scope(Gtk.ShortcutScope.GLOBAL) + controller.add_shortcut(Gtk.Shortcut.new( + Gtk.ShortcutTrigger.parse_string("f"), + Gtk.CallbackAction.new(lambda *_: self._open_search()), + )) + self.add_controller(controller) + + def _open_search(self) -> bool: + self._searchbar.set_search_mode(True) + self._search.grab_focus() + return True + + def _close_search(self) -> None: + self._search.set_text("") + self._searchbar.set_search_mode(False) + + # -- Content ------------------------------------------------------- + def _build_content(self) -> Gtk.Widget: + # Grosser Seitentitel oben, darunter der gestapelte Seiteninhalt. Titel + # ist gepinnt, der Inhalt scrollt. Titel läuft durch denselben _Clamp wie + # die Seiten → bleibt mittig zentriert und linksbündig zu den Karten. + self._title = Gtk.Label(label=self._app_title, xalign=0.0, hexpand=True) + self._title.add_css_class("tn-content-title") + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + box.add_css_class("tn-content-pane") + box.append(native._Clamp(self._title)) + box.append(self._stack) + return box + def _header_row(self, title: str) -> Gtk.ListBoxRow: row = Gtk.ListBoxRow() row.set_selectable(False) diff --git a/src/taninux/gui/ui/style_apple.css b/src/taninux/gui/ui/style_apple.css index 91b17f3..8898e7e 100644 --- a/src/taninux/gui/ui/style_apple.css +++ b/src/taninux/gui/ui/style_apple.css @@ -10,7 +10,7 @@ /* --------------------------------------------------------- Window / Fläche */ .tn-page { - background-color: #f6f6f6; + background-color: #f5f5f7; } .tn-page, .tn-title, @@ -103,6 +103,39 @@ .tn-row button.destructive-action:hover { background-color: rgba(255, 59, 48, 0.10); } +/* Keycap — a shortcut shown like a physical key; click to rebind. */ +.tn-row button.tn-keycap { + font-family: monospace; + font-weight: 600; + color: #1d1d1f; + background-color: rgba(120, 120, 128, 0.12); + border: 1px solid rgba(60, 60, 67, 0.18); + border-radius: 7px; + padding: 2px 10px; + min-height: 0; +} +.tn-row button.tn-keycap:hover { + color: @accent_color; + background-color: alpha(@accent_color, 0.14); + border-color: @accent_color; +} +window.tn-dark .tn-row button.tn-keycap { + color: #f0f0f0; + background-color: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.16); +} +window.tn-dark .tn-row button.tn-keycap:hover { + color: @accent_color; + background-color: alpha(@accent_color, 0.20); + border-color: @accent_color; +} +/* Capture dialog: show the grabbed combo large + monospace. */ +.tn-capture-combo { + font-family: monospace; + font-size: 20px; + font-weight: 700; + margin: 6px 0; +} /* Entry/Spin rahmenlos & rechtsbündig, Wert in Sekundärfarbe (wie iOS). */ .tn-row entry, @@ -162,16 +195,18 @@ background-color: transparent; min-width: 0; } +/* Light: einheitliche Shell im warmen Papier-Ton #f3f1ec (Fuji light $bg), + * statt kaltem Grau — Topbar + Sidebar + Page identisch, nur Cards (#fff) heben ab. */ .tn-sidebar { - background-color: #ebebeb !important; + background-color: #f3f1ec; border-right: 1px solid rgba(0, 0, 0, 0.07); min-width: 232px; } headerbar { - background-color: #ebebeb !important; - background: #ebebeb !important; - border-bottom: 1px solid rgba(0, 0, 0, 0.07) !important; - box-shadow: none !important; + background-color: #f3f1ec; + background: #f3f1ec; + border-bottom: 1px solid rgba(0, 0, 0, 0.07); + box-shadow: none; } .tn-search { border-radius: 999px; @@ -216,8 +251,27 @@ headerbar { color: rgba(60, 60, 67, 0.5); margin: 12px 12px 2px; } +/* Brand oben in der Sidebar (App-Name gross, wie „Music"). */ +.tn-brand { + font-size: 1.55em; + font-weight: 800; + margin: 18px 16px 10px; +} +/* Gepinnter Seitentitel oben im Content (der gewählte Eintrag). Gleich gross + * wie der Brand-Titel links; linke Kante deckt sich mit den Gruppen-Titeln in + * den Karten (18px Page + 10px Card). */ .tn-content-title { - font-weight: 600; + font-size: 1.55em; + font-weight: 800; + margin: 22px 0 6px 28px; +} +/* Content-Fläche der Settings-Shell: gleicher Ton wie die Music-App (.tn-content), + * damit Titelbereich und Seite nahtlos ineinander übergehen. */ +.tn-content-pane { + background-color: #f5f5f7; +} +window.tn-dark .tn-content-pane { + background-color: #161616; } /* Explizite Hell-Farben: GTK4 Adwaita kann beim Systemstart noch dunkel sein. */ @@ -245,42 +299,89 @@ window.tn:not(.tn-dark) .tn-group-desc { /* ------------------------------------------- Suggested-action + Progress */ /* GTK4 Adwaita nutzt @blue_3 für suggested-action; wir überschreiben mit dem - * Fuji-Akzent. !important schlägt Adwaita-Interna bei gleicher Priorität. */ + * Fuji-Akzent. schlägt Adwaita-Interna bei gleicher Priorität. */ button.suggested-action, button.suggested-action:not(:disabled) { - background-color: @accent_color !important; - background: @accent_color !important; - color: @accent_fg_color !important; - box-shadow: none !important; + background-color: @accent_color; + background: @accent_color; + color: @accent_fg_color; + box-shadow: none; } button.suggested-action:hover { - background-color: shade(@accent_color, 0.92) !important; - background: shade(@accent_color, 0.92) !important; + background-color: shade(@accent_color, 0.92); + background: shade(@accent_color, 0.92); } button.suggested-action:active { - background-color: shade(@accent_color, 0.85) !important; - background: shade(@accent_color, 0.85) !important; + background-color: shade(@accent_color, 0.85); + background: shade(@accent_color, 0.85); } /* Progress-Bars (Storage, Memory, CPU…) — Fuji-Akzent statt Adwaita-Blau */ progress, trough > progress, progressbar > trough > progress { - background-color: @accent_color !important; - background: @accent_color !important; + background-color: @accent_color; + background: @accent_color; +} + +/* LevelBar (CPU/Memory/Storage/Akku im Dashboard) — Fuji statt Adwaita-Blau. + * GTK4 nutzt levelbar > trough > block.{filled,full,high,low,empty}. */ +levelbar trough block.filled, +levelbar trough block.full, +levelbar trough block.high, +levelbar trough block.low { + background-color: @accent_color; + border-color: @accent_color; +} +levelbar trough block.empty { + background-color: alpha(@accent_color, 0.12); + border-color: transparent; +} + +/* Scale (Volume / Brightness) — dünner runder Track + runder Schiebe-Knopf + * (iOS-Stil), statt Adwaitas technischem Regler. */ +scale > trough { + background-color: alpha(@accent_color, 0.18); + min-height: 4px; + border-radius: 999px; +} +scale > trough > highlight { + background-color: @accent_color; + border-radius: 999px; +} +scale > trough > slider { + background-color: #ffffff; + border: none; + border-radius: 50%; + min-width: 18px; + min-height: 18px; + margin: -8px; /* überragt den dünnen Track als runder Knopf */ + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35), 0 0 0 0.5px rgba(0, 0, 0, 0.10); + transition: box-shadow 120ms ease; +} +scale > trough > slider:hover { + box-shadow: 0 2px 7px rgba(0, 0, 0, 0.40), 0 0 0 0.5px rgba(0, 0, 0, 0.10); +} +/* Tick-Marke (add_mark 100%) entschärfen — kein technischer Strich. */ +scale > marks, +scale > marks > mark > indicator { + background-color: transparent; + min-height: 0; + min-width: 0; } /* =================================================================== DUNKEL */ window.tn-dark .tn-page { - background-color: #0f0f0f; + background-color: #161616; } -/* Sidebar + Topbar so dunkel wie die eww-Panels ($bg #0f0f0f), satt & opak. */ +/* Einheitliche Shell-Fläche: Topbar + Sidebar + Page alle #0f0f0f (eww $bg). + * Nur die Cards heben sich mit #191919 ab. */ window.tn-dark headerbar { - background-color: #0f0f0f !important; - background: #0f0f0f !important; - border-bottom: 1px solid rgba(255, 255, 255, 0.05) !important; - box-shadow: none !important; + background-color: #0f0f0f; + background: #0f0f0f; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + box-shadow: none; } window.tn-dark .tn-sidebar { - background-color: #0f0f0f !important; + background-color: #0f0f0f; border-right-color: rgba(255, 255, 255, 0.06); } window.tn-dark .tn-search { @@ -309,7 +410,7 @@ window.tn-dark .tn-nav-header { color: rgba(235, 235, 245, 0.45); } window.tn-dark .tn-card { - background-color: rgba(44, 44, 46, 0.88); + background-color: #191919; /* Fuji card_bg (eww $bg-alt) */ border-color: rgba(255, 255, 255, 0.07); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.40), @@ -317,7 +418,7 @@ window.tn-dark .tn-card { inset 0 1px 0 rgba(255, 255, 255, 0.09); /* Glasrand */ } window.tn-dark .card { - background-color: rgba(44, 44, 46, 0.88); + background-color: #191919; /* Fuji card_bg (eww $bg-alt) */ border-color: rgba(255, 255, 255, 0.07); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.40), @@ -431,7 +532,7 @@ window.tn-dark .tn-expander-chevron { margin-top: 16px; } window.tn-dark .tn-alert-box { - background-color: #2c2c2e; + background-color: #191919; /* Fuji popover/dialog */ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45); } window.tn-dark .tn-alert-heading { @@ -440,3 +541,577 @@ window.tn-dark .tn-alert-heading { window.tn-dark .tn-alert-body { color: rgba(235, 235, 245, 0.65); } + +/* ============================================== FOKUS-RING (Akzent statt Blau) */ +/* GTK4s eingebautes Theme zeichnet den Tastatur-Fokus-Ring blau (outline-color). + * Über unseren USER+1-Provider auf den Fuji-Akzent umfärben — gilt für Rows, + * Buttons, LevelBars, Scales, alles Fokussierbare. */ +*:focus-visible { + outline-color: @accent_color; +} +levelbar:focus-visible, +scale:focus-visible, +row:focus-visible, +.tn-nav > row:focus-visible { + outline-color: @accent_color; +} +/* Entry/PasswordEntry/SpinButton zeichnen ihren Fokus per box-shadow (nicht + * outline) in GTKs eigenem Blau — elementspezifisch auf den Fuji-Akzent + * umfärben (höhere Spezifität als *:focus-visible, schlägt das Default-Theme). */ +entry:focus-within, +passwordentry:focus-within, +spinbutton:focus-within { + outline-color: @accent_color; + box-shadow: inset 0 0 0 2px @accent_color; +} +/* Borderlose Werte-Entries in den Karten-Zeilen bleiben ohne Ring. */ +.tn-row entry:focus-within, +.tn-row spinbutton:focus-within { + box-shadow: none; +} +/* Slider-Fokusring komplett entfernen (User: "der Slider braucht das nicht"). + * GTK zeichnet ihn je nach Element als outline ODER box-shadow auf scale/trough/ + * slider — daher alle Varianten abräumen. Der normale 3D-Knopfschatten bleibt + * (kommt aus „scale > trough > slider" oben, hier nicht angefasst). */ +scale:focus-visible, +scale:focus:focus-visible, +scale:focus-within, +scale:focus-visible > trough, +scale:focus:focus-visible > trough, +scale:focus-within > trough { + outline: none; + box-shadow: none; +} +scale:focus-visible > trough > slider, +scale:focus:focus-visible > trough > slider, +scale:focus-within > trough > slider { + outline: none; +} + +/* ============================================================ TANINUX Music + * Native Apple-Music-artige Shell (src/taninux/music). Nutzt .tn-sidebar mit; + * hier nur die Music-spezifischen Flächen: Transport-Bar, Content, Track-Liste. + */ +/* Music-Sidebar nutzt .tn-nav (glassy Accent-Wash + Border wie die Settings). + * Hier nur eine dezente Frost-Note: leichter Vertikal-Verlauf + Innen-Highlight, + * damit das Panel etwas „gläserner“ wirkt als eine flache Fläche. */ +window.tn-dark .tn-sidebar { + background-image: linear-gradient( + to bottom, + rgba(255, 255, 255, 0.045), + rgba(255, 255, 255, 0.0) 160px + ); + box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.05); +} + +.tn-content { + background-color: #f5f5f7; +} +window.tn-dark .tn-content { + background-color: #161616; +} + +/* Track-Liste */ +.tn-track-list { + background: transparent; +} +.tn-track-list > row { + border-radius: 8px; + margin: 0 14px; +} +.tn-track-list > row:hover { + background-color: rgba(120, 120, 128, 0.12); +} +.tn-track-list > row:selected { + background-color: rgba(120, 120, 128, 0.18); +} + +/* Transport-Bar (Now-Playing, unten) — randlos, Padding statt Margin, damit das + * Dunkel bündig bis zum Fensterrand läuft (kein „grauer Rahmen“). */ +.tn-transport { + padding: 8px 14px; + border-top: 1px solid rgba(0, 0, 0, 0.08); + background-color: #f3f1ec; +} +/* Dunkel: solide, identisch zur Sidebar (#0f0f0f). */ +window.tn-dark .tn-transport { + border-top-color: rgba(255, 255, 255, 0.06); + background-color: #0f0f0f; +} +.tn-transport-cover { + border-radius: 6px; + min-width: 46px; + min-height: 46px; +} +/* Ordner-Karte: Icon zentriert auf dezenter Fläche, Akzentfarbe. */ +.tn-folder-cover { + color: @accent_color; + background-color: rgba(120, 120, 128, 0.15); +} +.tn-transport-title { + font-weight: 600; + font-size: 0.95rem; +} +.tn-transport-artist { + font-size: 0.82rem; + opacity: 0.6; +} +.tn-transport-time { + font-size: 0.78rem; + opacity: 0.55; + font-feature-settings: "tnum"; +} +.tn-transport-play { + padding: 6px; +} +.tn-transport scale slider { + min-width: 12px; + min-height: 12px; +} +.tn-transport button.tn-active image { + color: @accent_color; +} + +/* Typografie-Fallbacks (falls das System-Theme sie nicht liefert) */ +.title-1 { font-size: 1.9em; font-weight: 800; } +.title-3 { font-size: 1.15em; font-weight: 700; } +.dim-label { opacity: 0.55; } +.error { color: #ff3b30; } + +/* Karten (Carousel/Grid) */ +.tn-card-btn { + padding: 6px; + border-radius: 12px; + background: transparent; +} +.tn-card-btn:hover { + background-color: rgba(120, 120, 128, 0.12); +} +.tn-cover { + background-color: rgba(120, 120, 128, 0.18); +} +.tn-cover-rounded { border-radius: 10px; } +.tn-cover-round { border-radius: 999px; } +.tn-card-title { font-weight: 600; font-size: 0.92rem; } +.tn-card-sub { font-size: 0.82rem; opacity: 0.55; } + +/* Carousel */ +.tn-carousel-scroller { background: transparent; } +.tn-carousel-scroller > * { background: transparent; } + +/* NavigationStack-Kopf */ +/* Fixer Seitentitel im Music-Header — gleiche Größe wie der Sidebar-Brand „Music“. */ +.tn-nav-title { font-weight: 800; font-size: 1.55em; } + +/* Toast */ +.tn-toast { + background-color: rgba(40, 40, 42, 0.92); + color: #ffffff; + border-radius: 999px; + padding: 10px 20px; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); +} + +/* Klickbarer Titel/Interpret in der Transport-Bar (zu Album/Interpret). */ +.tn-transport .tn-link:hover { + color: @accent_color; + text-decoration: underline; +} + +/* ============================================================ TANINUX Files + * Nativer Dateimanager (src/taninux/files). Teilt Sidebar (.tn-nav/.tn-sidebar) + * und Content (.tn-content) mit Music; hier nur die Datei-spezifischen Flächen: + * Toolbar, Breadcrumb und die Icon-Kacheln im Raster. + */ +/* Toolbar oben im Content (Zurück/Vor/Hoch · Breadcrumb · Aktionen). */ +.tn-files-toolbar { + border-bottom: 1px solid rgba(60, 60, 67, 0.10); + padding-bottom: 8px; +} +window.tn-dark .tn-files-toolbar { + border-bottom-color: rgba(255, 255, 255, 0.06); +} +.tn-files-toolbar button.circular { + min-width: 30px; + min-height: 30px; + padding: 0; +} + +/* Breadcrumb: flache Pfad-Segmente, aktuelles Segment betont. */ +.tn-crumb { + padding: 3px 8px; + min-height: 0; + font-weight: 500; + color: rgba(60, 60, 67, 0.7); +} +.tn-crumb:hover { + background-color: rgba(120, 120, 128, 0.12); +} +.tn-crumb-current { + color: #1d1d1f; + font-weight: 600; +} +.tn-crumb-sep { + color: rgba(60, 60, 67, 0.35); + font-size: 0.95em; +} +window.tn-dark .tn-crumb { + color: rgba(235, 235, 245, 0.6); +} +window.tn-dark .tn-crumb-current { + color: #f5f5f7; +} +window.tn-dark .tn-crumb-sep { + color: rgba(235, 235, 245, 0.35); +} + +/* Datei-Kachel: Icon + Name, Hover-/Auswahl-Wash wie die Music-Karten. */ +.tn-file-grid { + background: transparent; +} +.tn-file-tile { + padding: 10px 6px; + border-radius: 12px; +} +/* GridView-Item-Knoten heisst `child` (nicht `flowboxchild`). Die Default-Blau- + * Auswahl am child neutralisieren und stattdessen die Kachel im Akzent rahmen — + * dieselbe gerundete Akzent-Auswahl wie in der Listenansicht. */ +.tn-file-grid > child { + border-radius: 12px; + padding: 0; + background: none; +} +.tn-file-grid > child:hover .tn-file-tile { + background-color: rgba(120, 120, 128, 0.12); +} +.tn-file-grid > child:selected { + background: none; +} +.tn-file-grid > child:selected .tn-file-tile { + background-color: alpha(@accent_color, 0.16); +} +window.tn-dark .tn-file-grid > child:hover .tn-file-tile { + background-color: rgba(255, 255, 255, 0.06); +} +window.tn-dark .tn-file-grid > child:selected .tn-file-tile { + background-color: alpha(@accent_color, 0.24); +} +.tn-file-name { + font-size: 0.85rem; + color: #1d1d1f; +} +.tn-file-grid > child:selected .tn-file-name { + color: @accent_color; + font-weight: 600; +} +window.tn-dark .tn-file-name { + color: #f5f5f7; +} + +/* Kontextmenü (Rechtsklick) — kompaktes, flaches Popover. + * Über die Popover-Klasse gescopt, damit es die adw-gtk3-Popover-Regeln sicher + * schlägt (sonst werden die Buttons gross & klobig). */ +popover.tn-menu-popover > contents { + padding: 5px; + border-radius: 12px; + background-color: #ffffff; + border: 1px solid rgba(0, 0, 0, 0.08); + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18); +} +window.tn-dark popover.tn-menu-popover > contents { + background-color: #1f1f1f; + border-color: rgba(255, 255, 255, 0.08); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.50); +} +.tn-menu { + min-width: 178px; +} +popover.tn-menu-popover button.tn-menu-item { + min-height: 0; + padding: 6px 12px; + margin: 1px 0; + border-radius: 7px; + font-weight: 400; + font-size: 0.95rem; + background: transparent; + background-image: none; + box-shadow: none; + border: none; + color: #1d1d1f; +} +popover.tn-menu-popover button.tn-menu-item:hover { + background-color: alpha(@accent_color, 0.14); + color: @accent_color; +} +popover.tn-menu-popover button.tn-menu-item.destructive-action { + color: #ff3b30; + font-weight: 400; +} +popover.tn-menu-popover button.tn-menu-item.destructive-action:hover { + background-color: rgba(255, 59, 48, 0.12); + color: #ff3b30; +} +window.tn-dark popover.tn-menu-popover button.tn-menu-item { + color: #f5f5f7; +} +window.tn-dark popover.tn-menu-popover button.tn-menu-item.destructive-action { + color: #ff453a; +} +.tn-menu separator { + margin: 4px 6px; + min-height: 1px; + background-color: rgba(60, 60, 67, 0.14); +} +window.tn-dark .tn-menu separator { + background-color: rgba(255, 255, 255, 0.10); +} + +/* Listenansicht (ColumnView): ruhige Zeilen, Auswahl im Akzent. + * Die innere `listview`-Fläche (und Zeilen/Zellen) zeichnet sonst ihren eigenen + * hellen `view`-Hintergrund — transparent setzen, damit die dunkle Content- + * Fläche durchscheint. */ +.tn-file-list, +.tn-file-list > listview, +.tn-file-list > listview > row, +.tn-file-list > listview > row > cell { + background: transparent; + background-color: transparent; +} +.tn-file-list { + padding: 4px 10px 16px; +} +.tn-file-list > header { + background: transparent; +} +.tn-file-list > header > button { + background-color: transparent; +} +.tn-file-list > header button { + background: transparent; + box-shadow: none; + border: none; + font-size: 0.82rem; + font-weight: 600; + color: rgba(60, 60, 67, 0.55); +} +.tn-file-list row:hover { + background-color: rgba(120, 120, 128, 0.10); + border-radius: 8px; +} +.tn-file-list row:selected { + background-color: alpha(@accent_color, 0.16); + border-radius: 8px; +} +.tn-file-list row:selected label { + color: @accent_color; + font-weight: 600; +} +.tn-file-meta { + font-size: 0.85rem; + color: rgba(60, 60, 67, 0.6); +} +window.tn-dark .tn-file-list > header button { + color: rgba(235, 235, 245, 0.5); +} +window.tn-dark .tn-file-list row:hover { + background-color: rgba(255, 255, 255, 0.05); +} +window.tn-dark .tn-file-list row:selected { + background-color: alpha(@accent_color, 0.24); +} +window.tn-dark .tn-file-meta { + color: rgba(235, 235, 245, 0.5); +} + +/* Markierrahmen (Rubber-Band) im Akzent statt Adwaita-Blau. */ +.tn-file-grid > rubberband, +.tn-file-list > rubberband, +rubberband { + background-color: alpha(@accent_color, 0.18); + border: 1px solid alpha(@accent_color, 0.55); + border-radius: 3px; +} + +/* „+“ im Favoriten-Kopf der Sidebar. */ +.tn-fav-add { + min-width: 22px; + min-height: 22px; + padding: 0; + margin-right: 8px; + color: rgba(60, 60, 67, 0.5); +} +.tn-fav-add:hover { + color: @accent_color; + background-color: alpha(@accent_color, 0.12); +} +window.tn-dark .tn-fav-add { + color: rgba(235, 235, 245, 0.45); +} + +/* Statusleiste unten: Eintragszahl/Auswahl links, freier Speicher rechts. */ +.tn-statusbar { + padding: 4px 16px; + border-top: 1px solid rgba(60, 60, 67, 0.10); + min-height: 26px; +} +window.tn-dark .tn-statusbar { + border-top-color: rgba(255, 255, 255, 0.06); +} +.tn-status-text { + font-size: 0.82rem; + color: rgba(60, 60, 67, 0.6); +} +window.tn-dark .tn-status-text { + color: rgba(235, 235, 245, 0.5); +} + +/* Tab-Leiste (erscheint ab 2 Tabs). Aktiver Tab verschmilzt mit der Inhaltsfläche. */ +.tn-tabbar { + padding: 6px 12px 0; +} +.tn-tab { + padding: 4px 4px 5px 12px; + border-radius: 9px 9px 0 0; + background-color: rgba(120, 120, 128, 0.10); + min-width: 90px; +} +.tn-tab label { + font-size: 0.86rem; + color: rgba(60, 60, 67, 0.7); +} +.tn-tab-active { + background-color: #f5f5f7; +} +.tn-tab-active label { + color: #1d1d1f; + font-weight: 500; +} +.tn-tab-close { + min-width: 18px; + min-height: 18px; + padding: 0; + opacity: 0.55; +} +.tn-tab-close:hover { + opacity: 1; + background-color: rgba(120, 120, 128, 0.20); +} +window.tn-dark .tn-tab { + background-color: rgba(255, 255, 255, 0.05); +} +window.tn-dark .tn-tab label { + color: rgba(235, 235, 245, 0.6); +} +window.tn-dark .tn-tab-active { + background-color: #161616; +} +window.tn-dark .tn-tab-active label { + color: #f5f5f7; +} + +/* Eject-Knopf an Geräte-Zeilen in der Sidebar. */ +.tn-eject { + min-width: 24px; + min-height: 24px; + padding: 0; + margin-right: 6px; + opacity: 0.6; +} +.tn-eject:hover { + opacity: 1; + color: @accent_color; + background-color: alpha(@accent_color, 0.12); +} + +/* Quick Look — Leertaste-Vorschau über dem Inhalt. */ +.tn-preview-backdrop { + background-color: rgba(0, 0, 0, 0.55); +} +.tn-preview-card { + background-color: #f2f2f7; + border-radius: 16px; + border: 1px solid rgba(0, 0, 0, 0.10); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.50); + padding: 8px; + margin: 28px; +} +window.tn-dark .tn-preview-card { + background-color: #1c1c1c; + border-color: rgba(255, 255, 255, 0.08); +} +.tn-preview-header { + padding: 4px 6px 8px 12px; +} +.tn-preview-caption { + font-weight: 600; + font-size: 0.95rem; + color: #1d1d1f; +} +window.tn-dark .tn-preview-caption { + color: #f5f5f7; +} +.tn-preview { + background-color: #ffffff; + border-radius: 10px; +} +window.tn-dark .tn-preview { + background-color: #111111; +} +.tn-preview textview, +.tn-preview textview text { + background: transparent; + font-family: monospace; + font-size: 0.88rem; + padding: 6px; +} +window.tn-dark .tn-preview textview text { + color: rgba(235, 235, 245, 0.85); +} +.tn-preview-name { + font-size: 1.1rem; + font-weight: 600; +} +.tn-preview-meta { + opacity: 0.6; + font-size: 0.9rem; +} + +/* Batch-Rename-Dialog: Live-Vorschau „alt → neu". */ +.tn-rename-preview { + font-family: monospace; + font-size: 0.85rem; + padding: 8px 10px; + color: rgba(60, 60, 67, 0.75); +} +window.tn-dark .tn-rename-preview { + color: rgba(235, 235, 245, 0.7); +} +.tn-rename-scroll { + background-color: rgba(120, 120, 128, 0.10); + border-radius: 8px; +} + +/* Music-Sidebar schmal wie die Calendar-App (208px). Zwei-Klassen-Selektor + * schlägt .tn-sidebar (min-width 232) sicher. */ +.tn-sidebar.tn-music-pane { + min-width: 208px; +} +/* Sidebar „Playlists“-Kopf: Titel links, „+“-Button rechtsbündig. */ +.tn-playlists-header { + margin-right: 8px; +} +/* „+“ dezent: kein Hintergrund, klein, gedimmt — nur Hover/Akzent. */ +.tn-add-btn, +.tn-add-btn:hover, +.tn-add-btn:checked, +.tn-add-btn:active { + background: none; + background-color: transparent; + box-shadow: none; + border: none; + min-width: 20px; + min-height: 20px; + padding: 0; +} +.tn-add-btn { opacity: 0.55; } +.tn-add-btn:hover { opacity: 1; color: @accent_color; } diff --git a/tests/test_pages_native.py b/tests/test_pages_native.py new file mode 100644 index 0000000..96ec6b3 --- /dev/null +++ b/tests/test_pages_native.py @@ -0,0 +1,71 @@ +"""Smoke test for the native (libadwaita-free) UI backend. + +Under ``TANINUX_UI=native`` every registered page must ``build()`` without +tripping over a gap in the native widget shims (``ui/native.py``). The point is +to catch ``AttributeError`` / ``TypeError`` — a missing method or a wrong kwarg +on a shim — the kind of runtime crash that only surfaces when a page is opened. + +The test is deliberately tolerant of *environmental* failures: several pages +call system tools (pacman, nmcli, gsettings, …) inside ``build()``. When such a +call blows up we skip that page rather than fail — those are not shim gaps. + +Needs a display. If GTK cannot initialise (headless CI), the whole module is +skipped at import time. +""" +from __future__ import annotations + +import os + +# Must be set before taninux.gui.ui is imported so the native backend is chosen. +os.environ.setdefault("TANINUX_UI", "native") + +import pytest + +# --- GTK availability gate (module-level skip when there is no display) -------- +try: + import gi + + gi.require_version("Gtk", "4.0") + from gi.repository import Gtk + + _res = Gtk.init_check() + _gtk_ok = _res[0] if isinstance(_res, tuple) else bool(_res) +except Exception as exc: # noqa: BLE001 — any import/version error means no GTK + pytest.skip(f"GTK 4 unavailable: {exc}", allow_module_level=True) +else: + if not _gtk_ok: + pytest.skip("GTK could not initialise (no display)", allow_module_level=True) + +from taninux.gui import ui # noqa: E402 +from taninux.gui.pages import PAGES # noqa: E402 + + +@pytest.fixture(scope="module") +def window(): + """An off-screen GTK root so built pages have a real window to parent into. + + Deliberately never presented and never destroyed: realising/tearing down a + window here (while pages have spawned worker threads that post idle + callbacks) can crash GTK at interpreter shutdown. Leaving it for the OS to + reclaim keeps the smoke test stable. + """ + ui.ensure_css() + win = Gtk.Window() + ui.set_dark(win, False) + yield win + + +@pytest.mark.parametrize("page", PAGES, ids=lambda p: p.key) +def test_page_builds(page, window): + try: + widget = page.build() + except (AttributeError, TypeError): + # A shim gap (missing method / wrong kwarg) — exactly what we hunt for. + raise + except Exception as exc: # noqa: BLE001 — a system tool the sandbox lacks + pytest.skip(f"{page.key}: build() needs a live system resource: {exc!r}") + + assert widget is not None, f"{page.key}.build() returned None" + # Parent it (unrealised) so the native container measure paths are exercised. + holder = Gtk.Box() + holder.append(widget)