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:
+114
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user