From 8fb8efcbbd5930622cad83e6219ffb2f63df1aa3 Mon Sep 17 00:00:00 2001 From: Karim Date: Thu, 2 Jul 2026 21:22:20 +0200 Subject: [PATCH] packaging/iso: uninstall safety, fail-closed AUR updates, no sshd on live ISO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tanin-greet.install: post_remove restores the previous greetd config — removing the package no longer leaves greetd pointing at a dead binary - aur-autoupdate: skip packages when the AUR RPC is unreachable instead of building blind (fail closed) - build-tanin-repo: per-package makepkg logs instead of >/dev/null - live ISO: drop sshd.service enablement (root has an empty password on the live medium); encoded in bootstrap-profile.sh so it survives re-bootstrap - gtklock config: de-personalize hardcoded /home/karim path, mark as not-yet-packaged reference - tanin-desktop PKGBUILD: correct stale AUR-vs-extra comments (gtklock, awww, cliphist are in extra), document the eww-git provides=eww contract - docs/distribution.md: mark SigLevel TrustAll as temporary/insecure, add repo-signing TODO (repo-add -s, SigLevel Required), tick done roadmap items - tanin-calendar + niri-config.kdl: pending session work Co-Authored-By: Claude Fable 5 --- docs/distribution.md | 28 +- iso/bootstrap-profile.sh | 7 + .../multi-user.target.wants/sshd.service | 1 - packaging/aur-autoupdate.sh | 5 +- packaging/build-tanin-repo.sh | 14 +- packaging/gtklock/config.ini | 12 +- packaging/tanin-calendar/PKGBUILD | 18 +- .../ch.gabrielevarano.TaninCalendar.desktop | 2 +- packaging/tanin-calendar/tanin-calendar | 1528 +++++++++-------- packaging/tanin-calendar/tanin-calendar.css | 827 +++++---- packaging/tanin-desktop/PKGBUILD | 10 +- packaging/tanin-eww/niri-config.kdl | 20 +- packaging/tanin-greet/tanin-greet.install | 18 + 13 files changed, 1432 insertions(+), 1058 deletions(-) delete mode 120000 iso/build-profile/airootfs/etc/systemd/system/multi-user.target.wants/sshd.service mode change 100644 => 100755 packaging/tanin-calendar/tanin-calendar diff --git a/docs/distribution.md b/docs/distribution.md index c237e45..5948801 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -27,8 +27,8 @@ AUR/Binär-Pakete dürfen **nicht** nach `/home` schreiben. Daher: ## Phasen -1. **Fundament** — `packaging/taninux/PKGBUILD` (fertig), LICENSE wählen, - `cd packaging/taninux && makepkg -si` lokal testen. +1. **Fundament** — `packaging/taninux/PKGBUILD` (fertig), LICENSE (✅ da, + GPL-3.0-or-later), `cd packaging/taninux && makepkg -si` lokal testen. 2. **Meta + Config** — `tanin-desktop` (Skelett da), `tanin-eww`, `tanin-setup`. 3. **Eigenes Repo `[tanin]`**. Hosts: - **Code** → `git.openbureau.ch/karim/taninux` (PKGBUILD-`source`, woher `makepkg` klont) @@ -47,11 +47,29 @@ AUR/Binär-Pakete dürfen **nicht** nach `/home` schreiben. Daher: `pacman.conf`-Snippet für User: ```ini [tanin] - SigLevel = Optional TrustAll # später: Required + Key importieren + # TEMPORÄR + UNSICHER: TrustAll akzeptiert JEDES Paket ohne Signaturprüfung. + # Nur zum Bootstrappen, solange die DB noch nicht signiert ist. Sobald der + # Packager-Key steht -> auf "Required DatabaseOptional" umstellen (siehe unten). + SigLevel = Optional TrustAll Server = https://taninux.kgva.ch/$arch ``` -4. **Calamares / ISO** — `archiso`-Profil mit `[tanin]` vorkonfiguriert + - Calamares `packages`-Modul, das `tanin-desktop` installiert. + + #### Repo-Signing (TODO) + + Zielzustand (bevor das Repo öffentlich empfohlen wird): + 1. Packager-GPG-Key erzeugen (`gpg --full-gen-key`) und öffentlich ablegen + (z. B. `taninux.kgva.ch/tanin.gpg`), damit User ihn importieren können. + 2. DB **signiert** bauen: `repo-add -s tanin.db.tar.zst *.pkg.tar.zst` + (`-s` signiert DB + jedes hinzugefügte Paket mit dem Packager-Key). + 3. Key beim User installieren (`pacman-key --add`, `pacman-key --lsign-key`) + und dann in `pacman.conf` scharfschalten: + ```ini + SigLevel = Required DatabaseOptional + ``` + → jedes Paket muss signiert sein; die DB-Signatur ist optional. +4. **Calamares / ISO** ✅ — `archiso`-Profil mit `[tanin]` vorkonfiguriert + + Calamares `packages`-Modul, das `tanin-desktop` installiert + (`iso/build-profile/`, gebaute ISO in `iso/out/`). ## Dark Mode / Portal (Session-Fix) diff --git a/iso/bootstrap-profile.sh b/iso/bootstrap-profile.sh index f8b1ea8..4debe72 100755 --- a/iso/bootstrap-profile.sh +++ b/iso/bootstrap-profile.sh @@ -38,6 +38,13 @@ install -m644 "$HERE/overrides/profiledef.sh" "$PROFILE/profiledef.sh" install -m644 "$HERE/overrides/pacman.conf" "$PROFILE/pacman.conf" cp -rT "$HERE/overrides/airootfs" "$PROFILE/airootfs" +echo "==> security: do NOT enable sshd on the live ISO" +# releng ships root with an empty password AND enables sshd (PermitRootLogin yes) +# -> open remote root on any network the live ISO joins. We don't need sshd for +# the installer, so drop the wants-symlink. This is re-applied on every bootstrap +# because build-profile is regenerated from releng above (authoritative fix). +rm -f "$PROFILE/airootfs/etc/systemd/system/multi-user.target.wants/sshd.service" + echo "==> profile ready: $PROFILE" WORK=/tmp/tanin-work if [ "${1:-}" = "--build" ]; then diff --git a/iso/build-profile/airootfs/etc/systemd/system/multi-user.target.wants/sshd.service b/iso/build-profile/airootfs/etc/systemd/system/multi-user.target.wants/sshd.service deleted file mode 120000 index d21ebd9..0000000 --- a/iso/build-profile/airootfs/etc/systemd/system/multi-user.target.wants/sshd.service +++ /dev/null @@ -1 +0,0 @@ -/usr/lib/systemd/system/sshd.service \ No newline at end of file diff --git a/packaging/aur-autoupdate.sh b/packaging/aur-autoupdate.sh index 7cebd35..b021457 100755 --- a/packaging/aur-autoupdate.sh +++ b/packaging/aur-autoupdate.sh @@ -67,7 +67,10 @@ should_build() { local p="$1" [[ "$p" == *-git ]] && return 0 local rpc; rpc="$(aur_version "$p")" - [ -z "$rpc" ] && return 0 # RPC down → build to be safe + if [ -z "$rpc" ]; then # RPC unreachable → fail closed + log "WARNING: AUR RPC unreachable for $p — skipping (won't build blind)" + return 1 + fi local last; last="$(cat "$CACHE/$p.ver" 2>/dev/null || echo)" [ "$rpc" != "$last" ] } diff --git a/packaging/build-tanin-repo.sh b/packaging/build-tanin-repo.sh index a36f206..347af21 100755 --- a/packaging/build-tanin-repo.sh +++ b/packaging/build-tanin-repo.sh @@ -13,7 +13,8 @@ set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" OUT="${TANIN_REPO_DIR:-$HOME/projects/tanin-repo}" DB="$OUT/tanin.db.tar.zst" -mkdir -p "$OUT" +LOGDIR="$OUT/logs" +mkdir -p "$OUT" "$LOGDIR" # tanin-icons builds from the installed Adwaita theme -> needs librsvg + python. OWN_SIMPLE=(tanin-greet tanin-libadwaita tanin-setup tanin-eww tanin-icons tanin-desktop) @@ -21,12 +22,14 @@ OWN_BUILD=(taninux) AUR_REBUILD=(eww-git tiramisu-git waypaper calamares librewolf-bin arch-update timeshift-autosnap xdg-terminal-exec paru) +# makepkg output goes to a per-package log (console stays quiet); inspect the log +# for warnings even on success. Log path is echoed so it's easy to find. build() { - local p="$1" - echo "==> building $p" - ( cd "$HERE/$p" && makepkg -d -f --noconfirm >/dev/null 2>&1 ) \ + local p="$1" log="$LOGDIR/$p.log" + echo "==> building $p (log: $log)" + ( cd "$HERE/$p" && makepkg -d -f --noconfirm ) >"$log" 2>&1 \ && cp "$HERE/$p/"*.pkg.tar.zst "$OUT/" 2>/dev/null \ - && echo " ok" || { echo " FAILED ($p)"; return 1; } + && echo " ok" || { echo " FAILED ($p) — see $log"; return 1; } } for p in "${OWN_SIMPLE[@]}"; do build "$p"; done @@ -39,6 +42,7 @@ for p in "${OWN_BUILD[@]}"; do fi done +echo "** build logs (incl. makepkg warnings) in: $LOGDIR" echo "** AUR rebuilds TODO (not in official repos): ${AUR_REBUILD[*]}" echo " build each with paru/makepkg and copy the .pkg.tar.zst into $OUT, then re-run." diff --git a/packaging/gtklock/config.ini b/packaging/gtklock/config.ini index 61ee5e6..b39f9a6 100644 --- a/packaging/gtklock/config.ini +++ b/packaging/gtklock/config.ini @@ -1,8 +1,16 @@ # gtklock — TANINUX lockscreen config (Fuji greeter look). # glib keyfile format. gtklock is launched as: gtklock -d -c +# +# NOTE: this file is NOT yet wired into any package — it is a reference/template. +# Nothing installs or reads it at the moment (see the tanin-desktop deps: gtklock +# is pulled from extra, but no TANINUX package ships this config). [main] -# stylesheet + wallpaper-as-css-background live next to this file -style=/home/karim/.config/gtklock/gtklock.css +# stylesheet + wallpaper-as-css-background live next to this file. +# gtklock's keyfile does NOT expand ~ or $HOME reliably, so `style=` needs an +# absolute path. Point it at the actual user's gtklock.css, e.g. +# style=/home//.config/gtklock/gtklock.css +# (left commented on purpose — a hardcoded /home/ would only work for one user). +#style=/home//.config/gtklock/gtklock.css # modules (system .so, suffix optional): user avatar+name, and power buttons modules=userinfo;powerbar # built-in clock — time over date, like the greeter's centre clock diff --git a/packaging/tanin-calendar/PKGBUILD b/packaging/tanin-calendar/PKGBUILD index ef8513a..fec8de7 100644 --- a/packaging/tanin-calendar/PKGBUILD +++ b/packaging/tanin-calendar/PKGBUILD @@ -1,20 +1,22 @@ # Maintainer: Karim # -# tanin-calendar — TANINUX calendar app (GTK4 / libadwaita). A detailed agenda -# over your calendars, read straight from Evolution Data Server (the same -# backend GNOME Online Accounts feeds). Inherits the Fuji monochrome look from -# tanin-libadwaita. German UI. +# tanin-calendar — TANINUX calendar app (native GTK4, libadwaita-free). A +# sidebar + month/week/day calendar over your calendars, read straight from +# Evolution Data Server (the same backend GNOME Online Accounts feeds). Uses +# the TANINUX design language (Iosevka chrome, Fuji accent) and tracks the live +# accent via the global named colours. English UI, translatable via gettext. pkgname=tanin-calendar pkgver=0.1.0 pkgrel=1 -pkgdesc="TANINUX calendar — GTK4/libadwaita agenda over Evolution Data Server (GNOME Online Accounts)" +pkgdesc="TANINUX calendar — native GTK4 agenda over Evolution Data Server (GNOME Online Accounts)" arch=('any') url="https://taninux.kgva.ch" license=('GPL-3.0-or-later') -depends=('python' 'python-gobject' 'gtk4' 'libadwaita' - 'evolution-data-server' 'adwaita-icon-theme') +depends=('python' 'python-gobject' 'gtk4' + 'evolution-data-server' 'adwaita-icon-theme' + 'ttc-iosevka-aile') optdepends=('gnome-online-accounts: add Google/Nextcloud/CalDAV calendars' - 'tanin-libadwaita: Fuji monochrome theming for the app') + 'taninux: live Fuji accent + light/dark theming for the app') package() { install -Dm755 "$startdir/tanin-calendar" \ diff --git a/packaging/tanin-calendar/ch.gabrielevarano.TaninCalendar.desktop b/packaging/tanin-calendar/ch.gabrielevarano.TaninCalendar.desktop index cfc41c2..1a1f537 100644 --- a/packaging/tanin-calendar/ch.gabrielevarano.TaninCalendar.desktop +++ b/packaging/tanin-calendar/ch.gabrielevarano.TaninCalendar.desktop @@ -2,7 +2,7 @@ Type=Application Name=Calendar Name[de]=Kalender -Comment=Termine aus deinen Kalendern (Evolution Data Server) +Comment=Events from your calendars (Evolution Data Server) Comment[de]=Termine aus deinen Kalendern (Evolution Data Server) Exec=tanin-calendar Icon=x-office-calendar diff --git a/packaging/tanin-calendar/tanin-calendar b/packaging/tanin-calendar/tanin-calendar old mode 100644 new mode 100755 index cc4b909..334098c --- a/packaging/tanin-calendar/tanin-calendar +++ b/packaging/tanin-calendar/tanin-calendar @@ -1,43 +1,62 @@ #!/usr/bin/env python3 -"""tanin-calendar — TANINUX calendar (GTK4 / libadwaita / PyGObject). +"""tanin-calendar — TANINUX calendar (GTK4 / PyGObject, libadwaita-free). -A GNOME/Apple-Calendar-style MONTH GRID over your calendars. It reads events -straight from Evolution Data Server (EDS) — the same backend GNOME Online -Accounts feeds — so any calendar you've added in GNOME Online Accounts (Google, -Nextcloud, local …) shows up here automatically. +A native TANINUX calendar in the same Apple-flavoured design language as the +Music app and the Settings hub: a SIDEBAR on the left (brand, "New event", +a live mini-month navigator and a per-calendar show/hide list) and the CONTENT +on the right (a masthead with the month + year, a Day · Week · Month switcher +and prev/next/Today, over the actual view). -View: - • Header bar: title = current month + year ("June 2026"), prev/next month - nav, and a "Today" button that jumps back to the current month. - • A month grid: a weekday header row (Mon…Sun, week starts Monday) and a - 6×7 grid of day cells. Each cell shows its day number, dims days outside the - current month, highlights today, and lists the day's events as small - coloured chips (up to ~3, then a muted "+N more"). Clicking an event chip - opens a detail dialog; clicking a day cell opens a popover listing all that - day's events. +It reads events straight from Evolution Data Server (EDS) — the same backend +GNOME Online Accounts feeds — so any calendar you've added there (Google, +Nextcloud, local …) shows up automatically. -Theming inherits from tanin-libadwaita (Fuji monochrome). A stylesheet ships -alongside for the grid, cells and event chips; the rest is stock libadwaita. +Views: + • Month — a 6×7 grid of day cells with small coloured event chips. + • Week / Day — a scrolling hour time-grid with absolutely-placed event + blocks, an all-day strip, a now-line and drag-to-create. -If EDS isn't available (typelibs not installed), the window shows an -Adw.StatusPage instead of crashing — install `evolution-data-server`. +Theming is libadwaita-free and self-contained: the stylesheet uses the global +TANINUX named colours (@accent_color, @window_bg_color, …) so the calendar +tracks the live Fuji accent and light/dark exactly like the rest of TANINUX. + +If EDS isn't available (typelibs not installed), the window shows a native +status page instead of crashing — install `evolution-data-server`. Env overrides (handy for previewing from the repo): TANIN_CAL_CSS path to the stylesheet (default /usr/share/tanin/calendar/tanin-calendar.css) """ +import gettext import gi import os import sys import threading gi.require_version("Gtk", "4.0") -gi.require_version("Adw", "1") -from gi.repository import Gtk, Adw, GLib, Gdk, Gio, Pango # noqa: E402 +from gi.repository import Gtk, GLib, Gdk, Gio, Pango # noqa: E402 + +# --------------------------------------------------------------------------- # +# Localisation. The UI source language is English; every user-facing string is +# wrapped in _() so translations can be added LATER without touching the code — +# drop a compiled catalogue at +# //LC_MESSAGES/tanin-calendar.mo +# (localedir defaults to /usr/share/locale; override with TANIN_CAL_LOCALEDIR). +# Generate the template with e.g. xgettext -L Python -k_ -o tanin-calendar.pot +# tanin-calendar then translate per language. Without a catalogue the English +# source strings are used (fallback=True). +# --------------------------------------------------------------------------- # +_LOCALEDIR = os.environ.get("TANIN_CAL_LOCALEDIR", "/usr/share/locale") +try: + _translation = gettext.translation( + "tanin-calendar", _LOCALEDIR, fallback=True) +except Exception: # noqa: BLE001 — never let i18n setup break startup + _translation = gettext.NullTranslations() +_ = _translation.gettext # --------------------------------------------------------------------------- # # EDS import guard — keep the app alive even when the typelibs are missing. # (On a fresh box without evolution-data-server, `gi.require_version` raises; -# we degrade to a StatusPage rather than failing to start.) +# we degrade to a status page rather than failing to start.) # --------------------------------------------------------------------------- # EDS_OK = False EDS_ERROR = "" @@ -54,10 +73,6 @@ CSS_PATH = os.environ.get( "TANIN_CAL_CSS", "/usr/share/tanin/calendar/tanin-calendar.css" ) -# The calendar content is clamped to this width so wide/maximised windows show -# centred, readable cells instead of letting the grid stretch edge-to-edge. -GRID_MAX_WIDTH = 960 - # --------------------------------------------------------------------------- # # Time-grid geometry (Day / Week views) # --------------------------------------------------------------------------- # @@ -72,16 +87,33 @@ SNAP_MINUTES = 15 # drag start/end snap granularity DRAG_THRESHOLD = 6 # px below which a drag counts as a click SCROLL_TO_HOUR = 7 # on open, reveal ~07:00 first -# English month/weekday names for headings (independent of system locale, so -# the UI stays English even on a non-English-locale box). -MONTHS_EN = [ +# Month / weekday names (English source). Indexed at use and wrapped in _() so +# a catalogue can translate them — e.g. _(MONTHS[m - 1]). +MONTHS = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ] -WEEKDAYS_EN = [ +WEEKDAYS = [ "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday", ] +SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] +MINI_INITIALS = ["M", "T", "W", "T", "F", "S", "S"] + + +def _month(m): + """Localised full month name for a 1-based month number.""" + return _(MONTHS[m - 1]) + + +def _weekday(dow): + """Localised full weekday name for a 1-based ISO weekday (1=Mon).""" + return _(WEEKDAYS[dow - 1]) + + +def _short(dow): + """Localised short weekday name for a 1-based ISO weekday (1=Mon).""" + return _(SHORT[dow - 1]) # --------------------------------------------------------------------------- # @@ -214,7 +246,7 @@ def load_events(start_dt, end_dt): for source in sources: if not source.get_enabled(): continue - cal_name = source.get_display_name() or "Calendar" + cal_name = source.get_display_name() or _("Calendar") cal_color = _source_color(source) try: client = ECal.Client.connect_sync( @@ -224,7 +256,7 @@ def load_events(start_dt, end_dt): errors.append("%s: %s" % (cal_name, e.message)) continue if client is None: - errors.append("%s: not reachable" % cal_name) + errors.append(_("%s: not reachable") % cal_name) continue try: @@ -253,7 +285,7 @@ def _component_to_event(comp, cal_name, cal_color, source=None): ev.source = source ev.uid = _component_uid(comp) ev.rid = _component_rid(comp) - ev.summary = _text_value(comp.get_summary()) or "(No title)" + ev.summary = _text_value(comp.get_summary()) or _("(No title)") ev.location = comp.get_location() or "" descs = comp.get_descriptions() @@ -281,13 +313,17 @@ def _component_to_event(comp, cal_name, cal_color, source=None): # --------------------------------------------------------------------------- # -# UI +# Window — native sidebar (left) + content (right) shell # --------------------------------------------------------------------------- # -class CalendarWindow(Adw.ApplicationWindow): +class CalendarWindow(Gtk.ApplicationWindow): def __init__(self, app): super().__init__(application=app) - self.set_title("Calendar") - self.set_default_size(960, 760) + self.set_title(_("Calendar")) + self.set_default_size(1180, 800) + # Native TANINUX look: same .tn / .tn-dark hooks as the Music app. + self.add_css_class("tn") + self.add_css_class("tn-dark") + self.add_css_class("tn-cal") # Single focused-date state (the 1st-of-month + day of focus) drives # all three views. The month grid spans the whole calendar month padded @@ -297,55 +333,47 @@ class CalendarWindow(Adw.ApplicationWindow): self.cur_month = now.get_month() self.cur_day = now.get_day_of_month() - # Which view is active: "day" / "week" / "month". Default = month. - self.view_mode = "month" - self._switching = False # guard so programmatic stack changes don't loop + # Which view is active: "day" / "week" / "month". Default = month; + # TANIN_CAL_VIEW can preselect one (handy for the eww panel launcher). + self.view_mode = os.environ.get("TANIN_CAL_VIEW", "month") + if self.view_mode not in ("day", "week", "month"): + self.view_mode = "month" # The day the user has SELECTED (distinct from "today"). Drives the - # always-visible bottom agenda. Defaults to today. Kept as a (y, m, d) - # tuple so it survives month navigation cleanly. + # always-visible bottom agenda. Defaults to today. self.selected_day = (self.cur_year, self.cur_month, self.cur_day) - # Cache of the most recent load so the bottom agenda can re-render on - # selection changes without hitting EDS again. + # Render cache so the calendar list / mini-month can re-render on + # selection or visibility changes without hitting EDS again. self._last_buckets = {} + self._cur_start = self._focus_date() + self._cur_errors = [] + self._known_cals = {} # cal_name -> colour (accumulated across loads) + self._hidden_cals = set() # cal_names the user has toggled off + self._grid_weeks = 6 # month-grid row count (set by _grid_bounds) - toolbar = Adw.ToolbarView() - toolbar.add_top_bar(self._build_header()) - toolbar.add_top_bar(self._build_strip()) + # ------------------------------------------------------------------ # + # Shell: sidebar (left) · content (right), under a toast overlay. + # ------------------------------------------------------------------ # + body = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + body.append(self._build_sidebar()) + body.append(self._build_content()) - # The active view (and any status pages) live in this content box so we - # can swap them out per reload without rebuilding the chrome. - self.content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - self.content.set_vexpand(True) - self.content.set_hexpand(True) + self._overlay = Gtk.Overlay() + self._overlay.set_child(body) + self._toast_revealer = self._build_toast() + self._overlay.add_overlay(self._toast_revealer) + self.set_child(self._overlay) - # The always-visible bottom agenda for the SELECTED day. It sits below - # the grid/week views (and re-renders on selection without reloading). - self.bottom_agenda = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - self.bottom_agenda.add_css_class("selected-agenda") - self.bottom_agenda.set_hexpand(True) - - body = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - body.set_vexpand(True) - body.set_hexpand(True) - body.append(self.content) - body.append(self.bottom_agenda) - toolbar.set_content(body) - - # A ToastOverlay wraps everything so we can surface save/error toasts. - self._toast_overlay = Adw.ToastOverlay() - self._toast_overlay.set_child(toolbar) - - self.set_content(self._toast_overlay) - - # Sync the stack to the default view. Defer the first load to an idle - # callback so the window maps IMMEDIATELY even if EDS is slow, and never let - # a load error keep the window from showing (it prints instead of breaking). - self.view_stack.set_visible_child_name("month") + # Defer the first load to an idle callback so the window maps + # IMMEDIATELY even if EDS is slow, and never let a load error keep the + # window from showing (it prints instead of breaking). GLib.idle_add(self._initial_load) def _initial_load(self): + self._sync_segment() + self._update_masthead() + self._refresh_mini_month() try: self._reload() except Exception: # noqa: BLE001 @@ -353,21 +381,17 @@ class CalendarWindow(Adw.ApplicationWindow): traceback.print_exc() return False - # ---- focused date helpers --------------------------------------------- # + # ---- focused / selected date helpers ---------------------------------- # def _focus_date(self): - """The focused day as a GLib.DateTime (local midnight).""" return GLib.DateTime.new_local( - self.cur_year, self.cur_month, self.cur_day, 0, 0, 0 - ) + self.cur_year, self.cur_month, self.cur_day, 0, 0, 0) def _set_focus(self, dt): self.cur_year = dt.get_year() self.cur_month = dt.get_month() self.cur_day = dt.get_day_of_month() - # ---- selection helpers ------------------------------------------------ # def _selected_date(self): - """The selected day as a GLib.DateTime (local midnight).""" y, m, d = self.selected_day return GLib.DateTime.new_local(y, m, d, 0, 0, 0) @@ -376,148 +400,327 @@ class CalendarWindow(Adw.ApplicationWindow): return "%04d-%02d-%02d" % (y, m, d) def _select_day(self, day): - """Select a day cell/column. Re-renders the grid highlight (cheap, no - EDS reload) and refreshes the bottom agenda for the new day.""" + """Select a day cell/column: refresh the highlight + bottom agenda from + the cache (cheap, no EDS reload).""" new = (day.get_year(), day.get_month(), day.get_day_of_month()) if new == self.selected_day: return self.selected_day = new - self._reload() + self._render_view() - # ---- header ----------------------------------------------------------- # - def _build_header(self): - header = Adw.HeaderBar() + # ====================================================================== # + # Sidebar + # ====================================================================== # + def _build_sidebar(self): + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + box.add_css_class("tn-sidebar") + box.add_css_class("tn-cal-sidebar") + # Fixed width: never grows when the window widens (the content pane, + # which is hexpand, absorbs all extra width). + box.set_size_request(208, -1) + box.set_hexpand(False) - # Centre: a Day | Week | Month view switcher bound to a ViewStack. - # The stack pages carry no content of their own — they exist only to - # drive the switcher; the real view renders into self.content below. - self.view_stack = Adw.ViewStack() - for name, title in (("day", "Day"), ("week", "Week"), - ("month", "Month")): - self.view_stack.add_titled(Gtk.Box(), name, title) + brand = Gtk.Label(label=_("Calendar"), xalign=0.0) + brand.add_css_class("tn-brand") + box.append(brand) - switcher = Adw.ViewSwitcher() - switcher.set_stack(self.view_stack) - switcher.set_policy(Adw.ViewSwitcherPolicy.WIDE) - header.set_title_widget(switcher) - - self.view_stack.connect( - "notify::visible-child-name", self._on_view_changed) - - # left: "+" New event. A labelled icon button so the action reads - # clearly without crowding the centred switcher. + # _("New event") — a full-width glassy accent button (same accent-wash + # look as the selected sidebar rows in the other TANINUX apps). new_btn = Gtk.Button() - new_btn.set_icon_name("list-add-symbolic") - new_btn.set_tooltip_text("New event") - new_btn.add_css_class("flat") + new_btn.add_css_class("tn-cal-new") + new_btn.set_tooltip_text(_("Create a new event")) + nb = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + nb.set_halign(Gtk.Align.CENTER) + nb.append(Gtk.Image.new_from_icon_name("list-add-symbolic")) + nb.append(Gtk.Label(label=_("New event"))) + new_btn.set_child(nb) new_btn.connect("clicked", self._on_new_event) - header.pack_start(new_btn) + box.append(new_btn) - # right: Today (jump to the current date in the active view). - today_btn = Gtk.Button(label="Today") - today_btn.add_css_class("flat") - today_btn.connect("clicked", self._on_today) - header.pack_end(today_btn) + box.append(self._build_mini_month()) - return header + # Calendars header + list. + cals_header = Gtk.Label(label=_("Calendars"), xalign=0.0) + cals_header.add_css_class("tn-nav-header") + box.append(cals_header) - # ---- strip (month-left / year-right + nav) ---------------------------- # - def _build_strip(self): - """The masthead strip below the header: prev/next nav, the big month - name on the LEFT and the big year on the RIGHT (year lighter/muted). + self._cal_list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + self._cal_list.add_css_class("tn-cal-list") + scroller = Gtk.ScrolledWindow() + scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroller.set_vexpand(True) + scroller.set_child(self._cal_list) + box.append(scroller) + return box - Wrapped in an Adw.Clamp so the strip lines up with the clamped grid on - wide windows instead of flinging the year off to the far edge. - """ - clamp = Adw.Clamp() - clamp.set_maximum_size(GRID_MAX_WIDTH) - clamp.set_tightening_threshold(GRID_MAX_WIDTH) - clamp.add_css_class("cal-strip") + # ---- mini-month navigator --------------------------------------------- # + def _build_mini_month(self): + wrap = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + wrap.add_css_class("tn-mini") - strip = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) - strip.add_css_class("cal-strip-inner") - clamp.set_child(strip) + header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=2) + header.add_css_class("tn-mini-header") - prev_btn = Gtk.Button() - prev_btn.set_icon_name("go-previous-symbolic") + self._mini_label = Gtk.Label(xalign=0.0, hexpand=True) + self._mini_label.add_css_class("tn-mini-title") + header.append(self._mini_label) + + prev_btn = Gtk.Button(icon_name="go-previous-symbolic") prev_btn.add_css_class("flat") prev_btn.add_css_class("circular") - prev_btn.set_tooltip_text("Previous") - prev_btn.connect("clicked", self._on_prev) + prev_btn.set_tooltip_text(_("Previous month")) + prev_btn.connect("clicked", lambda *_: self._mini_step(-1)) + header.append(prev_btn) - next_btn = Gtk.Button() - next_btn.set_icon_name("go-next-symbolic") + next_btn = Gtk.Button(icon_name="go-next-symbolic") next_btn.add_css_class("flat") next_btn.add_css_class("circular") - next_btn.set_tooltip_text("Next") - next_btn.connect("clicked", self._on_next) + next_btn.set_tooltip_text(_("Next month")) + next_btn.connect("clicked", lambda *_: self._mini_step(1)) + header.append(next_btn) + wrap.append(header) - nav = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=2) - nav.add_css_class("cal-nav") - nav.append(prev_btn) - nav.append(next_btn) - nav.set_valign(Gtk.Align.CENTER) - strip.append(nav) + self._mini_grid = Gtk.Grid() + self._mini_grid.add_css_class("tn-mini-grid") + self._mini_grid.set_column_homogeneous(True) + wrap.append(self._mini_grid) + return wrap - # Month name + optional day subtitle, grouped so they share a baseline - # and never jump when the subtitle toggles between views. + def _mini_step(self, direction): + """Move the mini-month (and the main view) by one calendar month.""" + m = self.cur_month + direction + y = self.cur_year + if m < 1: + m, y = 12, y - 1 + elif m > 12: + m, y = 1, y + 1 + self.cur_month, self.cur_year = m, y + self.cur_day = min(self.cur_day, _days_in_month(y, m)) + self._reload() + + def _refresh_mini_month(self): + grid = getattr(self, "_mini_grid", None) + if grid is None: + return + child = grid.get_first_child() + while child is not None: + nxt = child.get_next_sibling() + grid.remove(child) + child = nxt + + self._mini_label.set_label( + "%s %d" % (_month(self.cur_month), self.cur_year)) + + for col, name in enumerate(MINI_INITIALS): + lbl = Gtk.Label(label=name) + lbl.add_css_class("tn-mini-dow") + grid.attach(lbl, col, 0, 1, 1) + + first = GLib.DateTime.new_local(self.cur_year, self.cur_month, 1, 0, 0, 0) + offset = first.get_day_of_week() - 1 + start = first.add_days(-offset) + today_key = GLib.DateTime.new_now_local().format("%Y-%m-%d") + buckets = self._filtered(self._last_buckets) + + for i in range(42): + day = start.add_days(i) + key = day.format("%Y-%m-%d") + btn = Gtk.Button(label=str(day.get_day_of_month())) + btn.add_css_class("flat") + btn.add_css_class("tn-mini-day") + # Centre (don't fill the cell) so the round highlight is a true + # circle, not an ellipse stretched to the column width. + btn.set_halign(Gtk.Align.CENTER) + btn.set_valign(Gtk.Align.CENTER) + in_month = (day.get_month() == self.cur_month + and day.get_year() == self.cur_year) + if not in_month: + btn.add_css_class("other-month") + if key == today_key: + btn.add_css_class("today") + if key == self._selected_key(): + btn.add_css_class("selected") + if buckets.get(key): + btn.add_css_class("has-events") + btn.connect("clicked", lambda _b, d=day: self._jump_to(d)) + grid.attach(btn, i % 7, i // 7 + 1, 1, 1) + + def _jump_to(self, day): + """Mini-month day click: focus + select that day and reload.""" + self._set_focus(day) + self.selected_day = (day.get_year(), day.get_month(), + day.get_day_of_month()) + self._reload() + + # ---- calendar show/hide list ------------------------------------------ # + def _refresh_calendar_list(self): + box = getattr(self, "_cal_list", None) + if box is None: + return + child = box.get_first_child() + while child is not None: + nxt = child.get_next_sibling() + box.remove(child) + child = nxt + + if not self._known_cals: + empty = Gtk.Label(label=_("No calendars"), xalign=0.0) + empty.add_css_class("tn-cal-list-empty") + box.append(empty) + return + + for name in sorted(self._known_cals): + color = self._known_cals[name] + row = Gtk.Button() + row.add_css_class("flat") + row.add_css_class("tn-cal-item") + if name in self._hidden_cals: + row.add_css_class("hidden") + inner = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + dot = self._color_dot(color, size=12) + dot.set_valign(Gtk.Align.CENTER) + inner.append(dot) + lbl = Gtk.Label(label=name, xalign=0.0, hexpand=True) + lbl.add_css_class("tn-cal-item-label") + lbl.set_ellipsize(Pango.EllipsizeMode.END) + inner.append(lbl) + row.set_child(inner) + row.connect("clicked", lambda _b, n=name: self._toggle_calendar(n)) + box.append(row) + + def _toggle_calendar(self, name): + if name in self._hidden_cals: + self._hidden_cals.discard(name) + else: + self._hidden_cals.add(name) + self._refresh_calendar_list() + self._render_view() + + def _filtered(self, buckets): + """Drop events whose calendar the user has toggled off.""" + if not self._hidden_cals: + return buckets + out = {} + for key, evs in buckets.items(): + keep = [e for e in evs if e.cal_name not in self._hidden_cals] + if keep: + out[key] = keep + return out + + # ====================================================================== # + # Content (right): masthead + view area + bottom agenda + # ====================================================================== # + def _build_content(self): + pane = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + pane.add_css_class("tn-cal-content") + pane.set_hexpand(True) + pane.set_vexpand(True) + + pane.append(self._build_masthead()) + + # The active view (and any status pages) live here so we can swap them + # out per reload without rebuilding the chrome. The Month view builds its + # selected-day agenda INSIDE this area (directly under the grid) so there + # is no gap; Day/Week fill it with the scrolling time grid. + self.content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + self.content.set_vexpand(True) + self.content.set_hexpand(True) + pane.append(self.content) + return pane + + def _build_masthead(self): + bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + bar.add_css_class("tn-cal-masthead") + + # Left: big month name + light year, with an optional day subtitle. title_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) title_box.set_valign(Gtk.Align.CENTER) + title_box.set_hexpand(True) - self.month_label = Gtk.Label(label="") - self.month_label.add_css_class("month-title") - self.month_label.set_halign(Gtk.Align.START) - self.month_label.set_xalign(0) + self.month_label = Gtk.Label(xalign=0.0) + self.month_label.add_css_class("tn-cal-month") title_box.append(self.month_label) - # Day view also shows the weekday/day number next to the month. - self.day_sub_label = Gtk.Label(label="") - self.day_sub_label.add_css_class("day-subtitle") - self.day_sub_label.set_halign(Gtk.Align.START) - self.day_sub_label.set_xalign(0) - self.day_sub_label.set_valign(Gtk.Align.CENTER) + self.year_label = Gtk.Label(xalign=0.0) + self.year_label.add_css_class("tn-cal-year") + title_box.append(self.year_label) + + self.day_sub_label = Gtk.Label(xalign=0.0) + self.day_sub_label.add_css_class("tn-cal-daysub") + self.day_sub_label.set_valign(Gtk.Align.BASELINE) title_box.append(self.day_sub_label) - strip.append(title_box) + bar.append(title_box) - # Spacer pushes the year to the far right. - spacer = Gtk.Box() - spacer.set_hexpand(True) - strip.append(spacer) + # Right cluster: prev/next · Heute · Day|Week|Month segmented switcher. + prev_btn = Gtk.Button(icon_name="go-previous-symbolic") + prev_btn.add_css_class("flat") + prev_btn.add_css_class("circular") + prev_btn.set_tooltip_text(_("Back")) + prev_btn.connect("clicked", self._on_prev) + bar.append(prev_btn) - # Year, large + muted, on the RIGHT. - self.year_label = Gtk.Label(label="") - self.year_label.add_css_class("year-title") - self.year_label.set_halign(Gtk.Align.END) - self.year_label.set_xalign(1) - self.year_label.set_valign(Gtk.Align.CENTER) - strip.append(self.year_label) + next_btn = Gtk.Button(icon_name="go-next-symbolic") + next_btn.add_css_class("flat") + next_btn.add_css_class("circular") + next_btn.set_tooltip_text(_("Forward")) + next_btn.connect("clicked", self._on_next) + bar.append(next_btn) - return clamp + today_btn = Gtk.Button(label=_("Today")) + today_btn.add_css_class("pill") + today_btn.add_css_class("tn-cal-today") + today_btn.connect("clicked", self._on_today) + bar.append(today_btn) - def _update_strip(self): - """Refresh the masthead for the current focus + view.""" + seg = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + seg.add_css_class("linked") + seg.add_css_class("tn-segment") + self._seg_buttons = {} + first = None + for key, label in (("day", _("Day")), ("week", _("Week")), + ("month", _("Month"))): + tb = Gtk.ToggleButton(label=label) + tb.add_css_class("tn-segment-btn") + if first is None: + first = tb + else: + tb.set_group(first) + self._seg_buttons[key] = tb + seg.append(tb) + # Set the initial active button BEFORE connecting handlers — grouping a + # toggle button auto-activates the first one and would otherwise fire + # "toggled" and flip view_mode to "day" during construction. + self._seg_buttons[self.view_mode].set_active(True) + for key, tb in self._seg_buttons.items(): + tb.connect("toggled", self._on_segment, key) + bar.append(seg) + return bar + + def _sync_segment(self): + """Reflect view_mode in the segmented control without re-triggering.""" + for key, tb in self._seg_buttons.items(): + tb.handler_block_by_func(self._on_segment) + tb.set_active(key == self.view_mode) + tb.handler_unblock_by_func(self._on_segment) + + def _on_segment(self, btn, key): + if not btn.get_active() or key == self.view_mode: + return + self.view_mode = key + self._reload() + + def _update_masthead(self): focus = self._focus_date() - self.month_label.set_label(MONTHS_EN[self.cur_month - 1]) + self.month_label.set_label(_month(self.cur_month)) self.year_label.set_label(str(self.cur_year)) - if self.view_mode == "day": - wd = WEEKDAYS_EN[focus.get_day_of_week() - 1] - self.day_sub_label.set_label( - "· %s %d" % (wd, self.cur_day)) + wd = _weekday(focus.get_day_of_week()) + self.day_sub_label.set_label(_("· %s, %d.") % (wd, self.cur_day)) self.day_sub_label.set_visible(True) else: self.day_sub_label.set_label("") self.day_sub_label.set_visible(False) - # ---- view switching --------------------------------------------------- # - def _on_view_changed(self, _stack, _param): - if self._switching: - return - name = self.view_stack.get_visible_child_name() - if name and name != self.view_mode: - self.view_mode = name - self._reload() - # ---- nav -------------------------------------------------------------- # def _on_today(self, _btn): now = GLib.DateTime.new_now_local() @@ -534,8 +737,7 @@ class CalendarWindow(Adw.ApplicationWindow): self._step(1) def _step(self, direction): - """Move by one view-unit: 1 day (Day), 1 week (Week), 1 month - (Month). A single focused-date state drives all three views.""" + """Move by one view-unit: 1 day (Day), 1 week (Week), 1 month (Month).""" if self.view_mode == "day": self._set_focus(self._focus_date().add_days(direction)) elif self.view_mode == "week": @@ -548,15 +750,12 @@ class CalendarWindow(Adw.ApplicationWindow): elif self.cur_month > 12: self.cur_month = 1 self.cur_year += 1 - # clamp the day so the 1st-driven grid math stays valid self.cur_day = min( - self.cur_day, - _days_in_month(self.cur_year, self.cur_month)) + self.cur_day, _days_in_month(self.cur_year, self.cur_month)) self._reload() - # ---- content ---------------------------------------------------------- # + # ---- content swap ----------------------------------------------------- # def _set_content(self, widget): - """Swap the single child of the content box.""" child = self.content.get_first_child() while child is not None: nxt = child.get_next_sibling() @@ -565,48 +764,41 @@ class CalendarWindow(Adw.ApplicationWindow): self.content.append(widget) def _grid_bounds(self): - """Six-week window the grid actually displays. - - Returns (grid_start, grid_end) where: - • grid_start is the Monday on/just before the 1st of the month, - • grid_end is exclusive (grid_start + 42 days). - Fetching over this window means every visible cell is populated. - """ first = GLib.DateTime.new_local(self.cur_year, self.cur_month, 1, 0, 0, 0) - # get_day_of_week: 1=Mon … 7=Sun → days to step back to reach Monday. offset = first.get_day_of_week() - 1 + # Always show a grayed LEADING week of the previous month — mirrors the + # trailing next-month days. When the month starts on Monday (offset 0) + # there would be none, so force a full week back. + if offset == 0: + offset = 7 grid_start = first.add_days(-offset) - grid_end = grid_start.add_days(42) # 6 weeks × 7 days, exclusive + days_total = offset + _days_in_month(self.cur_year, self.cur_month) + weeks = max(6, (days_total + 6) // 7) + # Guarantee at least one trailing grayed day (mirror the leading week). + if weeks * 7 - days_total < 1: + weeks += 1 + self._grid_weeks = weeks + grid_end = grid_start.add_days(weeks * 7) return grid_start, grid_end def _week_bounds(self): - """Monday→Sunday week containing the focused date. - - Returns (week_start, week_end) where week_start is that Monday (local - midnight) and week_end is exclusive (week_start + 7 days). - """ focus = self._focus_date() - offset = focus.get_day_of_week() - 1 # 1=Mon … 7=Sun + offset = focus.get_day_of_week() - 1 week_start = focus.add_days(-offset) return week_start, week_start.add_days(7) def _day_bounds(self): - """[focused day, next day) — the single day's range.""" start = self._focus_date() return start, start.add_days(1) + # ---- load / render ---------------------------------------------------- # def _reload(self): - # Keep the switcher in sync with view_mode (e.g. after Today / restore). - self._switching = True - if self.view_stack.get_visible_child_name() != self.view_mode: - self.view_stack.set_visible_child_name(self.view_mode) - self._switching = False - - self._update_strip() + self._sync_segment() + self._update_masthead() + self._refresh_mini_month() if not EDS_OK: self._set_content(self._eds_missing_page()) - self._clear_bottom_agenda() return if self.view_mode == "day": @@ -618,9 +810,8 @@ class CalendarWindow(Adw.ApplicationWindow): # Load EDS events in a WORKER THREAD. The ECal/SourceRegistry *_sync calls # spin the GLib main context internally, which DEADLOCKS when invoked from - # the running GTK main loop (the window then never paints). Do the blocking - # read off-thread and apply the result back on the main thread. A token - # drops stale loads if the user navigates/switches before this finishes. + # the running GTK main loop (the window then never paints). Do the + # blocking read off-thread and apply the result back on the main thread. mode = self.view_mode self._load_token = getattr(self, "_load_token", 0) + 1 token = self._load_token @@ -645,66 +836,94 @@ class CalendarWindow(Adw.ApplicationWindow): for ev in events: buckets.setdefault(ev.day_key, []).append(ev) self._last_buckets = buckets + self._cur_start = start + self._cur_errors = errors + for evs in buckets.values(): + for e in evs: + self._known_cals.setdefault(e.cal_name, e.cal_color) + self._refresh_calendar_list() + self._render_view() + return False + + def _render_view(self): + """(Re-)build the active view from the cache + visibility filter. Cheap; + no EDS round-trip — used after a load, a selection or a cal toggle.""" + buckets = self._filtered(self._last_buckets) + start, mode, errors = self._cur_start, self.view_mode, self._cur_errors if mode == "day": self._set_content(self._build_day(start, buckets, errors)) elif mode == "week": self._set_content(self._build_week(start, buckets, errors)) else: self._set_content(self._build_grid(start, buckets, errors)) - self._update_bottom_agenda() - return False + self._refresh_mini_month() - # ---- month grid ------------------------------------------------------- # + # ====================================================================== # + # Month grid + # ====================================================================== # def _build_grid(self, grid_start, buckets, errors): - outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - outer.set_vexpand(True) - outer.set_hexpand(True) + # Everything (grid + selected-day agenda) scrolls as one column so the + # agenda sits DIRECTLY under the grid with no gap. + column = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + column.add_css_class("tn-cal-page") + column.set_halign(Gtk.Align.CENTER) # centre the calendar on the page if errors: - outer.append(self._error_banner(errors)) + column.append(self._error_banner(errors)) grid = Gtk.Grid() grid.add_css_class("month-grid") - grid.set_vexpand(True) - grid.set_hexpand(True) - grid.set_row_homogeneous(True) + grid.set_halign(Gtk.Align.CENTER) + grid.set_valign(Gtk.Align.START) grid.set_column_homogeneous(True) - # Weekday header row (Mon…Sun). - short = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - for col, name in enumerate(short): - lbl = Gtk.Label(label=name) + for col, name in enumerate(SHORT): + lbl = Gtk.Label(label=_(name)) lbl.add_css_class("weekday-header") - lbl.set_hexpand(True) grid.attach(lbl, col, 0, 1, 1) today_key = GLib.DateTime.new_now_local().format("%Y-%m-%d") - # 6 week-rows × 7 day-columns. - for week in range(6): + cells = [] + for week in range(self._grid_weeks): for dow in range(7): day = grid_start.add_days(week * 7 + dow) cell = self._build_cell(day, buckets, today_key) + cells.append(cell) grid.attach(cell, dow, week + 1, 1, 1) - # Clamp the grid so it stays centred and readable on wide/maximised - # windows instead of stretching the cells edge-to-edge ("pulled out of - # shape"). The clamp caps the width; the grid itself fills that. - clamp = Adw.Clamp() - clamp.set_maximum_size(GRID_MAX_WIDTH) - clamp.set_tightening_threshold(GRID_MAX_WIDTH) - clamp.add_css_class("grid-clamp") - clamp.set_child(grid) - clamp.set_vexpand(True) - clamp.set_hexpand(True) + # Square cells, CAPPED so the calendar never grows on wide windows: each + # cell is min(MAX, available_width / 7), the grid is centred. Tracked on + # the scroller's width (it fills the content area); a guard avoids loops. + MAX_CELL, MIN_CELL = 132, 64 + last = {"size": 0} + + def _square(scroller, *_a): + w = scroller.get_width() + if w <= 0: + return + size = min(MAX_CELL, max(MIN_CELL, (w - 24) // 7)) + if size == last["size"]: + return + last["size"] = size + for c in cells: + c.set_size_request(size, size) + + column.append(grid) + + # The selected-day agenda, directly beneath the grid (no gap). + agenda = self._build_agenda_panel() + if agenda is not None: + column.append(agenda) scroller = Gtk.ScrolledWindow() scroller.set_vexpand(True) scroller.set_hexpand(True) scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - scroller.set_child(clamp) - outer.append(scroller) - return outer + scroller.set_child(column) + scroller.connect("notify::width", _square) + GLib.idle_add(_square, scroller) + return scroller def _build_cell(self, day, buckets, today_key): """One day cell: day number + up to 3 event chips + '+N more'.""" @@ -722,41 +941,33 @@ class CalendarWindow(Adw.ApplicationWindow): if key == self._selected_key(): cell.add_css_class("selected") - # Day number, top-left. num = Gtk.Label(label=str(day.get_day_of_month())) num.add_css_class("day-number") num.set_halign(Gtk.Align.START) num.set_xalign(0) cell.append(num) - # Event chips (up to 3), then a muted "+N more". MAX_CHIPS = 3 for ev in day_events[:MAX_CHIPS]: cell.append(self._event_chip(ev)) extra = len(day_events) - MAX_CHIPS if extra > 0: - more = Gtk.Label(label="+%d more" % extra) + more = Gtk.Label(label=_("+%d more") % extra) more.add_css_class("more-label") more.set_halign(Gtk.Align.START) more.set_xalign(0) cell.append(more) - # Click anywhere in the cell → SELECT that day. The bottom agenda then - # shows its events (this replaces the old click-a-day popover). click = Gtk.GestureClick() click.set_button(1) click.connect("released", lambda *_a, d=day: self._select_day(d)) cell.add_controller(click) - # Right-click on a month day cell → "New event" defaulting to this day. self._attach_day_menu(cell, day) return cell def _event_chip(self, ev): - """A small coloured chip: colour bar + time + truncated title. - - Activatable; clicking opens the event-detail dialog. - """ + """A small coloured chip: colour bar + time + truncated title.""" btn = Gtk.Button() btn.add_css_class("event-chip") btn.add_css_class("flat") @@ -764,7 +975,6 @@ class CalendarWindow(Adw.ApplicationWindow): btn.connect("clicked", lambda _b, e=ev: self._show_detail(e)) row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) - dot = self._color_dot(ev.cal_color, size=8) dot.add_css_class("chip-dot") row.append(dot) @@ -784,33 +994,17 @@ class CalendarWindow(Adw.ApplicationWindow): btn.set_tooltip_text(ev.summary) return btn - # ---- day view (time grid, one column) --------------------------------- # + # ====================================================================== # + # Day / Week time grid (shared) + # ====================================================================== # def _build_day(self, day, buckets, errors): - """The shared time grid restricted to a single full-width day column.""" return self._build_time_grid([day], buckets, errors, single=True) - # ---- week view (time grid, seven columns) ----------------------------- # def _build_week(self, week_start, buckets, errors): - """The shared time grid across Mon→Sun (seven equal day columns).""" days = [week_start.add_days(i) for i in range(7)] return self._build_time_grid(days, buckets, errors, single=False) - # ---- shared time grid ------------------------------------------------- # def _build_time_grid(self, days, buckets, errors, single): - """A GNOME/Apple-style time grid. - - Layout (top → bottom): - • a pinned header row (outside the scroller): an empty axis-width - spacer, then one header per day (weekday name + date, today lit); - • a thin all-day row (axis spacer + one all-day cell per day); - • a vertically scrolling area: a fixed hour axis on the LEFT (00–23) - and one ``.day-column`` per day, all sharing PX_PER_HOUR. Timed - events are absolutely placed in a per-column Gtk.Fixed at - y = start-hour-fraction × PX_PER_HOUR (height = duration × scale, - min EVENT_MIN_HEIGHT). A now-line is drawn on today's column. - - Each day column carries a Gtk.GestureDrag for drag-to-create. - """ outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) outer.set_vexpand(True) outer.set_hexpand(True) @@ -820,8 +1014,6 @@ class CalendarWindow(Adw.ApplicationWindow): today_key = GLib.DateTime.new_now_local().format("%Y-%m-%d") now = GLib.DateTime.new_now_local() - short = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] - # --- pinned header row (weekday + date), outside the scroller ------- # header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) header.add_css_class("timegrid-header") @@ -831,7 +1023,7 @@ class CalendarWindow(Adw.ApplicationWindow): header.append(corner) for day in days: key = day.format("%Y-%m-%d") - wd = WEEKDAYS_EN[day.get_day_of_week() - 1] + wd = _weekday(day.get_day_of_week()) col_head = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) col_head.add_css_class("timegrid-col-header") col_head.set_hexpand(True) @@ -841,13 +1033,13 @@ class CalendarWindow(Adw.ApplicationWindow): col_head.add_css_class("selected") if single: name = Gtk.Label( - label="%s, %d %s %d" % ( + label=_("%s, %d. %s %d") % ( wd, day.get_day_of_month(), - MONTHS_EN[day.get_month() - 1], day.get_year())) + _month(day.get_month()), day.get_year())) name.add_css_class("timegrid-col-name") col_head.append(name) else: - nm = Gtk.Label(label=short[day.get_day_of_week() - 1]) + nm = Gtk.Label(label=_short(day.get_day_of_week())) nm.add_css_class("timegrid-col-name") col_head.append(nm) num = Gtk.Label(label=str(day.get_day_of_month())) @@ -862,7 +1054,7 @@ class CalendarWindow(Adw.ApplicationWindow): # --- all-day row (axis spacer + one cell per day) ------------------ # allday = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) allday.add_css_class("allday-row") - ad_axis = Gtk.Label(label="all-day") + ad_axis = Gtk.Label(label=_("all-day")) ad_axis.add_css_class("allday-axis") ad_axis.set_size_request(HOUR_AXIS_WIDTH, ALLDAY_ROW_HEIGHT) allday.append(ad_axis) @@ -882,7 +1074,6 @@ class CalendarWindow(Adw.ApplicationWindow): grid_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) grid_row.add_css_class("timegrid-body") - # Hour axis: a fixed-width column of "HH:00" labels, one per hour. axis = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) axis.add_css_class("time-axis") axis.set_size_request(HOUR_AXIS_WIDTH, GRID_HEIGHT) @@ -910,8 +1101,6 @@ class CalendarWindow(Adw.ApplicationWindow): outer.append(allday) outer.append(scroller) - # On open, scroll so ~07:00–08:00 is visible. Deferred to idle so the - # adjustment has its real upper bound by then. def _scroll_to_morning(): adj = scroller.get_vadjustment() if adj is not None: @@ -934,9 +1123,6 @@ class CalendarWindow(Adw.ApplicationWindow): if key == today_key: column.add_css_class("today") - # Hour + half-hour hairlines as thin Fixed children spanning the width. - # We don't know the column's pixel width yet, so the lines are added as - # full-width boxes via a size allocation hook. lines = [] for hour in range(24): full = Gtk.Box() @@ -951,22 +1137,13 @@ class CalendarWindow(Adw.ApplicationWindow): column.put(half, 0, int((hour + 0.5) * PX_PER_HOUR)) lines.append(half) - # A selection rectangle reused for drag-to-create (hidden until a drag). - # It is a child of THIS column's Gtk.Fixed, so it can only ever live - # inside this one day column. Its width tracks the column's real pixel - # width (see _on_width below); x is pinned to 0 so it never spills into - # a neighbouring column — it only ever grows vertically (time). sel_box = Gtk.Box() sel_box.add_css_class("drag-selection") sel_box.set_visible(False) sel_box.set_halign(Gtk.Align.START) column.put(sel_box, 0, 0) - # The column's current pixel width, kept up to date by the - # notify::width handler and reused to size the selection box. Starts at - # 1 so a drag before the first allocation still draws something sane. col_w = {"w": 1} - # Timed event blocks, with best-effort side-by-side overlap splitting. timed = [e for e in buckets.get(key, []) if not e.all_day and e.start is not None] timed.sort(key=lambda e: e.start.to_unix()) @@ -978,7 +1155,6 @@ class CalendarWindow(Adw.ApplicationWindow): column.put(block, 0, y) blocks.append((block, y, h, col_i, col_n)) - # Now-line on today's column. now_line = None if key == today_key: now_line = Gtk.Box() @@ -987,10 +1163,7 @@ class CalendarWindow(Adw.ApplicationWindow): frac = (now.get_hour() * 60 + now.get_minute()) / 60.0 column.put(now_line, 0, int(frac * PX_PER_HOUR) - 1) - # Resize hook: stretch hairlines / now-line / blocks to the real width. def _on_width(_col, w, _h, _b=None): - # Remember THIS column's real pixel width so the drag-selection box - # can be sized to exactly one column (never the whole grid). col_w["w"] = max(1, w) for ln in lines: ln.set_size_request(w, 1) @@ -1000,12 +1173,9 @@ class CalendarWindow(Adw.ApplicationWindow): bw = max(1, int(w / col_n) - 2) column.move(block, col_i * int(w / col_n) + 1, _y) block.set_size_request(bw, _bh) - # Keep an in-progress selection box pinned to one column's width. if sel_box.get_visible(): _bh2 = sel_box.get_height() sel_box.set_size_request(col_w["w"], max(1, _bh2)) - # Gtk.Fixed has no allocate signal; track size via the widget's - # "notify" on width through a size-allocate via a tick callback. column.connect("notify::width", lambda c, _p: _on_width(c, c.get_width(), c.get_height())) @@ -1017,8 +1187,6 @@ class CalendarWindow(Adw.ApplicationWindow): def _drag_begin(_g, _sx, sy): state["start_y"] = sy sel_box.set_visible(True) - # Width = this single column's tracked pixel width; x pinned to 0 so - # the box stays wholly inside the column the drag started on. w = col_w["w"] or max(1, column.get_width()) sel_box.set_size_request(max(1, w), 1) column.move(sel_box, 0, int(sy)) @@ -1043,9 +1211,6 @@ class CalendarWindow(Adw.ApplicationWindow): drag.connect("drag-end", _drag_end) column.add_controller(drag) - # Right-click on the empty time grid → "New event here" at the clicked - # time. Right-clicks that land on an event block are consumed by the - # block's own button-3 menu, so they don't reach the column. self._attach_newhere_menu(column, day, get_minutes=_y_to_minutes) return column @@ -1056,7 +1221,6 @@ class CalendarWindow(Adw.ApplicationWindow): day_end = day_start.add_days(1) s = ev.start e = ev.end if ev.end is not None else ev.start.add_hours(1) - # Clamp to the visible day. s_unix = max(s.to_unix(), day_start.to_unix()) e_unix = min(e.to_unix(), day_end.to_unix()) start_min = (s_unix - day_start.to_unix()) / 60.0 @@ -1073,8 +1237,6 @@ class CalendarWindow(Adw.ApplicationWindow): btn.connect("clicked", lambda _b, e=ev: self._show_detail(e)) btn.set_tooltip_text(ev.summary) - # Right-click → context menu (Delete / Details). A separate button-3 - # GestureClick: it does not conflict with the left-button click above. self._attach_event_menu(btn, ev) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) @@ -1092,7 +1254,6 @@ class CalendarWindow(Adw.ApplicationWindow): box.append(title) btn.set_child(box) - # Tint the block with the calendar colour (left accent + faint fill). if ev.cal_color: cls = _color_class(ev.cal_color) css = (".event-block.cb-%s { border-left: 3px solid %s; " @@ -1140,7 +1301,6 @@ class CalendarWindow(Adw.ApplicationWindow): New-event dialog pre-filled. A near-zero drag (a click) defaults to a one-hour event at the clicked hour.""" if drag_dist < DRAG_THRESHOLD: - # Treat as a click: 1-hour event at the clicked hour (snapped). start_min = _snap(_y_to_minutes(y0), SNAP_MINUTES) end_min = start_min + 60 else: @@ -1149,7 +1309,6 @@ class CalendarWindow(Adw.ApplicationWindow): if b <= a: b = a + SNAP_MINUTES start_min, end_min = a, b - # Clamp within the day [0, 24h]. Keep at least one snap step. start_min = max(0, min(start_min, 24 * 60 - SNAP_MINUTES)) end_min = max(start_min + SNAP_MINUTES, min(end_min, 24 * 60)) @@ -1164,12 +1323,6 @@ class CalendarWindow(Adw.ApplicationWindow): # ---- right-click context menus ---------------------------------------- # def _attach_event_menu(self, widget, ev): - """Attach a button-3 (right-click) context menu to an event widget. - - The menu offers Delete (removes from EDS off-thread) and Details (the - existing detail dialog). The left-button gestures on the same widget are - untouched, so normal click-to-open still works. - """ gesture = Gtk.GestureClick() gesture.set_button(3) @@ -1181,7 +1334,7 @@ class CalendarWindow(Adw.ApplicationWindow): box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) box.add_css_class("context-menu") - del_btn = Gtk.Button(label="Delete") + del_btn = Gtk.Button(label=_("Delete")) del_btn.add_css_class("flat") del_btn.add_css_class("context-menu-item") del_btn.add_css_class("destructive") @@ -1194,7 +1347,7 @@ class CalendarWindow(Adw.ApplicationWindow): del_btn.connect("clicked", _on_delete) box.append(del_btn) - det_btn = Gtk.Button(label="Details") + det_btn = Gtk.Button(label=_("Details")) det_btn.add_css_class("flat") det_btn.add_css_class("context-menu-item") det_btn.set_halign(Gtk.Align.FILL) @@ -1209,16 +1362,12 @@ class CalendarWindow(Adw.ApplicationWindow): popover.set_child(box) def _on_pressed(_g, _n, x, y): - # Anchor the popover at the pointer within the clicked widget. popover.set_pointing_to(_point_rect(x, y)) popover.popup() gesture.connect("pressed", _on_pressed) widget.add_controller(gesture) def _attach_newhere_menu(self, widget, day, get_minutes=None): - """Attach a button-3 menu offering "New event here" to a time-grid day - column. ``get_minutes`` maps the click Y (within the column) → minutes - since midnight so the dialog opens pre-filled at the clicked time.""" gesture = Gtk.GestureClick() gesture.set_button(3) @@ -1230,7 +1379,7 @@ class CalendarWindow(Adw.ApplicationWindow): box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) box.add_css_class("context-menu") - new_btn = Gtk.Button(label="New event here") + new_btn = Gtk.Button(label=_("New event here")) new_btn.add_css_class("flat") new_btn.add_css_class("context-menu-item") new_btn.set_halign(Gtk.Align.FILL) @@ -1253,8 +1402,6 @@ class CalendarWindow(Adw.ApplicationWindow): widget.add_controller(gesture) def _attach_day_menu(self, widget, day): - """Attach a button-3 menu offering "New event" to a month-view day - cell (defaults the date to that day).""" gesture = Gtk.GestureClick() gesture.set_button(3) @@ -1265,7 +1412,7 @@ class CalendarWindow(Adw.ApplicationWindow): box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) box.add_css_class("context-menu") - new_btn = Gtk.Button(label="New event") + new_btn = Gtk.Button(label=_("New event")) new_btn.add_css_class("flat") new_btn.add_css_class("context-menu-item") new_btn.set_halign(Gtk.Align.FILL) @@ -1287,9 +1434,6 @@ class CalendarWindow(Adw.ApplicationWindow): widget.add_controller(gesture) def _new_event_at(self, day, y, get_minutes): - """Open the create dialog pre-filled with ``day`` and the time the user - right-clicked (a one-hour default). Reuses the drag/click→create path so - snapping/clamping match drag-to-create exactly.""" if get_minutes is not None: start_min = _snap(get_minutes(y), SNAP_MINUTES) else: @@ -1307,18 +1451,12 @@ class CalendarWindow(Adw.ApplicationWindow): # ---- delete (write) --------------------------------------------------- # def _delete_event(self, ev): - """Remove an event from EDS in a WORKER THREAD, then reload + toast. - - Like every other EDS access in this app, connect_sync/remove_object_sync - spin the GLib main context and DEADLOCK if called on the GTK main loop, - so the blocking work happens off-thread and the result is applied back - via GLib.idle_add. - """ + """Remove an event from EDS in a WORKER THREAD, then reload + toast.""" if not EDS_OK: - self._toast("Evolution Data Server is not available.") + self._toast(_("Evolution Data Server is not available.")) return if ev.source is None or not ev.uid: - self._toast("This event cannot be deleted (missing source).") + self._toast(_("This event cannot be deleted.")) return source = ev.source @@ -1331,12 +1469,12 @@ class CalendarWindow(Adw.ApplicationWindow): client = ECal.Client.connect_sync( source, ECal.ClientSourceType.EVENTS, 30, None) if client is None: - raise RuntimeError("calendar not reachable") + raise RuntimeError(_("calendar not reachable")) ok = client.remove_object_sync( uid, rid, ECal.ObjModType.THIS, ECal.OperationFlags.NONE, None) if not ok: - raise RuntimeError("the calendar rejected the deletion") + raise RuntimeError(_("the calendar rejected the deletion")) except Exception as e: # noqa: BLE001 err = str(e) GLib.idle_add(self._on_event_deleted, err) @@ -1345,12 +1483,54 @@ class CalendarWindow(Adw.ApplicationWindow): def _on_event_deleted(self, err): if err is not None: - self._toast("Could not delete event: %s" % err) + self._toast(_("Could not delete event: %s") % err) return False - self._toast("Event deleted.") + self._toast(_("Event deleted.")) self._reload() return False + # ====================================================================== # + # Selected-day agenda (Month view) — built directly under the grid + # ====================================================================== # + def _build_agenda_panel(self): + """The selected-day agenda widget for the Month view, or None for + Day/Week (those are full time grids that already show events in place).""" + if self.view_mode in ("day", "week"): + return None + + inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) + inner.add_css_class("tn-cal-agenda") + inner.add_css_class("tn-cal-agenda-inner") + inner.set_hexpand(True) # span the grid width (column is centred) + + day = self._selected_date() + wd = _weekday(day.get_day_of_week()) + heading = Gtk.Label(label=_("%s, %d. %s") % ( + wd, day.get_day_of_month(), _month(day.get_month()))) + heading.add_css_class("tn-cal-agenda-heading") + heading.set_halign(Gtk.Align.START) + heading.set_xalign(0) + inner.append(heading) + + buckets = self._filtered(self._last_buckets) + day_events = list(buckets.get(self._selected_key(), [])) + all_day = [e for e in day_events if e.all_day] + timed = [e for e in day_events if not e.all_day] + timed.sort(key=lambda e: (e.start.to_unix() if e.start else 0)) + + if not day_events: + empty = Gtk.Label(label=_("No events")) + empty.add_css_class("tn-cal-agenda-empty") + empty.set_halign(Gtk.Align.START) + empty.set_xalign(0) + inner.append(empty) + else: + listbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) + for ev in all_day + timed: + listbox.append(self._agenda_row(ev)) + inner.append(listbox) + return inner + def _agenda_row(self, ev): """A roomy agenda line: colour dot + time range + title + location.""" btn = Gtk.Button() @@ -1395,130 +1575,7 @@ class CalendarWindow(Adw.ApplicationWindow): btn.set_tooltip_text(ev.summary) return btn - # ---- bottom selected-day agenda --------------------------------------- # - def _clear_bottom_agenda(self): - child = self.bottom_agenda.get_first_child() - while child is not None: - nxt = child.get_next_sibling() - self.bottom_agenda.remove(child) - child = nxt - - def _update_bottom_agenda(self): - """Render the always-visible bottom section listing the SELECTED day's - events. Day view is itself an agenda for the focused day, so we hide the - bottom section there to avoid duplicating it.""" - self._clear_bottom_agenda() - # Day and Week are now full time grids that show events in place, so the - # bottom selected-day agenda would only duplicate them — hide it there. - # It stays for Month (where cells only fit a few chips). - if self.view_mode in ("day", "week"): - self.bottom_agenda.set_visible(False) - return - self.bottom_agenda.set_visible(True) - - day = self._selected_date() - wd = WEEKDAYS_EN[day.get_day_of_week() - 1] - heading_text = "%s, %d %s" % ( - wd, day.get_day_of_month(), MONTHS_EN[day.get_month() - 1]) - - inner = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - inner.add_css_class("selected-agenda-inner") - - heading = Gtk.Label(label=heading_text) - heading.add_css_class("selected-heading") - heading.set_halign(Gtk.Align.START) - heading.set_xalign(0) - inner.append(heading) - - day_events = list(self._last_buckets.get(self._selected_key(), [])) - all_day = [e for e in day_events if e.all_day] - timed = [e for e in day_events if not e.all_day] - timed.sort(key=lambda e: (e.start.to_unix() if e.start else 0)) - - if not day_events: - empty = Gtk.Label(label="No events") - empty.add_css_class("selected-empty") - empty.set_halign(Gtk.Align.START) - empty.set_xalign(0) - inner.append(empty) - else: - listbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) - for ev in all_day + timed: - listbox.append(self._agenda_row(ev)) - inner.append(listbox) - - # Clamp the bottom agenda to align with the (clamped) grid above. - clamp = Adw.Clamp() - clamp.set_maximum_size(GRID_MAX_WIDTH) - clamp.set_tightening_threshold(GRID_MAX_WIDTH) - clamp.set_child(inner) - - scroller = Gtk.ScrolledWindow() - scroller.add_css_class("selected-agenda-scroller") - scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - scroller.set_min_content_height(120) - scroller.set_max_content_height(220) - scroller.set_propagate_natural_height(True) - scroller.set_child(clamp) - self.bottom_agenda.append(scroller) - - # ---- day popover ------------------------------------------------------ # - def _show_day(self, day, day_events): - """Popover/dialog listing all of a day's events (overflow reachable).""" - dialog = Adw.Dialog() - wd = WEEKDAYS_EN[day.get_day_of_week() - 1] - title = "%s, %d %s %d" % (wd, day.get_day_of_month(), - MONTHS_EN[day.get_month() - 1], - day.get_year()) - dialog.set_title(title) - dialog.set_content_width(420) - - tv = Adw.ToolbarView() - tv.add_top_bar(Adw.HeaderBar()) - - page = Adw.PreferencesPage() - group = Adw.PreferencesGroup() - group.set_title(title) - - if not day_events: - empty = Adw.ActionRow() - empty.set_title("No events") - empty.set_subtitle("There is nothing scheduled for this day.") - group.add(empty) - else: - for ev in day_events: - group.add(self._event_row(ev, dialog)) - page.add(group) - - tv.set_content(page) - dialog.set_child(tv) - dialog.present(self) - - def _event_row(self, ev, parent_dialog=None): - row = Adw.ActionRow() - row.set_title(GLib.markup_escape_text(ev.summary)) - row.set_title_lines(2) - - subtitle_bits = [self._time_label(ev)] - if ev.location: - subtitle_bits.append(ev.location) - row.set_subtitle(GLib.markup_escape_text(" · ".join(subtitle_bits))) - row.set_subtitle_lines(1) - - row.add_prefix(self._color_dot(ev.cal_color)) - - row.set_activatable(True) - - def _open(_r, e=ev): - if parent_dialog is not None: - parent_dialog.close() - self._show_detail(e) - row.connect("activated", _open) - chevron = Gtk.Image.new_from_icon_name("go-next-symbolic") - chevron.add_css_class("dim-label") - row.add_suffix(chevron) - return row - + # ---- shared small bits ------------------------------------------------ # def _color_dot(self, color, size=10): dot = Gtk.Box() dot.add_css_class("event-dot") @@ -1539,65 +1596,93 @@ class CalendarWindow(Adw.ApplicationWindow): def _time_label(self, ev): if ev.all_day or ev.start is None: - return "All day" + return _("All day") start = ev.start.format("%H:%M") if ev.end is not None: - # only show the end time when it's on the same day if ev.end.format("%Y-%m-%d") == ev.start.format("%Y-%m-%d"): return "%s – %s" % (start, ev.end.format("%H:%M")) return start - # ---- detail ----------------------------------------------------------- # - def _show_detail(self, ev): - dialog = Adw.Dialog() - dialog.set_title(ev.summary or "Event") - dialog.set_content_width(420) + # ====================================================================== # + # Native dialogs (detail + new event), toast, status pages + # ====================================================================== # + def _new_dialog(self, title, width=440): + """A modal native dialog window in the TANINUX look.""" + dlg = Gtk.Window(modal=True, title=title) + dlg.add_css_class("tn") + dlg.add_css_class("tn-dark") + dlg.add_css_class("tn-cal-dialog") + dlg.set_transient_for(self) + dlg.set_default_size(width, -1) + return dlg - tv = Adw.ToolbarView() - tv.add_top_bar(Adw.HeaderBar()) - - page = Adw.PreferencesPage() - - head = Adw.PreferencesGroup() - head.set_title(ev.summary or "(No title)") - page.add(head) - - info = Adw.PreferencesGroup() - info.add(self._detail_row("Time", self._detail_time(ev))) - if ev.location: - info.add(self._detail_row("Location", ev.location)) - info.add(self._detail_row("Calendar", ev.cal_name or "—")) - page.add(info) - - if ev.description: - desc_group = Adw.PreferencesGroup() - desc_group.set_title("Description") - desc_label = Gtk.Label(label=ev.description) - desc_label.set_wrap(True) - desc_label.set_xalign(0) - desc_label.set_selectable(True) - desc_label.set_margin_top(6) - desc_label.set_margin_bottom(6) - desc_label.set_margin_start(12) - desc_label.set_margin_end(12) - desc_group.add(desc_label) - page.add(desc_group) - - tv.set_content(page) - dialog.set_child(tv) - dialog.present(self) - - def _detail_row(self, title, value): - row = Adw.ActionRow() - row.set_title(title) - row.set_subtitle(value) - row.set_subtitle_selectable(True) + def _detail_row(self, label_text, value, selectable=False): + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + row.add_css_class("tn-row") + key = Gtk.Label(label=label_text, xalign=0.0) + key.add_css_class("tn-subtitle") + key.set_size_request(90, -1) + key.set_valign(Gtk.Align.START) + row.append(key) + val = Gtk.Label(label=value, xalign=0.0, hexpand=True) + val.add_css_class("tn-title") + val.set_wrap(True) + val.set_selectable(selectable) + row.append(val) return row + def _show_detail(self, ev): + dlg = self._new_dialog(ev.summary or _("Event"), 440) + + outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18) + outer.add_css_class("tn-cal-dialog-box") + + head = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + dot = self._color_dot(ev.cal_color, size=14) + dot.set_valign(Gtk.Align.CENTER) + head.append(dot) + title = Gtk.Label(label=ev.summary or _("(No title)"), xalign=0.0) + title.add_css_class("tn-cal-dialog-title") + title.set_wrap(True) + title.set_hexpand(True) + head.append(title) + outer.append(head) + + card = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + card.add_css_class("tn-card") + card.append(self._detail_row(_("Time"), self._detail_time(ev), True)) + if ev.location: + card.append(self._detail_row(_("Location"), ev.location, True)) + card.append(self._detail_row(_("Calendar"), ev.cal_name or "—")) + if ev.description: + card.append(self._detail_row(_("Note"), ev.description, True)) + outer.append(card) + + btns = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + btns.set_halign(Gtk.Align.END) + del_btn = Gtk.Button(label=_("Delete")) + del_btn.add_css_class("pill") + del_btn.add_css_class("destructive-action") + + def _on_del(_b): + dlg.destroy() + self._delete_event(ev) + del_btn.connect("clicked", _on_del) + btns.append(del_btn) + + close_btn = Gtk.Button(label=_("Close")) + close_btn.add_css_class("pill") + close_btn.connect("clicked", lambda _b: dlg.destroy()) + btns.append(close_btn) + outer.append(btns) + + dlg.set_child(outer) + dlg.present() + def _detail_time(self, ev): if ev.all_day or ev.start is None: day = ev.start.format("%d.%m.%Y") if ev.start else "" - return "All day · %s" % day if day else "All day" + return _("All day · %s") % day if day else _("All day") out = ev.start.format("%a, %d.%m.%Y %H:%M") if ev.end is not None: if ev.end.format("%Y-%m-%d") == ev.start.format("%Y-%m-%d"): @@ -1608,20 +1693,11 @@ class CalendarWindow(Adw.ApplicationWindow): # ---- event creation (write) ------------------------------------------- # def _on_new_event(self, _btn, defaults=None): - """Open the New-event form. - - ``defaults`` is an optional dict of pre-fill values - ``{date, start_dt, end_dt, all_day}`` (any/all may be omitted). A plain - "+" click passes nothing → the form falls back to the selected day with - a 09:00–10:00 default. A drag-to-create passes the dragged column date - and the snapped start/end times. - """ if not EDS_OK: - self._toast("Evolution Data Server is not available.") + self._toast(_("Evolution Data Server is not available.")) return - # _writable_calendars() hits EDS (SourceRegistry.new_sync). A sync EDS call - # on the main loop DEADLOCKS the whole app — do it off-thread, then present - # the dialog back on the main thread. + # _writable_calendars() hits EDS (SourceRegistry.new_sync). A sync EDS + # call on the main loop DEADLOCKS — do it off-thread, then present. def worker(): try: writable = _writable_calendars() @@ -1633,14 +1709,32 @@ class CalendarWindow(Adw.ApplicationWindow): def _after_writable(self, writable, defaults=None): if not writable: - self._toast("No writable calendar found.") + self._toast(_("No writable calendar found.")) return False self._present_new_event_dialog(writable, defaults) return False + def _form_row(self, label_text, widget, fill=True): + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) + row.add_css_class("tn-row") + key = Gtk.Label(label=label_text, xalign=0.0) + key.add_css_class("tn-title") + key.set_size_request(90, -1) + key.set_valign(Gtk.Align.CENTER) + row.append(key) + widget.set_valign(Gtk.Align.CENTER) + if fill: + # Entries / dropdowns stretch to the row width. + widget.set_hexpand(True) + widget.set_halign(Gtk.Align.FILL) + else: + # Switches / the time picker keep their natural size, left-aligned. + widget.set_hexpand(False) + widget.set_halign(Gtk.Align.START) + row.append(widget) + return row + def _present_new_event_dialog(self, writable, defaults=None): - # Resolve the pre-fill defaults. ``date`` drives the calendar picker; - # ``start_dt``/``end_dt`` (GLib.DateTime, local) drive the time spinners. defaults = defaults or {} def_date = defaults.get("date") or self._selected_date() def_start = defaults.get("start_dt") @@ -1650,169 +1744,147 @@ class CalendarWindow(Adw.ApplicationWindow): def_sm = def_start.get_minute() if def_start is not None else 0 def_eh = def_end.get_hour() if def_end is not None else 10 def_em = def_end.get_minute() if def_end is not None else 0 - dialog = Adw.Dialog() - dialog.set_title("New Event") - dialog.set_content_width(440) - tv = Adw.ToolbarView() - header = Adw.HeaderBar() - header.set_show_end_title_buttons(False) - header.set_show_start_title_buttons(False) + dlg = self._new_dialog(_("New event"), 460) + outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=18) + outer.add_css_class("tn-cal-dialog-box") - cancel_btn = Gtk.Button(label="Cancel") - cancel_btn.connect("clicked", lambda _b: dialog.close()) - header.pack_start(cancel_btn) + title_lbl = Gtk.Label(label=_("New event"), xalign=0.0) + title_lbl.add_css_class("tn-cal-dialog-title") + outer.append(title_lbl) - save_btn = Gtk.Button(label="Save") - save_btn.add_css_class("suggested-action") - header.pack_end(save_btn) - tv.add_top_bar(header) + # --- title + calendar --------------------------------------------- # + card1 = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + card1.add_css_class("tn-card") - page = Adw.PreferencesPage() + title_entry = Gtk.Entry() + title_entry.set_placeholder_text(_("Title")) + card1.append(self._form_row(_("Title"), title_entry)) - # --- main group: title + calendar -------------------------------- # - grp = Adw.PreferencesGroup() - - title_row = Adw.EntryRow() - title_row.set_title("Title") - grp.add(title_row) - - cal_row = Adw.ComboRow() - cal_row.set_title("Calendar") + cal_dd = Gtk.DropDown() cal_model = Gtk.StringList() for _src, name in writable: cal_model.append(name) - cal_row.set_model(cal_model) - grp.add(cal_row) - page.add(grp) + cal_dd.set_model(cal_model) + card1.append(self._form_row(_("Calendar"), cal_dd)) + outer.append(card1) - # --- when group: all-day switch + date + times ------------------- # - when = Adw.PreferencesGroup() - when.set_title("When") + # --- when --------------------------------------------------------- # + card2 = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + card2.add_css_class("tn-card") - allday_row = Adw.SwitchRow() - allday_row.set_title("All day") - allday_row.set_active(def_all_day) - when.add(allday_row) + allday_sw = Gtk.Switch() + allday_sw.set_active(def_all_day) + card2.append(self._form_row(_("All day"), allday_sw, fill=False)) - sel = def_date - date_row = Adw.ActionRow() - date_row.set_title("Date") cal_picker = Gtk.Calendar() - cal_picker.select_day( - GLib.DateTime.new_local( - sel.get_year(), sel.get_month(), sel.get_day_of_month(), - 12, 0, 0)) + # Set via properties (select_day is deprecated). Gtk.Calendar's "month" + # property is 0-based, unlike GLib.DateTime's 1-based month. + cal_picker.set_property("year", def_date.get_year()) + cal_picker.set_property("month", def_date.get_month() - 1) + cal_picker.set_property("day", def_date.get_day_of_month()) date_btn = Gtk.MenuButton() - date_btn.add_css_class("flat") + date_btn.add_css_class("pill") date_btn.set_valign(Gtk.Align.CENTER) + date_btn.set_halign(Gtk.Align.START) date_pop = Gtk.Popover() date_pop.set_child(cal_picker) date_btn.set_popover(date_pop) def _fmt_date_btn(*_a): d = cal_picker.get_date() - date_btn.set_label(d.format("%a, %d %b %Y")) + date_btn.set_label(d.format("%a, %d.%m.%Y")) _fmt_date_btn() cal_picker.connect("day-selected", _fmt_date_btn) - date_row.add_suffix(date_btn) - date_row.set_activatable_widget(date_btn) - when.add(date_row) + card2.append(self._form_row(_("Date"), date_btn)) - start_row = Adw.SpinRow.new_with_range(0, 23, 1) - start_row.set_title("Start hour") - start_row.set_value(def_sh) - when.add(start_row) + start_box, start_h, start_m = _time_picker(def_sh, def_sm) + start_row = self._form_row(_("Start"), start_box, fill=False) + card2.append(start_row) + end_box, end_h, end_m = _time_picker(def_eh, def_em) + end_row = self._form_row(_("End"), end_box, fill=False) + card2.append(end_row) + outer.append(card2) - start_min_row = Adw.SpinRow.new_with_range(0, 55, 5) - start_min_row.set_title("Start minute") - start_min_row.set_value(def_sm) - when.add(start_min_row) - - end_row = Adw.SpinRow.new_with_range(0, 23, 1) - end_row.set_title("End hour") - end_row.set_value(def_eh) - when.add(end_row) - - end_min_row = Adw.SpinRow.new_with_range(0, 55, 5) - end_min_row.set_title("End minute") - end_min_row.set_value(def_em) - when.add(end_min_row) - page.add(when) - - # All-day hides the time rows. def _toggle_allday(*_a): - show = not allday_row.get_active() - for r in (start_row, start_min_row, end_row, end_min_row): - r.set_visible(show) - allday_row.connect("notify::active", _toggle_allday) + show = not allday_sw.get_active() + start_row.set_visible(show) + end_row.set_visible(show) + allday_sw.connect("notify::active", _toggle_allday) _toggle_allday() - # --- optional details -------------------------------------------- # - details = Adw.PreferencesGroup() - details.set_title("Details") - loc_row = Adw.EntryRow() - loc_row.set_title("Location") - details.add(loc_row) - desc_row = Adw.EntryRow() - desc_row.set_title("Description") - details.add(desc_row) - page.add(details) + # --- details ------------------------------------------------------ # + card3 = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + card3.add_css_class("tn-card") + loc_entry = Gtk.Entry() + loc_entry.set_placeholder_text(_("Location")) + card3.append(self._form_row(_("Location"), loc_entry)) + desc_entry = Gtk.Entry() + desc_entry.set_placeholder_text(_("Note")) + card3.append(self._form_row(_("Note"), desc_entry)) + outer.append(card3) - tv.set_content(page) - dialog.set_child(tv) + # --- buttons ------------------------------------------------------ # + btns = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + btns.set_halign(Gtk.Align.END) + cancel_btn = Gtk.Button(label=_("Cancel")) + cancel_btn.add_css_class("pill") + cancel_btn.connect("clicked", lambda _b: dlg.destroy()) + btns.append(cancel_btn) + save_btn = Gtk.Button(label=_("Save")) + save_btn.add_css_class("pill") + save_btn.add_css_class("suggested-action") + btns.append(save_btn) + outer.append(btns) + + scroller = Gtk.ScrolledWindow() + scroller.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scroller.set_propagate_natural_height(True) + scroller.set_max_content_height(720) + scroller.set_child(outer) + dlg.set_child(scroller) def _on_save(_b): - title = title_row.get_text().strip() + title = title_entry.get_text().strip() if not title: - title_row.add_css_class("error") - self._toast("Please enter a title.") + title_entry.add_css_class("error") + self._toast(_("Please enter a title.")) return - src, _name = writable[cal_row.get_selected()] - all_day = allday_row.get_active() + src, _name = writable[cal_dd.get_selected()] + all_day = allday_sw.get_active() d = cal_picker.get_date() - year, month, day = d.get_year(), d.get_month(), d.get_day_of_month() - sh = int(start_row.get_value()) - sm = int(start_min_row.get_value()) - eh = int(end_row.get_value()) - em = int(end_min_row.get_value()) - spec = { "summary": title, - "location": loc_row.get_text().strip(), - "description": desc_row.get_text().strip(), + "location": loc_entry.get_text().strip(), + "description": desc_entry.get_text().strip(), "all_day": all_day, - "year": year, "month": month, "day": day, - "sh": sh, "sm": sm, "eh": eh, "em": em, + "year": d.get_year(), "month": d.get_month(), + "day": d.get_day_of_month(), + "sh": start_h.get_selected(), "sm": start_m.get_selected() * 5, + "eh": end_h.get_selected(), "em": end_m.get_selected() * 5, } save_btn.set_sensitive(False) cancel_btn.set_sensitive(False) - save_btn.set_label("Saving…") - self._save_event_async(src, spec, dialog, save_btn, cancel_btn) + save_btn.set_label(_("Saving…")) + self._save_event_async(src, spec, dlg, save_btn, cancel_btn) save_btn.connect("clicked", _on_save) - dialog.present(self) + dlg.present() def _save_event_async(self, source, spec, dialog, save_btn, cancel_btn): - """Build the VEVENT and write it via ECal in a WORKER THREAD. - - EVERY EDS call here (connect_sync, create_object_sync) is synchronous and - spins the GLib main context, so it MUST run off the GTK main loop or the - window deadlocks. On completion we hop back to the main thread via - GLib.idle_add to close the dialog + reload (success) or report (error). - """ + """Build the VEVENT and write it via ECal in a WORKER THREAD.""" def worker(): err = None try: client = ECal.Client.connect_sync( source, ECal.ClientSourceType.EVENTS, 30, None) if client is None: - raise RuntimeError("calendar not reachable") + raise RuntimeError(_("calendar not reachable")) comp = _build_vevent(spec) ok, _uid = client.create_object_sync( comp, ECal.OperationFlags.NONE, None) if not ok: - raise RuntimeError("the calendar rejected the event") + raise RuntimeError(_("the calendar rejected the event")) except Exception as e: # noqa: BLE001 err = str(e) GLib.idle_add(self._on_event_saved, err, dialog, @@ -1824,52 +1896,116 @@ class CalendarWindow(Adw.ApplicationWindow): if err is not None: save_btn.set_sensitive(True) cancel_btn.set_sensitive(True) - save_btn.set_label("Save") - self._toast("Could not save event: %s" % err) + save_btn.set_label(_("Save")) + self._toast(_("Could not save event: %s") % err) return False - dialog.close() - self._toast("Event created.") + dialog.destroy() + self._toast(_("Event created.")) self._reload() return False - def _toast(self, text): - if hasattr(self, "_toast_overlay") and self._toast_overlay is not None: - self._toast_overlay.add_toast(Adw.Toast.new(text)) + # ---- toast ------------------------------------------------------------ # + def _build_toast(self): + revealer = Gtk.Revealer() + revealer.set_transition_type(Gtk.RevealerTransitionType.SLIDE_UP) + revealer.set_halign(Gtk.Align.CENTER) + revealer.set_valign(Gtk.Align.END) + revealer.set_margin_bottom(24) + self._toast_label = Gtk.Label() + self._toast_label.add_css_class("tn-toast") + revealer.set_child(self._toast_label) + return revealer + + def _toast(self, message): + self._toast_label.set_label(message) + self._toast_revealer.set_reveal_child(True) + GLib.timeout_add_seconds(3, self._hide_toast) + + def _hide_toast(self): + self._toast_revealer.set_reveal_child(False) + return False + + # ---- status / error pages --------------------------------------------- # + def _status_page(self, icon_name, title, description): + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + box.add_css_class("tn-status-page") + box.set_valign(Gtk.Align.CENTER) + box.set_halign(Gtk.Align.CENTER) + box.set_vexpand(True) + box.set_hexpand(True) + icon = Gtk.Image.new_from_icon_name(icon_name) + icon.set_pixel_size(96) + icon.add_css_class("tn-status-icon") + box.append(icon) + t = Gtk.Label(label=title) + t.add_css_class("title-1") + t.set_wrap(True) + t.set_justify(Gtk.Justification.CENTER) + box.append(t) + d = Gtk.Label(label=description) + d.add_css_class("tn-status-desc") + d.set_wrap(True) + d.set_justify(Gtk.Justification.CENTER) + box.append(d) + return box - # ---- placeholder pages ------------------------------------------------ # def _eds_missing_page(self): - page = Adw.StatusPage() - page.set_icon_name("dialog-warning-symbolic") - page.set_title("Evolution Data Server not installed") - page.set_description( - "Displaying calendars requires the " - "evolution-data-server package.\n\n" + EDS_ERROR - ) - return page + return self._status_page( + "x-office-calendar-symbolic", + _("Evolution Data Server is not installed"), + _("Displaying calendars requires the evolution-data-server " + "package.") + "\n\n" + EDS_ERROR) def _error_page(self, message): - page = Adw.StatusPage() - page.set_icon_name("dialog-error-symbolic") - page.set_title("Calendars could not be loaded") - page.set_description(message) - return page + return self._status_page( + "dialog-error-symbolic", + _("Calendars could not be loaded"), message) def _error_banner(self, errors): - banner = Adw.Banner() - banner.set_title("Some calendars are not reachable") - banner.set_revealed(True) - banner.set_tooltip_text("\n".join(errors)) + banner = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + banner.add_css_class("tn-cal-banner") + icon = Gtk.Image.new_from_icon_name("dialog-warning-symbolic") + banner.append(icon) + lbl = Gtk.Label(label=_("Some calendars are not reachable"), + xalign=0.0, hexpand=True) + lbl.set_tooltip_text("\n".join(errors)) + banner.append(lbl) return banner -def _writable_calendars(): - """List (source, display_name) for every enabled, WRITABLE calendar source. +# --------------------------------------------------------------------------- # +# Form helpers +# --------------------------------------------------------------------------- # +def _time_picker(hour, minute): + """A clean HH : MM picker — two compact dropdowns. Returns + (box, hour_dd, min_dd); the hour is dd.get_selected(), the minute is + dd.get_selected() * 5 (5-minute steps).""" + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + box.add_css_class("tn-cal-time") + box.set_halign(Gtk.Align.START) - The registry build is a synchronous EDS call, but it returns quickly and is - only invoked from the New-event click (which opens a dialog before any write - happens off-thread). Read-only calendars (Source.get_writable() is False) are - skipped so users can only create where the backend permits. - """ + h_dd = Gtk.DropDown.new_from_strings(["%02d" % h for h in range(24)]) + h_dd.add_css_class("tn-cal-time-dd") + h_dd.set_selected(max(0, min(23, hour))) + box.append(h_dd) + + sep = Gtk.Label(label=":") + sep.add_css_class("tn-cal-time-sep") + sep.set_valign(Gtk.Align.CENTER) + box.append(sep) + + m_dd = Gtk.DropDown.new_from_strings(["%02d" % m for m in range(0, 60, 5)]) + m_dd.add_css_class("tn-cal-time-dd") + m_dd.set_selected(max(0, min(11, minute // 5))) + box.append(m_dd) + return box, h_dd, m_dd + + +# --------------------------------------------------------------------------- # +# EDS write helpers +# --------------------------------------------------------------------------- # +def _writable_calendars(): + """List (source, display_name) for every enabled, WRITABLE calendar source.""" out = [] try: registry = EDataServer.SourceRegistry.new_sync(None) @@ -1884,17 +2020,12 @@ def _writable_calendars(): continue except Exception: # noqa: BLE001 continue - out.append((source, source.get_display_name() or "Calendar")) + out.append((source, source.get_display_name() or _("Calendar"))) return out def _ical_date(year, month, day): - """A floating DATE (is_date=True) with the given Y/M/D set explicitly. - - Building from Y/M/D directly avoids the timezone truncation you get when - converting a local-midnight unix timestamp to a UTC DATE (which can land on - the previous calendar day). - """ + """A floating DATE (is_date=True) with the given Y/M/D set explicitly.""" t = ICalGLib.Time.new_null_date() t.set_date(year, month, day) t.set_is_date(True) @@ -1930,7 +2061,6 @@ def _build_vevent(spec): if all_day: comp.add_property(ICalGLib.Property.new_dtstart(_ical_date(y, m, d))) - # All-day DTEND is the exclusive next day. nxt = GLib.DateTime.new_local(y, m, d, 0, 0, 0).add_days(1) comp.add_property(ICalGLib.Property.new_dtend( _ical_date(nxt.get_year(), nxt.get_month(), @@ -1940,7 +2070,6 @@ def _build_vevent(spec): _ical_datetime_utc(y, m, d, spec["sh"], spec["sm"]))) local_start = GLib.DateTime.new_local(y, m, d, spec["sh"], spec["sm"], 0) local_end = GLib.DateTime.new_local(y, m, d, spec["eh"], spec["em"], 0) - # Guard against an end at/before the start (roll to +1h). if local_end.to_unix() <= local_start.to_unix(): local_end = local_start.add_hours(1) comp.add_property(ICalGLib.Property.new_dtend( @@ -1951,18 +2080,16 @@ def _build_vevent(spec): return comp +# --------------------------------------------------------------------------- # +# Small helpers +# --------------------------------------------------------------------------- # def _color_class(color): """A CSS-safe class suffix derived from a colour string.""" return "".join(c for c in color if c.isalnum()) or "x" def _point_rect(x, y): - """A 1×1 Gdk.Rectangle at (x, y). - - NB: ``Gdk.Rectangle(x=…, y=…)`` SILENTLY IGNORES its kwargs (boxed-struct - init is deprecated/no-op), so the fields must be assigned after the fact or - every popover would anchor at (0, 0) instead of the pointer. - """ + """A 1×1 Gdk.Rectangle at (x, y).""" rect = Gdk.Rectangle() rect.x = int(x) rect.y = int(y) @@ -1971,9 +2098,6 @@ def _point_rect(x, y): return rect -# --------------------------------------------------------------------------- # -# Time-grid math helpers -# --------------------------------------------------------------------------- # def _y_to_minutes(y): """A Y pixel offset within a day column → minutes since 00:00.""" return (y / float(PX_PER_HOUR)) * 60.0 @@ -1985,11 +2109,7 @@ def _snap(minutes, step): def _tint(color): - """A translucent fill derived from a calendar colour for event blocks. - - Returns a GTK ``alpha(#rrggbb, …)`` expression so the block reads as a - faint wash of the calendar colour over the dark Fuji background. - """ + """A translucent fill derived from a calendar colour for event blocks.""" return "alpha(%s, 0.22)" % color @@ -2013,7 +2133,7 @@ def _layout_overlaps(timed, day): return s, e out = [] - cluster = [] # list of (ev, s, e, col) + cluster = [] cluster_max_end = None def _flush(cluster): @@ -2029,7 +2149,6 @@ def _layout_overlaps(timed, day): _flush(cluster) cluster = [] cluster_max_end = None - # find the lowest free column among events still overlapping ``s`` used = set() for _ev2, _s2, e2, col2 in cluster: if e2 > s: @@ -2042,7 +2161,6 @@ def _layout_overlaps(timed, day): if cluster: _flush(cluster) - # restore input order order = {id(ev): i for i, ev in enumerate(timed)} out.sort(key=lambda t: order[id(t[0])]) return out @@ -2060,7 +2178,7 @@ def _days_in_month(year, month): # --------------------------------------------------------------------------- # # Application # --------------------------------------------------------------------------- # -class CalendarApp(Adw.Application): +class CalendarApp(Gtk.Application): def __init__(self): super().__init__(application_id="ch.gabrielevarano.TaninCalendar", flags=Gio.ApplicationFlags.FLAGS_NONE) @@ -2082,8 +2200,10 @@ class CalendarApp(Adw.Application): return display = Gdk.Display.get_default() if display is not None: + # USER+1 so our look beats the auto-loaded ~/.config/gtk-4.0/gtk.css + # (adw-gtk3) sidebar/headerbar rules, like the native UI naht does. Gtk.StyleContext.add_provider_for_display( - display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER) + display, provider, Gtk.STYLE_PROVIDER_PRIORITY_USER + 1) if __name__ == "__main__": diff --git a/packaging/tanin-calendar/tanin-calendar.css b/packaging/tanin-calendar/tanin-calendar.css index bd36d86..1db39e0 100644 --- a/packaging/tanin-calendar/tanin-calendar.css +++ b/packaging/tanin-calendar/tanin-calendar.css @@ -1,511 +1,690 @@ /* tanin-calendar — TANINUX calendar * - * The bulk of the look comes from tanin-libadwaita (Fuji monochrome recolour of - * GTK4/libadwaita). This sheet adds the month grid: weekday header, day cells - * (with today / other-month states), and the small per-event colour chips. + * libadwaita-free. The look is the TANINUX design language shared with the + * Music app and the Settings hub: a glassy sidebar on the left, content on the + * right, rounded cards, accent pills. * - * Fuji palette: - * bg #0f0f0f bg_alt #191919 - * fg #f0f0f0 muted #8a8a8a accent #a39ec4 + * Colours come from the GLOBAL TANINUX named colours (defined in + * ~/.config/gtk-4.0/libadwaita.css by `taninux` → see gui/style.py), so the + * calendar tracks the LIVE Fuji accent and light/dark automatically: + * @accent_color the chosen Fuji hue + * @window_bg_color shell surface (dark #0f0f0f / light #f3f1ec) + * @window_fg_color text (dark #f0f0f0 / light #0f0f0f) + * @card_bg_color raised cards (dark #191919 / light #ffffff) * * Per-event/calendar background colours are injected at runtime (see - * _color_dot in tanin-calendar); the .event-dot rule below is the shape / - * fallback so a dot is visible even without a calendar colour from EDS. + * _color_dot / _event_block in tanin-calendar). */ -/* --------------------------------------------------------------- strip --- */ -/* The masthead strip below the header bar: prev/next nav on the left, the big - * month name (bold) on the left and the big year (lighter/muted) on the right. - */ -.cal-strip { - background-color: #0f0f0f; - border-bottom: 1px solid alpha(#f0f0f0, 0.07); +/* ===================================================================== FONT */ +/* Body / content text: Inter, like the Settings & Music apps. */ +.tn-cal, +.tn-cal label, +.tn-cal-dialog, +.tn-cal-dialog label { + font-family: -apple-system, "SF Pro Text", "Inter", "Cantarell", sans-serif; } -/* Inner box of the clamped strip carries the padding so the clamp can keep the - * content aligned with the (equally clamped) grid below. */ -.cal-strip-inner { - padding: 8px 8px 12px 8px; +/* Top-area "chrome" / display text: Iosevka Aile, like the TANINUX bar (eww). + * Brand, masthead month/year, the Day·Week·Month switcher, Today, the + * mini-month and the grid's weekday/day labels all read as bar-style display + * type so the calendar ties into the panel typography. + * + * Each selector is qualified with `.tn-cal ` so it has higher specificity than + * the `.tn-cal label` body-font rule above (otherwise Inter would win). */ +.tn-cal .tn-brand, +.tn-cal .tn-cal-month, +.tn-cal .tn-cal-year, +.tn-cal .tn-cal-daysub, +.tn-cal .tn-segment-btn, +.tn-cal .tn-cal-today, +.tn-cal .tn-mini-title, +.tn-cal .tn-mini-dow, +.tn-cal .tn-mini-day, +.tn-cal .weekday-header, +.tn-cal .day-number, +.tn-cal .timegrid-col-name, +.tn-cal .timegrid-col-date, +.tn-cal .hour-label { + font-family: "Iosevka Aile", "Iosevka", "Symbols Nerd Font", sans-serif; } -.cal-strip .month-title { - font-size: 1.9em; +/* ============================================================ WINDOW SURFACE */ +.tn-cal, +.tn-cal-dialog { + background-color: @window_bg_color; + color: @window_fg_color; +} + +/* =================================================================== SIDEBAR */ +/* Glassy floating panel — same family as the Music/Settings .tn-sidebar. */ +.tn-cal-sidebar { + background-color: @window_bg_color; + border-right: 1px solid alpha(@window_fg_color, 0.07); + padding: 0 8px 12px 8px; + background-image: linear-gradient( + to bottom, + alpha(@window_fg_color, 0.035), + alpha(@window_fg_color, 0.0) 180px + ); + box-shadow: inset -1px 0 0 alpha(@window_fg_color, 0.04); +} + +.tn-cal .tn-brand { + font-size: 1.55em; font-weight: 800; - color: #f0f0f0; - margin-left: 8px; + color: @window_fg_color; + margin: 18px 4px 12px 6px; } -.cal-strip .day-subtitle { +/* "Neuer Termin" — glassy accent-wash, matching a selected sidebar row in the + * Music/Settings apps (alpha accent fill + accent border + accent text). */ +.tn-cal-new { + margin: 0 2px 16px 2px; + padding: 9px 14px; + font-weight: 600; + border-radius: 12px; + color: @accent_color; + background-color: alpha(@accent_color, 0.18); + border: 1px solid alpha(@accent_color, 0.32); + box-shadow: + 0 2px 8px rgba(0, 0, 0, 0.30), + inset 0 1px 0 alpha(@window_fg_color, 0.10); +} +.tn-cal-new:hover { + background-color: alpha(@accent_color, 0.26); + border-color: alpha(@accent_color, 0.45); +} +.tn-cal-new label, +.tn-cal-new image { + color: @accent_color; +} + +.tn-cal .tn-nav-header { + font-size: 0.78em; + font-weight: 700; + color: alpha(@window_fg_color, 0.5); + text-transform: uppercase; + letter-spacing: 0.04em; + margin: 14px 6px 6px 6px; +} + +/* --- mini-month navigator ------------------------------------------------- */ +.tn-mini { + margin: 0 0 8px 0; +} + +.tn-mini-header { + margin: 2px 2px 4px 2px; +} + +.tn-mini-title { + font-size: 0.95em; + font-weight: 700; + color: @window_fg_color; + margin-left: 4px; +} + +.tn-mini-header button { + min-width: 26px; + min-height: 26px; + padding: 0; + color: alpha(@window_fg_color, 0.7); +} + +.tn-mini-grid { + margin: 0 1px; +} + +.tn-mini-dow { + font-size: 0.68em; + font-weight: 700; + color: alpha(@window_fg_color, 0.4); + padding-bottom: 2px; +} + +/* Each mini day is a tiny round button; today/selected/event states layer on. + * Fixed equal width/height (centred in its cell) → a true circle, not an + * ellipse stretched to the column. */ +.tn-mini-day { + font-size: 0.78em; + min-width: 28px; + min-height: 28px; + padding: 0; + margin: 0; + border-radius: 50%; + color: @window_fg_color; +} +.tn-mini-day:hover { + background-color: alpha(@window_fg_color, 0.08); +} +.tn-mini-day.other-month { + color: alpha(@window_fg_color, 0.28); +} +.tn-mini-day.today { + color: @accent_color; + font-weight: 800; +} +.tn-mini-day.selected { + background-color: @accent_color; + color: @accent_fg_color; + font-weight: 700; +} +.tn-mini-day.selected.today { + color: @accent_fg_color; +} +/* A small accent dot under days that hold events (no dot when selected). */ +.tn-mini-day.has-events:not(.selected) { + background-image: radial-gradient( + circle at 50% 86%, @accent_color 0, @accent_color 1.3px, transparent 1.6px); +} + +/* --- calendar show/hide list ---------------------------------------------- */ +.tn-cal-list { + padding: 2px; +} +.tn-cal-item { + padding: 6px 8px; + border-radius: 10px; + margin: 1px 0; +} +.tn-cal-item:hover { + background-color: alpha(@window_fg_color, 0.06); +} +.tn-cal-item-label { + color: @window_fg_color; + font-size: 0.92em; +} +/* Hidden calendar: dim the row + hollow out its colour dot. */ +.tn-cal-item.hidden .tn-cal-item-label { + color: alpha(@window_fg_color, 0.4); +} +.tn-cal-item.hidden .event-dot { + opacity: 0.3; +} +.tn-cal-list-empty { + color: alpha(@window_fg_color, 0.45); + font-size: 0.88em; + padding: 6px 8px; +} + +/* =================================================================== CONTENT */ +.tn-cal-content { + background-color: @window_bg_color; +} + +/* --- masthead ------------------------------------------------------------- */ +.tn-cal-masthead { + padding: 16px 20px 14px 24px; + border-bottom: 1px solid alpha(@window_fg_color, 0.07); +} +/* Same size as the sidebar "Calendar" brand (.tn-brand = 1.55em). */ +.tn-cal-month { + font-size: 1.55em; + font-weight: 800; + color: @window_fg_color; +} +.tn-cal-year { + font-size: 1.55em; + font-weight: 300; + color: alpha(@window_fg_color, 0.45); +} +.tn-cal-daysub { font-size: 1.05em; font-weight: 500; - color: #8a8a8a; - margin-left: 8px; + color: alpha(@window_fg_color, 0.55); + margin-left: 2px; +} +.tn-cal-masthead button.circular { + min-width: 32px; + min-height: 32px; + padding: 0; + color: @window_fg_color; +} +.tn-cal-masthead button.circular:hover { + background-color: alpha(@window_fg_color, 0.08); +} +.tn-cal-today { + padding: 5px 16px; + font-weight: 600; + color: @window_fg_color; + background-color: alpha(@window_fg_color, 0.08); +} +.tn-cal-today:hover { + background-color: alpha(@window_fg_color, 0.14); } -.cal-strip .year-title { - font-size: 1.9em; - font-weight: 300; - color: #8a8a8a; -} - -.cal-strip .cal-nav button { - min-width: 30px; - min-height: 30px; +/* Day | Week | Month segmented switcher. */ +.tn-segment { + margin-left: 6px; + border-radius: 999px; padding: 2px; - color: #f0f0f0; + background-color: alpha(@window_fg_color, 0.08); +} +.tn-segment-btn { + padding: 4px 14px; + border-radius: 999px; + min-height: 0; + background: transparent; + box-shadow: none; + border: none; + color: alpha(@window_fg_color, 0.7); + font-weight: 600; + font-size: 0.9em; +} +.tn-segment-btn:hover { + background-color: alpha(@window_fg_color, 0.06); +} +.tn-segment-btn:checked { + background-color: @accent_color; + color: @accent_fg_color; + box-shadow: 0 1px 3px alpha(@accent_color, 0.4); } -.cal-strip .cal-nav button:hover { - background-color: #242424; +/* --- warning banner ------------------------------------------------------- */ +.tn-cal-banner { + padding: 8px 18px; + background-color: alpha(#e0a000, 0.14); + color: @window_fg_color; + border-bottom: 1px solid alpha(#e0a000, 0.25); } -/* ------------------------------------------------------------------ dots --- */ +/* ===================================================================== DOTS */ .event-dot { min-width: 10px; min-height: 10px; border-radius: 9999px; - /* fallback: the dim UI foreground, so colourless calendars still show a dot */ background-color: alpha(@window_fg_color, 0.35); } - .chip-dot { min-width: 8px; min-height: 8px; } -/* ----------------------------------------------------------- month grid --- */ +/* =============================================================== MONTH GRID */ .month-grid { - background-color: #0f0f0f; + background-color: @window_bg_color; padding: 0; } - -/* Weekday header (Mon…Sun). */ .weekday-header { - color: #8a8a8a; + color: alpha(@window_fg_color, 0.55); font-weight: bold; - font-size: 0.85em; + font-size: 0.82em; text-transform: uppercase; letter-spacing: 0.04em; padding: 8px 6px; - background-color: #191919; + background-color: alpha(@window_fg_color, 0.04); } - /* One day cell. The 1px outline forms the grid's hairlines without doubling. */ .day-cell { - background-color: #0f0f0f; - border: 1px solid alpha(#f0f0f0, 0.08); + background-color: @window_bg_color; + border: 1px solid alpha(@window_fg_color, 0.07); padding: 4px 5px; - min-height: 88px; + min-height: 92px; } - -/* Days outside the current month are dimmed. */ .day-cell.other-month { - background-color: #0d0d0d; + background-color: alpha(@window_fg_color, 0.02); } - .day-cell.other-month .day-number { - color: alpha(#8a8a8a, 0.55); + color: alpha(@window_fg_color, 0.3); } - /* Today's cell: accent ring + subtle tinted background. */ .day-cell.today { - background-color: alpha(#a39ec4, 0.10); - border: 1px solid #a39ec4; + background-color: alpha(@accent_color, 0.10); + border: 1px solid @accent_color; } - .day-cell.today .day-number { - color: #a39ec4; + color: @accent_color; font-weight: bold; } - -/* The SELECTED cell (distinct from today): a brighter fill + inset ring so the - * user can tell which day's events the bottom agenda is showing. */ +/* The SELECTED cell (distinct from today). */ .day-cell.selected { - background-color: #242424; - border: 1px solid alpha(#f0f0f0, 0.30); + background-color: alpha(@window_fg_color, 0.07); + border: 1px solid alpha(@window_fg_color, 0.3); } - .day-cell.selected.today { - background-color: alpha(#a39ec4, 0.18); - border: 1px solid #a39ec4; + background-color: alpha(@accent_color, 0.18); + border: 1px solid @accent_color; } - .day-cell.selected .day-number { - color: #f0f0f0; + color: @window_fg_color; font-weight: bold; } - -/* Day number, top-left. */ .day-number { - color: #f0f0f0; + color: @window_fg_color; font-size: 0.9em; padding: 1px 2px; margin-bottom: 2px; } -/* ---------------------------------------------------------- event chips --- */ +/* --- event chips ---------------------------------------------------------- */ .event-chip { - background-color: #191919; - border-radius: 5px; + background-color: alpha(@window_fg_color, 0.06); + border-radius: 6px; padding: 1px 5px; min-height: 0; margin: 0; } - .event-chip:hover { - background-color: #232323; + background-color: alpha(@window_fg_color, 0.11); } - .chip-label { - color: #f0f0f0; + color: @window_fg_color; font-size: 0.82em; } - -/* The muted "+N more" line when a day overflows its chip budget. */ .more-label { - color: #8a8a8a; + color: alpha(@window_fg_color, 0.5); font-size: 0.78em; padding: 0 4px; } -/* ------------------------------------------------------------- day view --- */ -.day-agenda { - padding: 18px 16px 24px 16px; +/* ================================================ SELECTED-DAY AGENDA (Month) */ +/* Sits directly under the grid (same scroller), aligned to the grid width. */ +.tn-cal-page { + padding: 18px 0 24px 0; } - -.day-agenda .agenda-heading { - color: #f0f0f0; - font-size: 1.25em; - font-weight: 700; - margin-bottom: 6px; +.tn-cal-agenda-inner { + margin-top: 14px; + padding: 14px 6px 0 6px; + border-top: 1px solid alpha(@window_fg_color, 0.10); } - -.day-agenda .agenda-empty { - color: #8a8a8a; - font-size: 0.95em; - padding: 6px 2px; -} - -.agenda-row { - background-color: #191919; - border-radius: 8px; - padding: 8px 12px; - min-height: 0; - margin: 0; -} - -.agenda-row:hover { - background-color: #242424; -} - -.agenda-row .agenda-time { - color: #8a8a8a; - font-size: 0.88em; - font-variant-numeric: tabular-nums; -} - -.agenda-row .agenda-title { - color: #f0f0f0; - font-size: 0.98em; - font-weight: 500; -} - -.agenda-row .agenda-location { - color: #8a8a8a; - font-size: 0.82em; -} - -/* ------------------------------------------------------------ week view --- */ -.week-grid { - background-color: #0f0f0f; -} - -.week-column { - background-color: #0f0f0f; - border-left: 1px solid alpha(#f0f0f0, 0.07); - padding: 4px 5px 10px 5px; -} - -.week-column:first-child { - border-left: none; -} - -.week-column.today { - background-color: alpha(#a39ec4, 0.08); -} - -.week-column.selected { - background-color: #1d1d1d; -} - -.week-column.selected .week-col-header { - border-bottom-color: alpha(#f0f0f0, 0.30); -} - -.week-column.selected .week-col-header .week-col-date { - color: #f0f0f0; -} - -.week-col-header:hover { - background-color: alpha(#f0f0f0, 0.04); -} - -.week-col-header { - padding: 6px 2px 8px 2px; - margin-bottom: 2px; - border-bottom: 1px solid alpha(#f0f0f0, 0.07); -} - -.week-col-header .week-col-name { - color: #8a8a8a; - font-size: 0.78em; - font-weight: bold; - text-transform: uppercase; - letter-spacing: 0.04em; -} - -.week-col-header .week-col-date { - color: #f0f0f0; - font-size: 1.25em; - font-weight: 600; -} - -.week-column.today .week-col-header .week-col-date { - color: #a39ec4; - font-weight: bold; -} - -.week-column.today .week-col-header { - border-bottom-color: #a39ec4; -} - -/* ------------------------------------------------------ grid clamp -------- */ -/* The Adw.Clamp wrapping the month/week grid keeps the content centred on wide - * windows. A faint side gutter is fine; just give the grid a little breathing - * room so cells don't kiss the clamp edge. */ -.grid-clamp { - padding: 0 8px 8px 8px; -} - -/* --------------------------------------------- bottom selected agenda ----- */ -/* The always-visible bottom section listing the SELECTED day's events. Sits - * under the grid with a clear separator so it reads as its own panel. */ -.selected-agenda { - background-color: #0d0d0d; - border-top: 1px solid alpha(#f0f0f0, 0.10); -} - -.selected-agenda-scroller { - background-color: transparent; -} - -.selected-agenda-inner { - padding: 12px 12px 16px 12px; -} - -.selected-agenda .selected-heading { - color: #f0f0f0; +.tn-cal-agenda-heading { + color: @window_fg_color; font-size: 1.05em; font-weight: 700; margin-bottom: 4px; } - -.selected-agenda .selected-empty { - color: #8a8a8a; +.tn-cal-agenda-empty { + color: alpha(@window_fg_color, 0.5); font-size: 0.92em; - padding: 2px 2px; + padding: 2px; +} +.agenda-row { + background-color: @card_bg_color; + border: 1px solid alpha(@window_fg_color, 0.06); + border-radius: 10px; + padding: 8px 12px; + min-height: 0; + margin: 0; +} +.agenda-row:hover { + background-color: alpha(@window_fg_color, 0.08); +} +.agenda-time { + color: alpha(@window_fg_color, 0.55); + font-size: 0.88em; + font-variant-numeric: tabular-nums; +} +.agenda-title { + color: @window_fg_color; + font-size: 0.98em; + font-weight: 500; +} +.agenda-location { + color: alpha(@window_fg_color, 0.5); + font-size: 0.82em; } -/* ============================================================ time grid === */ -/* Day / Week time-grid views: a pinned header + all-day strip above a - * vertically-scrolling hour grid. Fuji palette throughout, GNOME-clean. */ - -/* --- pinned header row (weekday + date), stays above the scroller -------- */ +/* ================================================================ TIME GRID */ .timegrid-header { - background-color: #0f0f0f; - border-bottom: 1px solid alpha(#f0f0f0, 0.10); + background-color: @window_bg_color; + border-bottom: 1px solid alpha(@window_fg_color, 0.10); } - .timegrid-corner { - background-color: #0f0f0f; + background-color: @window_bg_color; } - .timegrid-col-header { - padding: 6px 4px 7px 4px; - border-left: 1px solid alpha(#f0f0f0, 0.06); + padding: 8px 4px 9px 4px; + border-left: 1px solid alpha(@window_fg_color, 0.06); } - .timegrid-col-header:hover { - background-color: alpha(#f0f0f0, 0.04); + background-color: alpha(@window_fg_color, 0.04); } - -.timegrid-col-header .timegrid-col-name { - color: #8a8a8a; +.timegrid-col-name { + color: alpha(@window_fg_color, 0.55); font-size: 0.78em; font-weight: bold; text-transform: uppercase; letter-spacing: 0.04em; } - -.timegrid-col-header .timegrid-col-date { - color: #f0f0f0; +.timegrid-col-date { + color: @window_fg_color; font-size: 1.25em; font-weight: 600; } - .timegrid-col-header.today .timegrid-col-name, .timegrid-col-header.today .timegrid-col-date { - color: #a39ec4; + color: @accent_color; font-weight: bold; } - .timegrid-col-header.selected { - background-color: #1d1d1d; + background-color: alpha(@window_fg_color, 0.06); } -/* --- thin all-day strip --------------------------------------------------- */ +/* --- all-day strip -------------------------------------------------------- */ .allday-row { - background-color: #0d0d0d; - border-bottom: 1px solid alpha(#f0f0f0, 0.10); + background-color: alpha(@window_fg_color, 0.02); + border-bottom: 1px solid alpha(@window_fg_color, 0.10); min-height: 30px; } - .allday-axis { - color: #8a8a8a; + color: alpha(@window_fg_color, 0.5); font-size: 0.7em; text-transform: uppercase; letter-spacing: 0.03em; padding-right: 8px; } - .allday-cell { - border-left: 1px solid alpha(#f0f0f0, 0.06); + border-left: 1px solid alpha(@window_fg_color, 0.06); padding: 2px 4px; } - .allday-cell.today { - background-color: alpha(#a39ec4, 0.08); + background-color: alpha(@accent_color, 0.08); } - .allday-chip { - background-color: #191919; - border-left: 3px solid #8a8a8a; - border-radius: 4px; + background-color: alpha(@window_fg_color, 0.06); + border-left: 3px solid alpha(@window_fg_color, 0.5); + border-radius: 5px; padding: 0 6px; min-height: 0; margin: 1px 0; } - .allday-chip:hover { - background-color: #232323; + background-color: alpha(@window_fg_color, 0.11); } - .allday-chip-label { - color: #f0f0f0; + color: @window_fg_color; font-size: 0.8em; } /* --- scrolling hour grid -------------------------------------------------- */ .timegrid-body { - background-color: #0f0f0f; + background-color: @window_bg_color; } - -/* The left hour axis with "08:00" labels. */ .time-axis { - background-color: #0f0f0f; - border-right: 1px solid alpha(#f0f0f0, 0.08); + background-color: @window_bg_color; + border-right: 1px solid alpha(@window_fg_color, 0.08); } - -.time-axis .hour-label { - color: #8a8a8a; +.hour-label { + color: alpha(@window_fg_color, 0.5); font-size: 0.72em; font-variant-numeric: tabular-nums; padding: 1px 6px 0 0; } - -/* One day's column holding the hour lines and positioned event blocks. */ .day-column { - background-color: #0f0f0f; - border-left: 1px solid alpha(#f0f0f0, 0.06); + background-color: @window_bg_color; + border-left: 1px solid alpha(@window_fg_color, 0.06); } - .day-column.today { - background-color: alpha(#a39ec4, 0.05); + background-color: alpha(@accent_color, 0.05); } - -/* Hairline separators: full opacity on the hour, fainter on the half-hour. */ .day-column .hour-line { - background-color: alpha(#f0f0f0, 0.09); + background-color: alpha(@window_fg_color, 0.09); } - .day-column .half-line { - background-color: alpha(#f0f0f0, 0.04); + background-color: alpha(@window_fg_color, 0.04); } - /* A positioned timed-event block (rounded, calendar-colour tinted). */ .event-block { - background-color: alpha(#a39ec4, 0.18); - border-left: 3px solid #a39ec4; - border-radius: 5px; + background-color: alpha(@accent_color, 0.18); + border-left: 3px solid @accent_color; + border-radius: 6px; padding: 2px 6px; min-height: 0; margin: 0; } - .event-block:hover { - background-color: alpha(#a39ec4, 0.30); + background-color: alpha(@accent_color, 0.30); } - -.event-block .event-block-time { - color: #f0f0f0; +.event-block-time { + color: @window_fg_color; font-size: 0.72em; font-variant-numeric: tabular-nums; opacity: 0.85; } - -.event-block .event-block-title { - color: #f0f0f0; +.event-block-title { + color: @window_fg_color; font-size: 0.82em; font-weight: 600; } - -/* The accent "now" line on today's column. */ .now-line { - background-color: #a39ec4; + background-color: @accent_color; } - -/* The translucent drag-to-create selection rectangle. */ .drag-selection { - background-color: alpha(#a39ec4, 0.25); - border: 1px solid #a39ec4; - border-radius: 4px; -} - -/* ----------------------------------------------------- context menus --- */ -/* Right-click popovers (event block: Delete/Details; time grid / month cell: - * New event). A compact column of flat, left-aligned menu rows. */ -.context-menu { - padding: 4px; - min-width: 140px; -} - -.context-menu-item { - padding: 6px 12px; + background-color: alpha(@accent_color, 0.25); + border: 1px solid @accent_color; border-radius: 6px; - color: #f0f0f0; +} + +/* =============================================================== CARDS/ROWS */ +/* Shared with the dialogs: rounded raised card holding hairline-split rows. */ +.tn-cal .tn-card, +.tn-cal-dialog .tn-card { + background-color: @card_bg_color; + border-radius: 14px; + border: 1px solid alpha(@window_fg_color, 0.07); + box-shadow: + 0 2px 6px rgba(0, 0, 0, 0.40), + 0 8px 28px rgba(0, 0, 0, 0.28), + inset 0 1px 0 alpha(@window_fg_color, 0.07); +} +.tn-cal-dialog .tn-row { + padding: 10px 16px; + min-height: 26px; +} +.tn-cal-dialog .tn-card > .tn-row:not(:last-child) { + border-bottom: 1px solid alpha(@window_fg_color, 0.10); +} +.tn-cal-dialog .tn-title { + color: @window_fg_color; font-weight: 500; } +.tn-cal-dialog .tn-subtitle { + color: alpha(@window_fg_color, 0.55); + font-size: 0.9em; +} +/* ================================================================== DIALOGS */ +.tn-cal-dialog-box { + padding: 22px 22px 20px 22px; +} +.tn-cal-dialog-title { + font-size: 1.4em; + font-weight: 800; + color: @window_fg_color; +} +.tn-cal-dialog entry { + background-color: alpha(@window_fg_color, 0.06); + border: 1px solid alpha(@window_fg_color, 0.10); + border-radius: 10px; + color: @window_fg_color; + padding: 6px 10px; +} +.tn-cal-dialog entry:focus-within { + border-color: @accent_color; +} +.tn-cal-dialog entry.error { + border-color: #ff453a; +} +/* HH : MM time picker — compact dropdowns with a tabular Iosevka readout. */ +.tn-cal-time-dd { + min-width: 58px; +} +.tn-cal-time-dd, +.tn-cal-time-sep { + font-family: "Iosevka Aile", "Iosevka", monospace; + font-variant-numeric: tabular-nums; +} +.tn-cal-time-sep { + font-weight: 700; + color: alpha(@window_fg_color, 0.6); +} +/* Accent the switch when on (GTK default would be blue without libadwaita). */ +.tn-cal-dialog switch:checked { + background-color: @accent_color; +} +/* Primary action (Save) = solid Fuji accent, not GTK's default blue. */ +.tn-cal-dialog button.suggested-action, +.tn-cal button.suggested-action { + background-color: @accent_color; + color: @accent_fg_color; + box-shadow: none; +} +.tn-cal-dialog button.suggested-action:hover, +.tn-cal button.suggested-action:hover { + background-color: shade(@accent_color, 0.92); +} + +/* =============================================================== STATUS PAGE */ +.tn-status-page { + padding: 36px 24px; +} +.tn-status-icon { + color: alpha(@window_fg_color, 0.3); + -gtk-icon-size: 96px; +} +.tn-status-desc { + color: alpha(@window_fg_color, 0.6); + font-size: 0.95em; +} +.title-1 { + font-size: 1.9em; + font-weight: 800; + color: @window_fg_color; +} + +/* ===================================================================== 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); +} + +/* ============================================================ CONTEXT MENUS */ +.context-menu { + padding: 4px; + min-width: 150px; +} +.context-menu-item { + padding: 6px 12px; + border-radius: 8px; + color: @window_fg_color; + font-weight: 500; +} .context-menu-item:hover { - background-color: #242424; + background-color: alpha(@window_fg_color, 0.10); } - -/* Destructive (Delete) row reads in a warm red, brighter on hover. */ .context-menu-item.destructive { - color: #e06c75; + color: #ff453a; +} +.context-menu-item.destructive:hover { + background-color: alpha(#ff453a, 0.16); + color: #ff453a; } -.context-menu-item.destructive:hover { - background-color: alpha(#e06c75, 0.18); - color: #e06c75; +/* ============================================================== FOCUS RING */ +*:focus-visible { + outline-color: @accent_color; } diff --git a/packaging/tanin-desktop/PKGBUILD b/packaging/tanin-desktop/PKGBUILD index 8dbeec3..7fd35a3 100644 --- a/packaging/tanin-desktop/PKGBUILD +++ b/packaging/tanin-desktop/PKGBUILD @@ -3,8 +3,9 @@ # Metapackage: pulls the complete TANINUX (camel) desktop — niri + eww, managed # by the TANINUX apps. Own packages come from the [tanin] repo, everything else # from the official Arch repos. AUR-only runtime deps (eww, tiramisu, waypaper, -# cliphist, librewolf-bin, arch-update, timeshift-autosnap, xdg-terminal-exec, -# paru) are rebuilt into [tanin] so this never hard-depends on the AUR. +# librewolf-bin, arch-update, timeshift-autosnap, xdg-terminal-exec, paru) are +# rebuilt into [tanin] so this never hard-depends on the AUR. (cliphist, awww, +# gtklock + its modules are in official extra -> pulled straight from Arch.) # Calamares just installs this package at the end. pkgname=tanin-desktop pkgver=0.2.0 @@ -40,8 +41,9 @@ depends=( 'xdg-desktop-portal-gtk' # Settings/Appearance portal (dark mode!) 'xdg-desktop-portal-gnome' # screencast backend recommended for niri - # --- shell / eww stack (eww, tiramisu, waypaper, cliphist via [tanin]) --- - 'eww' # bar/dock/panels/spotlight + # --- shell / eww stack (eww/tiramisu/waypaper via [tanin]; cliphist from extra) --- + 'eww' # bar/dock/panels/spotlight — [tanin] ships eww-git, + # which must `provides=(eww)` to satisfy this dep 'tiramisu-git' # headless notification daemon -> eww UI 'waypaper' # wallpaper (niri autostart: waypaper --restore) 'awww' # 2nd wallpaper surface -> blurred niri Overview backdrop (place-within-backdrop) diff --git a/packaging/tanin-eww/niri-config.kdl b/packaging/tanin-eww/niri-config.kdl index 0fa8f07..470f01c 100644 --- a/packaging/tanin-eww/niri-config.kdl +++ b/packaging/tanin-eww/niri-config.kdl @@ -14,11 +14,13 @@ input { mouse {} } -// Outputs — scale 1.25 like the Hyprland setup. Mode omitted on purpose so niri -// picks the highest refresh rate automatically (144 Hz on DP-3). Positions are -// left to niri's auto-placement. +// Outputs — scale 1.25 like the Hyprland setup. Mode is pinned explicitly: +// niri's "preferred" mode on DP-3 is only 60 Hz, so omitting it left the +// monitor at 59.951 Hz. We force the full 143.973 Hz panel rate (gaming). +// Positions are left to niri's auto-placement. output "DP-3" { scale 1.25 + mode "2560x1440@143.973" } output "HDMI-A-1" { scale 1.25 @@ -92,6 +94,18 @@ window-rule { clip-to-geometry true } +// ---- Gaming: Spiele direkt im echten Vollbild öffnen ---------------------- +// Steam/Proton-Titel (app-id "steam_app_") und gamescope gehen sofort +// fullscreen auf — ohne Gaps, Rundungen oder geometry-clip (würde sonst die +// Render-Surface beschneiden). Manuell jederzeit mit Mod+Shift+F umschaltbar. +window-rule { + match app-id="^steam_app_" + match app-id="^gamescope$" + open-fullscreen true + geometry-corner-radius 0 + clip-to-geometry false +} + // ---- geblurrtes Wallpaper im Overview-Backdrop ---------------------------- // Zwei Wallpaper-Surfaces: // * swaybg (namespace "wallpaper") -> scharf auf dem Desktop (waypaper) diff --git a/packaging/tanin-greet/tanin-greet.install b/packaging/tanin-greet/tanin-greet.install index d09b72e..0768b39 100644 --- a/packaging/tanin-greet/tanin-greet.install +++ b/packaging/tanin-greet/tanin-greet.install @@ -22,3 +22,21 @@ EOF post_upgrade() { _activate } + +# Package files (incl. the greeter binary greetd is pointed at) are gone by the +# time post_remove runs, so restore the pre-tanin greetd config we backed up in +# _activate — otherwise greetd keeps launching a deleted greeter and login breaks. +post_remove() { + local dst=/etc/greetd/config.toml + if [ -e "$dst.tanin-bak" ]; then + mv -f "$dst.tanin-bak" "$dst" + echo ">> tanin-greet: restored the previous greetd config from $dst.tanin-bak" >&2 + else + cat >&2 <<'EOF' +!! tanin-greet: WARNING — /etc/greetd/config.toml still points at the TANINUX +!! greeter, which was just REMOVED, and no backup (.tanin-bak) exists to restore. +!! greetd will fail to start (no login) until you edit /etc/greetd/config.toml +!! to reference an installed greeter (e.g. agreety) or disable greetd. +EOF + fi +}