fix(astal-menu): working favourites, 3-day weather, deploy-safe settings

Favourites:
- Pin/unpin from the drawer never worked: the right-click / long-press gestures
  were added to a Gtk.Button in the default (bubble) phase, where the button's own
  primary-click gesture swallowed them. Move them to the CAPTURE phase and claim the
  sequence (shared add_pin_gestures helper) so they fire reliably and don't also
  launch the app.
- Show a ★ on pinned tiles and re-render tiles when favourites change, so the state
  is visible and updates live.
- Verified end-to-end (right-click pins, right-click again unpins).

Weather: the expanded quad showed current conditions only. weather.sh used
`${2:-0}`, which rewrote the expanded view's intentionally-empty opts to "0"
(current only); use `${2-0}` so the empty value reaches wttr.in as its default
3-day forecast. Verified the expanded view now renders all three days.

Settings: move settings.json from CONFIG_DIR to XDG_STATE_HOME. The config-updater
`rm -rf`s ~/.config/astal-menu on every deploy, which was wiping pinned favourites
and quad toggles; the state dir is untouched by config sync. Migrates the old file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XUWCXM4KhjRkwheaA3X7bP
feat/astal-menu
Amir Alexander Abdelbaki 2026-07-05 23:35:23 +02:00
parent 16f75edb13
commit 3aafd2cc17
6 changed files with 62 additions and 18 deletions

View File

@ -5,7 +5,10 @@
set -uo pipefail set -uo pipefail
loc="${1:-}" loc="${1:-}"
opts="${2:-0}" # Note: ${2-0}, not ${2:-0} — the expanded view passes an *empty* opts to request
# wttr.in's default 3-day forecast, and :- would wrongly rewrite that empty string
# to "0" (current conditions only). Default to "0" only when no opts arg is given.
opts="${2-0}"
# URL-encode spaces in a city name. # URL-encode spaces in a city name.
loc="${loc// /+}" loc="${loc// /+}"

View File

@ -11,8 +11,12 @@ STYLE_DIR = BASE_DIR / "style"
CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "astal-menu" CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "astal-menu"
CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "astal-menu" CACHE_DIR = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "astal-menu"
# User settings live under XDG_STATE_HOME, NOT CONFIG_DIR: the config-updater does
# `rm -rf ~/.config/astal-menu` on every deploy, which would wipe pinned favourites
# and quad toggles. The state dir is never touched by config sync.
STATE_DIR = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "astal-menu"
SETTINGS_FILE = CONFIG_DIR / "settings.json" SETTINGS_FILE = STATE_DIR / "settings.json"
APP_ID = "eu.abdelbaki.astalmenu" APP_ID = "eu.abdelbaki.astalmenu"
@ -20,3 +24,4 @@ APP_ID = "eu.abdelbaki.astalmenu"
def ensure_dirs() -> None: def ensure_dirs() -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True) CONFIG_DIR.mkdir(parents=True, exist_ok=True)
CACHE_DIR.mkdir(parents=True, exist_ok=True) CACHE_DIR.mkdir(parents=True, exist_ok=True)
STATE_DIR.mkdir(parents=True, exist_ok=True)

View File

@ -10,7 +10,7 @@ from __future__ import annotations
import json import json
from typing import Callable from typing import Callable
from paths import SETTINGS_FILE, ensure_dirs from paths import CONFIG_DIR, SETTINGS_FILE, ensure_dirs
class Settings: class Settings:
@ -21,8 +21,14 @@ class Settings:
# -- persistence ------------------------------------------------------- # -- persistence -------------------------------------------------------
def load(self) -> None: def load(self) -> None:
path = SETTINGS_FILE
if not path.exists():
# one-time migration from the old CONFIG_DIR location (pre state-dir)
legacy = CONFIG_DIR / "settings.json"
if legacy.exists():
path = legacy
try: try:
self._data.update(json.loads(SETTINGS_FILE.read_text())) self._data.update(json.loads(path.read_text()))
except (FileNotFoundError, json.JSONDecodeError): except (FileNotFoundError, json.JSONDecodeError):
pass pass

View File

@ -124,6 +124,7 @@ button:checked.quad-action { background: @accent; color: @bg; border-color: @acc
} }
.app-tile:hover { border-color: @accent; background: alpha(@violet, 0.18); } .app-tile:hover { border-color: @accent; background: alpha(@violet, 0.18); }
.app-tile label { color: @text; font-size: 10pt; } .app-tile label { color: @text; font-size: 10pt; }
.fav-star { color: @accent; font-size: 13pt; margin: 2px 4px; }
/* ---- favourites row (top of drawer) --------------------------------- */ /* ---- favourites row (top of drawer) --------------------------------- */
.favorites-row { padding: 4px 0; } .favorites-row { padding: 4px 0; }

View File

