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,
|
- `camel.toml` `rebuilt_aur` list is stale (missing librewolf-bin, arch-update,
|
||||||
timeshift-autosnap, xdg-terminal-exec).
|
timeshift-autosnap, xdg-terminal-exec).
|
||||||
- Manual icon picker in `desktop.py` still lists all 5 `Tanin-*` themes (minor).
|
- 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:
|
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)
|
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).
|
"""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
|
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()
|
instead of displaying them in a scrollable in-app widget. LogConsole.clear() is
|
||||||
and collapsible_log() helpers are kept as no-ops so callers can be cleaned up
|
a no-op kept only for API stability (the file log is append-only).
|
||||||
incrementally, but they return None and must not be added to the page.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -41,11 +40,6 @@ class LogConsole:
|
|||||||
_write_log(text)
|
_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):
|
class RunnerCard(Gtk.Box):
|
||||||
"""Konsole + Action-Button, verdrahtet mit einem ProcessRunner."""
|
"""Konsole + Action-Button, verdrahtet mit einem ProcessRunner."""
|
||||||
|
|
||||||
@@ -87,9 +81,3 @@ class RunnerCard(Gtk.Box):
|
|||||||
_write_log("")
|
_write_log("")
|
||||||
_write_log(f"✔ done (exit {code})" if code == 0 else f"✘ exit {code}")
|
_write_log(f"✔ done (exit {code})" if code == 0 else f"✘ exit {code}")
|
||||||
self._button.set_sensitive(True)
|
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_size_request(200, -1)
|
||||||
scale.set_draw_value(False)
|
scale.set_draw_value(False)
|
||||||
scale.set_valign(Gtk.Align.CENTER)
|
scale.set_valign(Gtk.Align.CENTER)
|
||||||
scale.add_mark(100, Gtk.PositionType.BOTTOM, None)
|
|
||||||
|
|
||||||
mute_btn = Gtk.ToggleButton()
|
mute_btn = Gtk.ToggleButton()
|
||||||
mute_btn.add_css_class("flat")
|
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_size_request(220, -1)
|
||||||
scale.set_draw_value(False)
|
scale.set_draw_value(False)
|
||||||
scale.set_valign(Gtk.Align.CENTER)
|
scale.set_valign(Gtk.Align.CENTER)
|
||||||
scale.add_mark(100, Gtk.PositionType.BOTTOM, None) # 100%-Markierung
|
|
||||||
|
|
||||||
pct = Gtk.Label()
|
pct = Gtk.Label()
|
||||||
pct.add_css_class("dim-label")
|
pct.add_css_class("dim-label")
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ def build() -> Gtk.Widget:
|
|||||||
|
|
||||||
def _connect(net: network.WifiNet, saved: bool) -> None:
|
def _connect(net: network.WifiNet, saved: bool) -> None:
|
||||||
def do(pw: str) -> None:
|
def do(pw: str) -> None:
|
||||||
console.clear()
|
|
||||||
runner.run(network.connect_argv(net.ssid, pw))
|
runner.run(network.connect_argv(net.ssid, pw))
|
||||||
# Gesichert, aber schon gespeichert -> ohne Passwort-Dialog verbinden.
|
# Gesichert, aber schon gespeichert -> ohne Passwort-Dialog verbinden.
|
||||||
if net.security and not saved:
|
if net.security and not saved:
|
||||||
@@ -94,12 +93,10 @@ def build() -> Gtk.Widget:
|
|||||||
argv = network.disconnect_argv()
|
argv = network.disconnect_argv()
|
||||||
if not argv:
|
if not argv:
|
||||||
return
|
return
|
||||||
console.clear()
|
|
||||||
runner.run(argv)
|
runner.run(argv)
|
||||||
|
|
||||||
def _forget(net: network.WifiNet) -> None:
|
def _forget(net: network.WifiNet) -> None:
|
||||||
def do() -> None:
|
def do() -> None:
|
||||||
console.clear()
|
|
||||||
runner.run(network.forget_argv(net.ssid))
|
runner.run(network.forget_argv(net.ssid))
|
||||||
confirm.confirm(
|
confirm.confirm(
|
||||||
refresh,
|
refresh,
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ def build() -> Gtk.Widget:
|
|||||||
|
|
||||||
def _act(item: pkgsearch.Result, btn: Gtk.Button) -> None:
|
def _act(item: pkgsearch.Result, btn: Gtk.Button) -> None:
|
||||||
argv, priv = (pkgsearch.remove_argv if item.installed else pkgsearch.install_argv)(item)
|
argv, priv = (pkgsearch.remove_argv if item.installed else pkgsearch.install_argv)(item)
|
||||||
console.clear()
|
|
||||||
btn.set_sensitive(False)
|
btn.set_sensitive(False)
|
||||||
runner.run(argv, privileged=priv)
|
runner.run(argv, privileged=priv)
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ from pathlib import Path
|
|||||||
import gi
|
import gi
|
||||||
|
|
||||||
gi.require_version("Gtk", "4.0")
|
gi.require_version("Gtk", "4.0")
|
||||||
gi.require_version("Adw", "1")
|
from gi.repository import GLib, Gio, Gtk # noqa: E402
|
||||||
from gi.repository import Adw, GLib, Gio, Gtk # noqa: E402
|
|
||||||
from taninux.gui import ui
|
from taninux.gui import ui
|
||||||
|
|
||||||
from taninux.core import pacman
|
from taninux.core import pacman
|
||||||
@@ -239,14 +238,12 @@ def build() -> Gtk.Widget:
|
|||||||
# ── Actions ────────────────────────────────────────────────────────────
|
# ── Actions ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _run_privileged(argv: list[str]) -> None:
|
def _run_privileged(argv: list[str]) -> None:
|
||||||
console.clear()
|
|
||||||
runner.run(argv, privileged=True)
|
runner.run(argv, privileged=True)
|
||||||
|
|
||||||
def _run_winetricks(verb: str) -> None:
|
def _run_winetricks(verb: str) -> None:
|
||||||
prefix = _state["prefix"]
|
prefix = _state["prefix"]
|
||||||
if prefix is None:
|
if prefix is None:
|
||||||
return
|
return
|
||||||
console.clear()
|
|
||||||
runner.run(winecore.winetricks_argv(prefix, verb))
|
runner.run(winecore.winetricks_argv(prefix, verb))
|
||||||
|
|
||||||
def _pick_exe(prefix: Path) -> None:
|
def _pick_exe(prefix: Path) -> None:
|
||||||
@@ -277,13 +274,12 @@ def build() -> Gtk.Widget:
|
|||||||
if f is None:
|
if f is None:
|
||||||
return
|
return
|
||||||
exe = Path(f.get_path())
|
exe = Path(f.get_path())
|
||||||
console.clear()
|
|
||||||
runner.run(winecore.run_exe_argv(prefix, exe))
|
runner.run(winecore.run_exe_argv(prefix, exe))
|
||||||
|
|
||||||
dialog.open(page.get_root(), None, _done)
|
dialog.open(page.get_root(), None, _done)
|
||||||
|
|
||||||
def _new_prefix_dialog(_btn: Gtk.Widget) -> None:
|
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 = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||||
box.set_margin_top(12)
|
box.set_margin_top(12)
|
||||||
@@ -304,7 +300,7 @@ def build() -> Gtk.Widget:
|
|||||||
dlg.set_extra_child(box)
|
dlg.set_extra_child(box)
|
||||||
dlg.add_response("cancel", "Cancel")
|
dlg.add_response("cancel", "Cancel")
|
||||||
dlg.add_response("create", "Create")
|
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_default_response("create")
|
||||||
dlg.set_close_response("cancel")
|
dlg.set_close_response("cancel")
|
||||||
|
|
||||||
@@ -320,7 +316,6 @@ def build() -> Gtk.Widget:
|
|||||||
return
|
return
|
||||||
arch = "win32" if arch_combo.get_selected() == 1 else "win64"
|
arch = "win32" if arch_combo.get_selected() == 1 else "win64"
|
||||||
ppath = winecore.new_prefix_path(name)
|
ppath = winecore.new_prefix_path(name)
|
||||||
console.clear()
|
|
||||||
runner.run(winecore.create_prefix_argv(ppath, arch))
|
runner.run(winecore.create_prefix_argv(ppath, arch))
|
||||||
|
|
||||||
dlg.connect("response", _on_response)
|
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
|
# Akzent-Override + Swatch-Styling (Swatches sind statisch, aber billig
|
||||||
# mitzubauen). Swatch = runder Farbknopf, aktiver bekommt einen Ring.
|
# mitzubauen). Swatch = runder Farbknopf, aktiver bekommt einen Ring.
|
||||||
swatches = "\n".join(
|
swatches = "\n".join(
|
||||||
|
# Kein !important — GTK4 verwirft Deklarationen damit (siehe style_apple.css).
|
||||||
f"button.fuji-swatch.swatch-{a.key} {{"
|
f"button.fuji-swatch.swatch-{a.key} {{"
|
||||||
f" background-color: {a.hex} !important;"
|
f" background-color: {a.hex};"
|
||||||
f" background: {a.hex} !important;"
|
f" background: {a.hex};"
|
||||||
f" border: none !important; }}"
|
f" border: none; }}"
|
||||||
for a in FUJI_ACCENTS
|
for a in FUJI_ACCENTS
|
||||||
)
|
)
|
||||||
return f"""
|
return f"""
|
||||||
@@ -67,6 +68,15 @@ def _build_css(acc: Accent) -> bytes:
|
|||||||
@define-color accent_fg_color {fg};
|
@define-color accent_fg_color {fg};
|
||||||
@define-color accent_color {acc.hex};
|
@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 */
|
/* pill controls — all buttons & dropdowns are stadium pills */
|
||||||
button, menubutton > button, dropdown, dropdown > button,
|
button, menubutton > button, dropdown, dropdown > button,
|
||||||
combobox button, splitbutton > button, entry, spinbutton,
|
combobox button, splitbutton > button, entry, spinbutton,
|
||||||
@@ -135,12 +145,15 @@ def apply_persisted() -> None:
|
|||||||
# existing `@import "libadwaita.css"`; GTK3 rides on adw-gtk3.
|
# existing `@import "libadwaita.css"`; GTK3 rides on adw-gtk3.
|
||||||
|
|
||||||
# Monochrome surfaces derived from the two base values (#0f0f0f / #f0f0f0).
|
# Monochrome surfaces derived from the two base values (#0f0f0f / #f0f0f0).
|
||||||
_DARK_SURF = dict(bg="#0f0f0f", bg_alt="#191919", bg_hi="#262626", view="#141414",
|
# Töne 1:1 mit tanin-libadwaita/gtk-4.0/{dark,light}.css (= eww-Palette).
|
||||||
card="#191919", headerbar="#0f0f0f", fg="#f0f0f0",
|
# Shell-Fläche einheitlich: window/headerbar/sidebar alle #0f0f0f, nur Cards
|
||||||
border="rgba(240,240,240,0.10)")
|
# heben sich mit bg_alt (#191919) ab.
|
||||||
_LIGHT_SURF = dict(bg="#f0f0f0", bg_alt="#e4e4e4", bg_hi="#d6d6d6", view="#fafafa",
|
_DARK_SURF = dict(bg="#0f0f0f", bg_alt="#191919", bg_hi="#242424", view="#141414",
|
||||||
card="#ffffff", headerbar="#e8e8e8", fg="#0f0f0f",
|
card="#191919", headerbar="#0f0f0f", sidebar="#0f0f0f",
|
||||||
border="rgba(15,15,15,0.12)")
|
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 = (
|
_PILLS_GTK4 = (
|
||||||
"button, menubutton > button, dropdown, dropdown > button, combobox button,\n"
|
"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 dialog_fg_color {p['fg']};
|
||||||
@define-color popover_bg_color {p['bg_alt']};
|
@define-color popover_bg_color {p['bg_alt']};
|
||||||
@define-color popover_fg_color {p['fg']};
|
@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']};
|
@define-color sidebar_fg_color {p['fg']};
|
||||||
{_PILLS_GTK4}"""
|
{_PILLS_GTK4}"""
|
||||||
|
|
||||||
@@ -302,7 +315,7 @@ placessidebar,
|
|||||||
placessidebar > viewport,
|
placessidebar > viewport,
|
||||||
.sidebar > viewport > contents,
|
.sidebar > viewport > contents,
|
||||||
placessidebar > viewport > contents {{
|
placessidebar > viewport > contents {{
|
||||||
background-color: {p['bg_alt']};
|
background-color: {p['sidebar']};
|
||||||
color: {p['fg']};
|
color: {p['fg']};
|
||||||
}}
|
}}
|
||||||
.sidebar,
|
.sidebar,
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ EIN Schalter:
|
|||||||
TANINUX_UI=adw → libadwaita (Adw.PreferencesPage/Group/ActionRow)
|
TANINUX_UI=adw → libadwaita (Adw.PreferencesPage/Group/ActionRow)
|
||||||
TANINUX_UI=native → eigene GTK4-Widgets mit Apple-Optik (ui/native.py)
|
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
|
~430 Adw-Zeilen-/Gruppen-Instanzen über 34 Dateien laufen ab jetzt durch EINE
|
||||||
Stelle. Damit wird „eigene Design-Sprache statt libadwaita“ ein Umbau hier
|
Stelle. Damit wird „eigene Design-Sprache statt libadwaita“ ein Umbau hier
|
||||||
statt überall — und Seite für Seite migrierbar, weil beide Backends dieselbe
|
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("Gdk", "4.0")
|
||||||
gi.require_version("Gtk", "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.
|
# Content-Spalte: responsiv. Wächst mit dem Fenster, aber nie schmaler als
|
||||||
CLAMP_WIDTH = 640
|
# 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
|
_css_loaded = False
|
||||||
|
|
||||||
@@ -41,8 +46,12 @@ def ensure_css() -> None:
|
|||||||
|
|
||||||
provider = Gtk.CssProvider()
|
provider = Gtk.CssProvider()
|
||||||
provider.load_from_path(str(Path(__file__).with_name("style_apple.css")))
|
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(
|
Gtk.StyleContext.add_provider_for_display(
|
||||||
display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER
|
display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1
|
||||||
)
|
)
|
||||||
_css_loaded = True
|
_css_loaded = True
|
||||||
|
|
||||||
@@ -64,7 +73,8 @@ class Row(Gtk.ListBoxRow):
|
|||||||
def __init__(self, title: str = "", subtitle: str = "") -> None:
|
def __init__(self, title: str = "", subtitle: str = "") -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.set_activatable(True)
|
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 = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||||
box.add_css_class("tn-row")
|
box.add_css_class("tn-row")
|
||||||
@@ -91,6 +101,17 @@ class Row(Gtk.ListBoxRow):
|
|||||||
self._suffix.set_valign(Gtk.Align.CENTER)
|
self._suffix.set_valign(Gtk.Align.CENTER)
|
||||||
box.append(self._suffix)
|
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 ------------------------------------
|
# -- Adw.ActionRow-kompatible API ------------------------------------
|
||||||
def set_title(self, title: str) -> None:
|
def set_title(self, title: str) -> None:
|
||||||
self._title.set_label(title)
|
self._title.set_label(title)
|
||||||
@@ -99,6 +120,15 @@ class Row(Gtk.ListBoxRow):
|
|||||||
self._subtitle.set_label(subtitle or "")
|
self._subtitle.set_label(subtitle or "")
|
||||||
self._subtitle.set_visible(bool(subtitle))
|
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:
|
def add_prefix(self, widget: Gtk.Widget) -> None:
|
||||||
self._prefix.append(widget)
|
self._prefix.append(widget)
|
||||||
|
|
||||||
@@ -286,6 +316,51 @@ class Group(Gtk.Box):
|
|||||||
self._header.set_visible(True)
|
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):
|
class Page(Gtk.ScrolledWindow):
|
||||||
"""Adw.PreferencesPage-Ersatz: scrollende, zentrierte Gruppen-Spalte."""
|
"""Adw.PreferencesPage-Ersatz: scrollende, zentrierte Gruppen-Spalte."""
|
||||||
|
|
||||||
@@ -295,13 +370,11 @@ class Page(Gtk.ScrolledWindow):
|
|||||||
self.add_css_class("tn-page")
|
self.add_css_class("tn-page")
|
||||||
|
|
||||||
self._box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=28)
|
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_top(28)
|
||||||
self._box.set_margin_bottom(28)
|
self._box.set_margin_bottom(28)
|
||||||
self._box.set_margin_start(18)
|
self._box.set_margin_start(18)
|
||||||
self._box.set_margin_end(18)
|
self._box.set_margin_end(18)
|
||||||
self.set_child(self._box)
|
self.set_child(_Clamp(self._box))
|
||||||
|
|
||||||
# -- Adw.PreferencesPage-kompatible API ------------------------------
|
# -- Adw.PreferencesPage-kompatible API ------------------------------
|
||||||
def add(self, group: Gtk.Widget) -> None:
|
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 = Gtk.Window(modal=True, resizable=False, decorated=False)
|
||||||
dlg.add_css_class("tn-alert-dialog")
|
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:
|
if win:
|
||||||
dlg.set_transient_for(win)
|
dlg.set_transient_for(win)
|
||||||
dlg.set_default_size(380, -1)
|
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 = Gtk.Stack(hexpand=True, vexpand=True)
|
||||||
self._stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
|
self._stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
|
||||||
|
|
||||||
# Content-Titel lebt in der Fenster-HeaderBar (statt Adw.WindowTitle).
|
# Kein Topbar/HeaderBar (wie die Music-App): der Seitentitel lebt gross
|
||||||
self._title = Gtk.Label(label=title)
|
# oben im Content, der App-Name als Brand oben in der Sidebar.
|
||||||
self._title.add_css_class("tn-content-title")
|
|
||||||
header = Gtk.HeaderBar()
|
|
||||||
header.set_title_widget(self._title)
|
|
||||||
self.set_titlebar(header)
|
|
||||||
|
|
||||||
split = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
split = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||||
split.add_css_class("tn-shell")
|
split.add_css_class("tn-shell")
|
||||||
split.append(self._build_sidebar())
|
split.append(self._build_sidebar())
|
||||||
sep = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)
|
sep = Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)
|
||||||
split.append(sep)
|
split.append(sep)
|
||||||
split.append(self._stack)
|
split.append(self._build_content())
|
||||||
self.set_child(split)
|
self.set_child(split)
|
||||||
|
self._install_search_shortcut()
|
||||||
|
|
||||||
if self._pages:
|
if self._pages:
|
||||||
self._listbox.select_row(self._rows[self._pages[0].key])
|
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_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
|
||||||
scroller.set_child(self._listbox)
|
scroller.set_child(self._listbox)
|
||||||
|
|
||||||
search = Gtk.SearchEntry()
|
# Suche ist kein Dauer-Feld: eingeklappte SearchBar, erscheint per Ctrl+F
|
||||||
search.set_placeholder_text("Search settings")
|
# (Esc schliesst). Bewusst ohne Pill-Look (keine tn-search-Klasse).
|
||||||
search.add_css_class("tn-search")
|
self._search = Gtk.SearchEntry()
|
||||||
search.set_margin_top(8)
|
self._search.set_placeholder_text("Search settings")
|
||||||
search.set_margin_bottom(4)
|
self._search.connect("search-changed", self._on_search)
|
||||||
search.set_margin_start(8)
|
self._search.connect("stop-search", lambda *_: self._close_search())
|
||||||
search.set_margin_end(8)
|
|
||||||
search.connect("search-changed", self._on_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 = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||||
box.add_css_class("tn-sidebar")
|
box.add_css_class("tn-sidebar")
|
||||||
box.append(search)
|
box.append(brand)
|
||||||
|
box.append(self._searchbar)
|
||||||
box.append(scroller)
|
box.append(scroller)
|
||||||
return box
|
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:
|
def _header_row(self, title: str) -> Gtk.ListBoxRow:
|
||||||
row = Gtk.ListBoxRow()
|
row = Gtk.ListBoxRow()
|
||||||
row.set_selectable(False)
|
row.set_selectable(False)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
/* --------------------------------------------------------- Window / Fläche */
|
/* --------------------------------------------------------- Window / Fläche */
|
||||||
.tn-page {
|
.tn-page {
|
||||||
background-color: #f6f6f6;
|
background-color: #f5f5f7;
|
||||||
}
|
}
|
||||||
.tn-page,
|
.tn-page,
|
||||||
.tn-title,
|
.tn-title,
|
||||||
@@ -103,6 +103,39 @@
|
|||||||
.tn-row button.destructive-action:hover {
|
.tn-row button.destructive-action:hover {
|
||||||
background-color: rgba(255, 59, 48, 0.10);
|
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). */
|
/* Entry/Spin rahmenlos & rechtsbündig, Wert in Sekundärfarbe (wie iOS). */
|
||||||
.tn-row entry,
|
.tn-row entry,
|
||||||
@@ -162,16 +195,18 @@
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
min-width: 0;
|
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 {
|
.tn-sidebar {
|
||||||
background-color: #ebebeb !important;
|
background-color: #f3f1ec;
|
||||||
border-right: 1px solid rgba(0, 0, 0, 0.07);
|
border-right: 1px solid rgba(0, 0, 0, 0.07);
|
||||||
min-width: 232px;
|
min-width: 232px;
|
||||||
}
|
}
|
||||||
headerbar {
|
headerbar {
|
||||||
background-color: #ebebeb !important;
|
background-color: #f3f1ec;
|
||||||
background: #ebebeb !important;
|
background: #f3f1ec;
|
||||||
border-bottom: 1px solid rgba(0, 0, 0, 0.07) !important;
|
border-bottom: 1px solid rgba(0, 0, 0, 0.07);
|
||||||
box-shadow: none !important;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
.tn-search {
|
.tn-search {
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -216,8 +251,27 @@ headerbar {
|
|||||||
color: rgba(60, 60, 67, 0.5);
|
color: rgba(60, 60, 67, 0.5);
|
||||||
margin: 12px 12px 2px;
|
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 {
|
.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. */
|
/* 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 */
|
/* ------------------------------------------- Suggested-action + Progress */
|
||||||
/* GTK4 Adwaita nutzt @blue_3 für suggested-action; wir überschreiben mit dem
|
/* 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,
|
||||||
button.suggested-action:not(:disabled) {
|
button.suggested-action:not(:disabled) {
|
||||||
background-color: @accent_color !important;
|
background-color: @accent_color;
|
||||||
background: @accent_color !important;
|
background: @accent_color;
|
||||||
color: @accent_fg_color !important;
|
color: @accent_fg_color;
|
||||||
box-shadow: none !important;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
button.suggested-action:hover {
|
button.suggested-action:hover {
|
||||||
background-color: shade(@accent_color, 0.92) !important;
|
background-color: shade(@accent_color, 0.92);
|
||||||
background: shade(@accent_color, 0.92) !important;
|
background: shade(@accent_color, 0.92);
|
||||||
}
|
}
|
||||||
button.suggested-action:active {
|
button.suggested-action:active {
|
||||||
background-color: shade(@accent_color, 0.85) !important;
|
background-color: shade(@accent_color, 0.85);
|
||||||
background: shade(@accent_color, 0.85) !important;
|
background: shade(@accent_color, 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Progress-Bars (Storage, Memory, CPU…) — Fuji-Akzent statt Adwaita-Blau */
|
/* Progress-Bars (Storage, Memory, CPU…) — Fuji-Akzent statt Adwaita-Blau */
|
||||||
progress, trough > progress, progressbar > trough > progress {
|
progress, trough > progress, progressbar > trough > progress {
|
||||||
background-color: @accent_color !important;
|
background-color: @accent_color;
|
||||||
background: @accent_color !important;
|
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 */
|
/* =================================================================== DUNKEL */
|
||||||
window.tn-dark .tn-page {
|
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 {
|
window.tn-dark headerbar {
|
||||||
background-color: #0f0f0f !important;
|
background-color: #0f0f0f;
|
||||||
background: #0f0f0f !important;
|
background: #0f0f0f;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05) !important;
|
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
box-shadow: none !important;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
window.tn-dark .tn-sidebar {
|
window.tn-dark .tn-sidebar {
|
||||||
background-color: #0f0f0f !important;
|
background-color: #0f0f0f;
|
||||||
border-right-color: rgba(255, 255, 255, 0.06);
|
border-right-color: rgba(255, 255, 255, 0.06);
|
||||||
}
|
}
|
||||||
window.tn-dark .tn-search {
|
window.tn-dark .tn-search {
|
||||||
@@ -309,7 +410,7 @@ window.tn-dark .tn-nav-header {
|
|||||||
color: rgba(235, 235, 245, 0.45);
|
color: rgba(235, 235, 245, 0.45);
|
||||||
}
|
}
|
||||||
window.tn-dark .tn-card {
|
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);
|
border-color: rgba(255, 255, 255, 0.07);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 2px 6px rgba(0, 0, 0, 0.40),
|
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 */
|
inset 0 1px 0 rgba(255, 255, 255, 0.09); /* Glasrand */
|
||||||
}
|
}
|
||||||
window.tn-dark .card {
|
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);
|
border-color: rgba(255, 255, 255, 0.07);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 2px 6px rgba(0, 0, 0, 0.40),
|
0 2px 6px rgba(0, 0, 0, 0.40),
|
||||||
@@ -431,7 +532,7 @@ window.tn-dark .tn-expander-chevron {
|
|||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
window.tn-dark .tn-alert-box {
|
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);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
|
||||||
}
|
}
|
||||||
window.tn-dark .tn-alert-heading {
|
window.tn-dark .tn-alert-heading {
|
||||||
@@ -440,3 +541,577 @@ window.tn-dark .tn-alert-heading {
|
|||||||
window.tn-dark .tn-alert-body {
|
window.tn-dark .tn-alert-body {
|
||||||
color: rgba(235, 235, 245, 0.65);
|
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