"""Section 5: the full-width application drawer that replaces nwg-drawer. Top to bottom: a favourites row (full-width module), then search, then a FlowBox of all apps. Collapsed it is a bottom strip; expanded it fills down to the bottom. Right-click / long-press an app tile to pin or unpin it from favourites. """ from __future__ import annotations import gi gi.require_version("Gtk", "4.0") gi.require_version("AstalApps", "0.1") from gi.repository import AstalApps, GLib, Gtk # noqa: E402 from ui.favorites import Favorites, add_pin_gestures _STRIP_HEIGHT = 150 # collapsed grid height (logical px) class AppDrawer(Gtk.Box): def __init__(self, settings, on_launch, on_toggle_expand): super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8) self.add_css_class("appdrawer") self.settings = settings self._on_launch = on_launch self._on_toggle_expand = on_toggle_expand self._apps = AstalApps.Apps() self._expanded = False self.favorites = Favorites(settings, on_launch) self.append(self.favorites) self.append(Gtk.Separator()) self.append(self._build_header()) self._search = Gtk.SearchEntry(placeholder_text="Search applications") self._search.add_css_class("appdrawer-search") self._search.connect("search-changed", self._on_search) self.append(self._search) self._flow = Gtk.FlowBox( selection_mode=Gtk.SelectionMode.NONE, homogeneous=True, min_children_per_line=4, max_children_per_line=12, row_spacing=8, column_spacing=8, valign=Gtk.Align.START) # keep rows their natural height (no stretch) self._flow.add_css_class("appdrawer-flow") self._flow.connect("child-activated", self._on_child_activated) self._scroll = Gtk.ScrolledWindow( hscrollbar_policy=Gtk.PolicyType.NEVER, vexpand=True) self._scroll.set_propagate_natural_height(False) self._scroll.set_child(self._flow) self.append(self._scroll) self._apply_mode() 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: header = Gtk.CenterBox() header.add_css_class("appdrawer-header") title = Gtk.Label(label="Applications", xalign=0.0) title.add_css_class("section-title") header.set_start_widget(title) self._expand_btn = Gtk.Button(label="") # nf-fa-expand (uniform with quad expand) self._expand_btn.add_css_class("quad-action") self._expand_btn.set_tooltip_text("Expand") self._expand_btn.connect("clicked", lambda *_: self._toggle_expand()) header.set_end_widget(self._expand_btn) return header # -- expansion --------------------------------------------------------- def _apply_mode(self) -> None: if self._expanded: self.set_vexpand(True) self._scroll.set_min_content_height(_STRIP_HEIGHT) self._scroll.set_max_content_height(100000) self._expand_btn.set_label("") # nf-fa-compress else: self.set_vexpand(False) self._scroll.set_min_content_height(_STRIP_HEIGHT) self._scroll.set_max_content_height(_STRIP_HEIGHT) self._expand_btn.set_label("") # nf-fa-expand def _pop(self) -> None: # bouncy scale pop on the app grid when the drawer expands; cleared after the # animation so a re-layout (tiles loading) can't restart it into a loop. self._scroll.add_css_class("drawer-pop") GLib.timeout_add(320, self._clear_pop) def _clear_pop(self) -> bool: self._scroll.remove_css_class("drawer-pop") return GLib.SOURCE_REMOVE def _toggle_expand(self) -> None: self._expanded = not self._expanded self._apply_mode() if self._expanded: self._pop() self._on_toggle_expand(self._expanded) def set_expanded(self, value: bool) -> None: if value != self._expanded: self._toggle_expand() # -- data -------------------------------------------------------------- def _sorted_all(self) -> list: apps = list(self._apps.get_list()) apps.sort(key=lambda a: (-a.get_frequency(), a.get_name().lower())) return apps def _on_search(self, entry: Gtk.SearchEntry) -> None: text = entry.get_text().strip() results = self._apps.fuzzy_query(text) if text else self._sorted_all() self._populate(results) def _populate(self, apps: list) -> None: child = self._flow.get_first_child() while child: self._flow.remove(child) child = self._flow.get_first_child() for app in apps: self._flow.append(self._app_button(app)) def _app_button(self, app) -> Gtk.Widget: btn = Gtk.Button(valign=Gtk.Align.START) btn.add_css_class("app-tile") btn.app = app overlay = Gtk.Overlay() 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.set_pixel_size(48) label = Gtk.Label(label=app.get_name(), ellipsize=3, max_width_chars=12, justify=Gtk.Justification.CENTER) content.append(icon) content.append(label) 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)) add_pin_gestures(btn, lambda: self._toggle_fav(app)) return btn def _toggle_fav(self, app) -> None: self.settings.toggle_favorite(app.get_entry()) def _on_child_activated(self, _flow, child) -> None: btn = child.get_child() if btn and getattr(btn, "app", None): self._launch(btn.app) def _launch(self, app) -> None: try: app.launch() except Exception: pass self._on_launch() def on_show(self) -> None: self._search.set_text("") self._populate(self._sorted_all()) self.favorites.refresh() self._search.grab_focus()