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 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 21:21:52 +02:00
parent e64e3d69fe
commit 30899de5a3
13 changed files with 1058 additions and 94 deletions
+114
View File
@@ -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.
+1 -1
View File
@@ -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)
+2 -14
View File
@@ -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
-2
View File
@@ -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")
-3
View File
@@ -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,
-1
View File
@@ -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)
+3 -8
View File
@@ -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)
+24 -11
View File
@@ -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,
+2 -1
View File
@@ -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
+85 -8
View File
@@ -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)
+53 -17
View File
@@ -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("<Control>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)
+703 -28
View File
@@ -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; }
+71
View File
@@ -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)