@ -11,9 +11,9 @@ import gi
gi.require_version("Gtk", "4.0") gi.require_version("Gtk", "4.0")
gi.require_version("AstalApps", "0.1") gi.require_version("AstalApps", "0.1")
from gi.repository import AstalApps, Gtk # noqa: E402 from gi.repository import AstalApps, GLib, Gtk # noqa: E402
from ui.favorites import Favorites from ui.favorites import Favorites, add_pin_gestures
_STRIP_HEIGHT = 150 # collapsed grid height (logical px) _STRIP_HEIGHT = 150 # collapsed grid height (logical px)
@ -55,6 +55,12 @@ class AppDrawer(Gtk.Box):
self._apply_mode() self._apply_mode()
self._populate(self._sorted_all()) self._populate(self._sorted_all())
# re-render tiles when favourites change so the pinned star stays in sync
settings.subscribe(self._on_settings_changed)
def _on_settings_changed(self) -> None:
# Deferred so we never rebuild the FlowBox from inside a tile's own gesture.
GLib.idle_add(lambda: (self._on_search(self._search), False)[1])
def _build_header(self) -> Gtk.Widget: def _build_header(self) -> Gtk.Widget:
header = Gtk.CenterBox() header = Gtk.CenterBox()
@ -114,6 +120,7 @@ class AppDrawer(Gtk.Box):
btn = Gtk.Button(valign=Gtk.Align.START) btn = Gtk.Button(valign=Gtk.Align.START)
btn.add_css_class("app-tile") btn.add_css_class("app-tile")
btn.app = app btn.app = app
overlay = Gtk.Overlay()
content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
icon = Gtk.Image.new_from_icon_name(app.get_icon_name() or "application-x-executable") icon = Gtk.Image.new_from_icon_name(app.get_icon_name() or "application-x-executable")
icon.set_pixel_size(48) icon.set_pixel_size(48)
@ -121,15 +128,17 @@ class AppDrawer(Gtk.Box):
justify=Gtk.Justification.CENTER) justify=Gtk.Justification.CENTER)
content.append(icon) content.append(icon)
content.append(label) content.append(label)
btn.set_child(content) overlay.set_child(content)
# a star marks pinned apps (visible only when favourited)
star = Gtk.Label(label="")
star.add_css_class("fav-star")
star.set_halign(Gtk.Align.END)
star.set_valign(Gtk.Align.START)
star.set_visible(self.settings.is_favorite(app.get_entry()))
overlay.add_overlay(star)
btn.set_child(overlay)
btn.connect("clicked", lambda *_: self._launch(app)) btn.connect("clicked", lambda *_: self._launch(app))
# pin / unpin: right-click or long-press add_pin_gestures(btn, lambda: self._toggle_fav(app))
rclick = Gtk.GestureClick(button=3)
rclick.connect("released", lambda *_a: self._toggle_fav(app))
btn.add_controller(rclick)
longpress = Gtk.GestureLongPress()
longpress.connect("pressed", lambda *_a: self._toggle_fav(app))
btn.add_controller(longpress)
return btn return btn
def _toggle_fav(self, app) -> None: def _toggle_fav(self, app) -> None:

View File

@ -14,6 +14,28 @@ gi.require_version("AstalApps", "0.1")
from gi.repository import AstalApps, Gtk # noqa: E402 from gi.repository import AstalApps, Gtk # noqa: E402
def add_pin_gestures(widget: Gtk.Widget, on_toggle) -> None:
"""Wire right-click and long-press on `widget` to pin/unpin.
Uses the CAPTURE phase and claims the sequence so the gesture fires reliably on a
Gtk.Button (whose own primary-click gesture would otherwise swallow it) and does
not also trigger the button's launch action."""
def fire(gesture, *_a) -> None:
on_toggle()
gesture.set_state(Gtk.EventSequenceState.CLAIMED)
rclick = Gtk.GestureClick(button=3)
rclick.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
rclick.connect("pressed", fire)
widget.add_controller(rclick)
longpress = Gtk.GestureLongPress()
longpress.set_touch_only(False)
longpress.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
longpress.connect("pressed", fire)
widget.add_controller(longpress)
class Favorites(Gtk.Box): class Favorites(Gtk.Box):
def __init__(self, settings, on_launch): def __init__(self, settings, on_launch):
super().__init__(orientation=Gtk.Orientation.VERTICAL) super().__init__(orientation=Gtk.Orientation.VERTICAL)
@ -72,10 +94,8 @@ class Favorites(Gtk.Box):
content.append(Gtk.Label(label=app.get_name(), ellipsize=3, max_width_chars=14)) content.append(Gtk.Label(label=app.get_name(), ellipsize=3, max_width_chars=14))
btn.set_child(content) btn.set_child(content)
btn.connect("clicked", lambda *_a: self._launch(app)) btn.connect("clicked", lambda *_a: self._launch(app))
# right-click unpins # right-click / long-press unpins
gesture = Gtk.GestureClick(button=3) add_pin_gestures(btn, lambda: self.settings.toggle_favorite(app.get_entry()))
gesture.connect("released", lambda *_a: self.settings.toggle_favorite(app.get_entry()))
btn.add_controller(gesture)
return btn return btn
def _launch(self, app) -> None: def _launch(self, app) -> None: