From a4da4be30373e8632bf84d12fe3b7ef61482da29 Mon Sep 17 00:00:00 2001 From: itsvlxd Date: Sun, 16 Aug 2026 02:17:59 +0300 Subject: [PATCH 01/11] feat(wallpapers): add wallpaper preview, pagination & filtering --- cmds/tools/settings/pages/wallpapers.py | 285 +++++++++++++++++++++--- cmds/tools/settings/window.py | 263 ++++++++-------------- 2 files changed, 353 insertions(+), 195 deletions(-) diff --git a/cmds/tools/settings/pages/wallpapers.py b/cmds/tools/settings/pages/wallpapers.py index 54285c6c..02f7d423 100644 --- a/cmds/tools/settings/pages/wallpapers.py +++ b/cmds/tools/settings/pages/wallpapers.py @@ -1,5 +1,6 @@ """Wallpaper selection page — grid with thumbnails.""" +import math import os import re import subprocess @@ -123,6 +124,14 @@ def __init__(self, window): self._search_entry = None self._all_wallpapers: list[dict] = [] self._search_term = "" + self._page = 0 + self._page_size = 8 + self._columns = 2 + self._pag_prev: Gtk.Button | None = None + self._pag_next: Gtk.Button | None = None + self._pag_label: Gtk.Label | None = None + self._pag_size_dd: Gtk.DropDown | None = None + self._pag_cols_dd: Gtk.DropDown | None = None self._dirty = False self._notify_dirty = lambda: None self._pending_static: bool | None = None @@ -567,18 +576,14 @@ def build(self, header: Adw.HeaderBar | None = None) -> Gtk.Widget: content_box.append(pref_group) - search_entry = Gtk.SearchEntry() - search_entry.set_placeholder_text("Search wallpapers\u2026") - search_entry.set_margin_start(12) - search_entry.set_margin_end(12) - search_entry.set_margin_top(12) - search_entry.set_margin_bottom(4) - search_entry.connect("search-changed", self._on_search) - content_box.append(search_entry) + toolbar_row = self._build_toolbar_row() + content_box.append(toolbar_row) flow = Gtk.FlowBox() - flow.set_max_children_per_line(5) - flow.set_min_children_per_line(2) + flow.set_max_children_per_line(self._columns) + flow.set_min_children_per_line(self._columns) + flow.set_homogeneous(True) + flow.set_hexpand(True) flow.set_selection_mode(Gtk.SelectionMode.NONE) flow.set_column_spacing(10) flow.set_row_spacing(10) @@ -591,7 +596,7 @@ def build(self, header: Adw.HeaderBar | None = None) -> Gtk.Widget: self._content_box = content_box self._flow_box = flow - self._search_entry = search_entry + self._search_entry = self._toolbar_search_entry spinner_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) spinner_box.set_valign(Gtk.Align.CENTER) @@ -627,6 +632,7 @@ def _on_wallpapers_loaded(self, wallpapers: list[dict]) -> None: if self._spinner_box is not None and self._content_box is not None: self._content_box.remove(self._spinner_box) self._spinner_box = None + self._page = 0 self._rebuild() self._needs_opt = self._check_needs_optimization() self._refresh_opt_banner() @@ -641,18 +647,22 @@ def worker(): def _on_refreshed(self, wallpapers: list[dict]) -> None: self._all_wallpapers = wallpapers + self._page = 0 self._rebuild() self._refresh_opt_banner() def _on_search(self, _entry): term = self._search_entry.get_text().strip().lower() # type: ignore[union-attr] self._search_term = term if len(term) >= 3 else "" + self._page = 0 self._rebuild() def _make_flowbox(self) -> Gtk.FlowBox: flow = Gtk.FlowBox() - flow.set_max_children_per_line(5) - flow.set_min_children_per_line(2) + flow.set_max_children_per_line(self._columns) + flow.set_min_children_per_line(self._columns) + flow.set_homogeneous(True) + flow.set_hexpand(True) flow.set_selection_mode(Gtk.SelectionMode.NONE) flow.set_column_spacing(10) flow.set_row_spacing(10) @@ -663,6 +673,117 @@ def _make_flowbox(self) -> Gtk.FlowBox: flow.set_vexpand(True) return flow + def _build_toolbar_row(self) -> Gtk.Widget: + bar = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + bar.set_margin_start(12) + bar.set_margin_end(12) + bar.set_margin_top(12) + bar.set_margin_bottom(4) + + search_entry = Gtk.SearchEntry() + search_entry.set_placeholder_text("Search wallpapers\u2026") + search_entry.set_hexpand(True) + search_entry.connect("search-changed", self._on_search) + bar.append(search_entry) + self._toolbar_search_entry = search_entry + + cols_label = Gtk.Label(label="Columns") + cols_label.add_css_class("dim-label") + bar.append(cols_label) + cols_model = Gtk.StringList.new(["2", "3"]) + cols_dd = Gtk.DropDown(model=cols_model) + cols_dd.set_selected(0 if self._columns == 2 else 1) + cols_dd.connect("notify::selected", self._on_columns_changed) + bar.append(cols_dd) + self._pag_cols_dd = cols_dd + + size_label = Gtk.Label(label="Per page") + size_label.add_css_class("dim-label") + bar.append(size_label) + size_model = Gtk.StringList.new(["8", "12", "24", "48", "All"]) + size_dd = Gtk.DropDown(model=size_model) + size_dd.set_selected(self._page_size_index()) + size_dd.connect("notify::selected", self._on_page_size_changed) + bar.append(size_dd) + self._pag_size_dd = size_dd + + bar.append(Gtk.Separator(orientation=Gtk.Orientation.VERTICAL)) + + prev_btn = Gtk.Button(icon_name="go-previous-symbolic") + prev_btn.set_tooltip_text("Previous page") + prev_btn.add_css_class("flat") + prev_btn.connect("clicked", self._on_prev_page) + bar.append(prev_btn) + self._pag_prev = prev_btn + + page_label = Gtk.Label(label="1 / 1") + page_label.add_css_class("dim-label") + bar.append(page_label) + self._pag_label = page_label + + next_btn = Gtk.Button(icon_name="go-next-symbolic") + next_btn.set_tooltip_text("Next page") + next_btn.add_css_class("flat") + next_btn.connect("clicked", self._on_next_page) + bar.append(next_btn) + self._pag_next = next_btn + + return bar + + def _page_size_index(self) -> int: + options = [8, 12, 24, 48, 0] + try: + return options.index(self._page_size) + except ValueError: + return 1 + + def _page_size_value(self, index: int) -> int: + return {0: 8, 1: 12, 2: 24, 3: 48, 4: 0}.get(index, 8) + + def _on_columns_changed(self, dd: Gtk.DropDown, _pspec) -> None: + self._columns = 2 if dd.get_selected() == 0 else 3 + self._rebuild() + + def _on_page_size_changed(self, dd: Gtk.DropDown, _pspec) -> None: + if dd is None: + return + self._page_size = self._page_size_value(dd.get_selected()) + self._page = 0 + self._rebuild() + + def _on_prev_page(self, _btn) -> None: + if self._page > 0: + self._page -= 1 + self._rebuild() + + def _on_next_page(self, _btn) -> None: + total = self._total_pages() + if self._page < total - 1: + self._page += 1 + self._rebuild() + + def _total_pages(self) -> int: + matching = self._filtered_wallpapers() + if self._page_size == 0: + return 1 + return max(1, math.ceil(len(matching) / self._page_size)) + + def _filtered_wallpapers(self) -> list[dict]: + if not self._search_term: + return list(self._all_wallpapers) + return [w for w in self._all_wallpapers if self._search_term in w.get("_search_tags", "")] + + def _update_pagination_bar(self) -> None: + total = self._total_pages() + if self._page >= total: + self._page = total - 1 + if self._pag_label is not None: + self._pag_label.set_label(f"{self._page + 1} / {total}") + if self._pag_prev is not None: + self._pag_prev.set_sensitive(self._page > 0) + if self._pag_next is not None: + self._pag_next.set_sensitive(self._page < total - 1) + def get_search_entries(self) -> list[dict]: return [ { @@ -918,14 +1039,25 @@ def _re_enable() -> bool: def _rebuild(self): if self._content_box is None: return - # Build cards for matching wallpapers only - matching = self._all_wallpapers - if self._search_term: - matching = [w for w in matching if self._search_term in w.get("_search_tags", "")] + matching = self._filtered_wallpapers() + total = len(matching) + self._update_pagination_bar() new_flow = self._make_flowbox() - for wp in matching: + if self._page_size == 0: + page_items = matching + else: + lo = self._page * self._page_size + page_items = matching[lo:lo + self._page_size] + for wp in page_items: card = self._make_card(wp) new_flow.append(card) + if not page_items: + empty = Gtk.Label(label="No wallpapers found") + empty.add_css_class("dim-label") + empty.set_margin_top(24) + empty.set_margin_bottom(24) + empty.set_halign(Gtk.Align.CENTER) + new_flow.append(empty) # Replace old FlowBox if self._flow_box is not None: self._content_box.remove(self._flow_box) @@ -933,9 +1065,14 @@ def _rebuild(self): self._content_box.append(self._flow_box) self._flow_box.show() + def _thumb_hover(self, pic: Gtk.Widget, icon: Gtk.Widget, hovering: bool) -> None: + icon.set_visible(hovering) + pic.set_opacity(0.5 if hovering else 1.0) + def _make_card(self, wp: dict) -> Gtk.Widget: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - box.set_halign(Gtk.Align.CENTER) + box.set_halign(Gtk.Align.FILL) + box.set_hexpand(True) thumb_path = wp.get("thumb", "") try: @@ -944,16 +1081,36 @@ def _make_card(self, wp: dict) -> Gtk.Widget: pic.set_content_fit(Gtk.ContentFit.COVER) pic.add_css_class("wallpaper-thumb") pic.set_can_shrink(True) - pic.set_size_request(260, 146) + pic.set_hexpand(True) + pic.set_vexpand(True) + pic.set_size_request(0, 146) except Exception: pic = Gtk.Picture.new_for_filename("") - pic.set_size_request(260, 146) + pic.set_hexpand(True) + pic.set_vexpand(True) + pic.set_size_request(0, 146) + + thumb_overlay = Gtk.Overlay() + thumb_overlay.set_child(pic) + thumb_overlay.set_hexpand(True) + + preview_icon = Gtk.Image.new_from_icon_name("view-reveal-symbolic") + preview_icon.set_pixel_size(22) + preview_icon.set_halign(Gtk.Align.CENTER) + preview_icon.set_valign(Gtk.Align.CENTER) + preview_icon.set_visible(False) + thumb_overlay.add_overlay(preview_icon) + + thumb_motion = Gtk.EventControllerMotion.new() + thumb_motion.connect("enter", lambda *_a: self._thumb_hover(pic, preview_icon, True)) + thumb_motion.connect("leave", lambda *_a: self._thumb_hover(pic, preview_icon, False)) + thumb_overlay.add_controller(thumb_motion) name_label = Gtk.Label(label=wp.get("name", "")) name_label.set_ellipsize(3) # type: ignore[arg-type] name_label.set_max_width_chars(24) - box.append(pic) + box.append(thumb_overlay) name_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) name_box.set_halign(Gtk.Align.CENTER) @@ -997,6 +1154,12 @@ def _make_card(self, wp: dict) -> Gtk.Widget: actions_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) actions_box.set_halign(Gtk.Align.CENTER) + apply_btn = Gtk.Button(label="Apply") + apply_btn.add_css_class("suggested-action") + apply_btn.set_tooltip_text("Set this wallpaper") + apply_btn.set_sensitive(not wp.get("active", False)) + apply_btn.connect("clicked", self._on_apply, wp) + edit_btn = Gtk.Button(icon_name="document-edit-symbolic") edit_btn.set_tooltip_text("Rename") edit_btn.add_css_class("flat") @@ -1007,13 +1170,14 @@ def _make_card(self, wp: dict) -> Gtk.Widget: trash_btn.add_css_class("flat") trash_btn.connect("clicked", self._on_delete, wp) + actions_box.append(apply_btn) actions_box.append(edit_btn) actions_box.append(trash_btn) box.append(actions_box) gesture = Gtk.GestureClick() - gesture.connect("pressed", self._on_click, wp.get("path", ""), wp.get("name", "")) - pic.add_controller(gesture) + gesture.connect("pressed", self._on_preview, wp.get("path", ""), wp.get("name", "")) + thumb_overlay.add_controller(gesture) return box def _on_rename(self, _btn, wp: dict): @@ -1154,7 +1318,9 @@ def _selected_collection(self) -> str: return self._collections[idx] return get_var("RETRO_WALL_COLLECTION", "retro") - def _on_click(self, _gesture, _n_press, _x, _y, path: str, display: str): + def _on_apply(self, _btn, wp: dict): + path = wp.get("path", "") + display = wp.get("name", "") if not path or not Path(path).exists(): self._window.show_toast(f"Wallpaper not found: {display}", timeout=3) return @@ -1165,6 +1331,75 @@ def _on_click(self, _gesture, _n_press, _x, _y, path: str, display: str): ) GLib.child_watch_add(proc.pid, self._on_set_done, display) + def _on_preview(self, _gesture, _n_press, _x, _y, path: str, display: str): + if not path or not Path(path).exists(): + self._window.show_toast(f"Wallpaper not found: {display}", timeout=3) + return + + thumb = self._thumb_for(path) + dialog = Adw.Dialog() + dialog.set_title(display) + dialog.set_content_width(960) + dialog.set_content_height(600) + + toolbar = Adw.ToolbarView() + header = Adw.HeaderBar() + + apply_btn = Gtk.Button(label="Apply") + apply_btn.add_css_class("suggested-action") + apply_btn.set_tooltip_text("Set this wallpaper") + apply_btn.connect( + "clicked", + lambda _b: (self._apply_path(path, display), dialog.close()), + ) + header.pack_start(apply_btn) + + close_btn = Gtk.Button(label="Close") + close_btn.connect("clicked", lambda _b: dialog.close()) + header.pack_end(close_btn) + + toolbar.add_top_bar(header) + + clamp = Adw.Clamp() + clamp.set_maximum_size(960) + clamp.set_tightening_threshold(700) + + try: + pb = GdkPixbuf.Pixbuf.new_from_file_at_scale(thumb, 1920, 1080, True) + pic = Gtk.Picture.new_for_pixbuf(pb) + except Exception: + pic = Gtk.Picture.new_for_filename("") + pic.set_content_fit(Gtk.ContentFit.CONTAIN) + pic.set_can_shrink(True) + pic.set_hexpand(True) + pic.set_vexpand(True) + pic.set_margin_top(16) + pic.set_margin_bottom(16) + pic.set_margin_start(16) + pic.set_margin_end(16) + + clamp.set_child(pic) + scrolled = Gtk.ScrolledWindow() + scrolled.set_child(clamp) + scrolled.set_vexpand(True) + toolbar.set_content(scrolled) + dialog.set_child(toolbar) + dialog.present(self._window) + + def _apply_path(self, path: str, display: str) -> None: + proc = subprocess.Popen( + ["bash", str(WALLPAPER_CORE), "--set", path], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + GLib.child_watch_add(proc.pid, self._on_set_done, display) + + def _thumb_for(self, path: str) -> str: + name = os.path.basename(path) + thumb = FRAME_CACHE / f"{name}.png" + if thumb.is_file(): + return str(thumb) + return "" + def _on_set_done(self, pid: int, status: int, display: str) -> None: if status == 0: self._window.show_toast(f"Wallpaper set: {display}", timeout=2) diff --git a/cmds/tools/settings/window.py b/cmds/tools/settings/window.py index febf8f3f..e8750b5d 100644 --- a/cmds/tools/settings/window.py +++ b/cmds/tools/settings/window.py @@ -459,49 +459,6 @@ def _build_content_pane(self): def _build_pages(self) -> tuple[list[dict], dict[str, dict]]: """Build the default schema page eagerly, defer the rest.""" - # Lazy page imports — deferred to avoid pulling in 20+ page modules at startup - from settings.pages.about import AboutPage - from settings.pages.apps import AppsPage - from settings.pages.audio import AudioPage - from settings.pages.autostart import AutostartPage - from settings.pages.battery import BatteryPage - from settings.pages.binds import BindsPage - from settings.pages.bluetooth import BluetoothPage - from settings.pages.changelog import ChangelogPage - from settings.pages.daemon import DaemonPage - from settings.pages.disk import DiskPage - from settings.pages.driver import DriverPage - from settings.pages.env_vars import EnvVarsPage - from settings.pages.fonts import FontsPage - from settings.pages.grub import GrubPage - from settings.pages.home import HomePage, PAGE_REGISTRY - from settings.pages.layer_rules import LayerRulesPage - from settings.pages.layouts import LayoutsPage - from settings.pages.logs import LogsPage - from settings.pages.misc import MiscPage - from settings.pages.monitors import MonitorsPage - from settings.pages.network import NetworkPage - from settings.pages.pending import PendingChangesPage - from settings.pages.power import PowerPage - from settings.pages.quickshare import QuickSharePage - from settings.pages.settings import SettingsPage - from settings.pages.shell_bar import ShellBarPage - from settings.pages.shell_desktop import ShellDesktopPage - from settings.pages.shell_dock import ShellDockPage - from settings.pages.shell_frame import ShellFramePage - from settings.pages.shell_lock import ShellLockPage - from settings.pages.shell_notch import ShellNotchPage - from settings.pages.shell_overview import ShellOverviewPage - from settings.pages.shell_presets import ShellPresetsPage - from settings.pages.shell_sidebar import ShellSidebarPage - from settings.pages.shell_theme import ShellThemePage - from settings.pages.shell_workspaces import ShellWorkspacesPage - from settings.pages.themes import ThemesPage - from settings.pages.users import UsersPage - from settings.pages.wallpapers import WallpapersPage - from settings.pages.window_rules import WindowRulesPage - from settings.pages.workspaces import WorkspacesPage - from settings.pages.xdg import XdgPage _bt0 = time.monotonic() self._page_titles: dict[str, str] = {} groups = schema.get_groups(self._schema) @@ -531,32 +488,23 @@ def _build_pages(self) -> tuple[list[dict], dict[str, dict]]: _bt1 = time.monotonic() print(f'[TIMING] schema_groups={_bt1-_bt0:.3f}s', file=__import__('sys').stderr) - # Store section page specs for lazy building. - section_page_specs: list[tuple[type, str, str, str]] = [ - (BindsPage, "_binds_page", "binds", "Keybinds"), - (MonitorsPage, "_monitors_page", "monitors", "Monitors"), - (WorkspacesPage, "_workspaces_page", "workspaces", "Workspaces"), - (EnvVarsPage, "_env_vars_page", "env_vars", "Env Variables"), + section_page_specs: list[tuple[str, str, str, str, str]] = [ + ("settings.pages.binds", "BindsPage", "_binds_page", "binds", "Keybinds"), + ("settings.pages.monitors", "MonitorsPage", "_monitors_page", "monitors", "Monitors"), + ("settings.pages.workspaces", "WorkspacesPage", "_workspaces_page", "workspaces", "Workspaces"), + ("settings.pages.env_vars", "EnvVarsPage", "_env_vars_page", "env_vars", "Env Variables"), + ("settings.pages.window_rules", "WindowRulesPage", "_window_rules_page", "window_rules", "Window Rules"), + ("settings.pages.layer_rules", "LayerRulesPage", "_layer_rules_page", "layer_rules", "Layer Rules"), + ("settings.pages.autostart", "AutostartPage", "_autostart_page", "autostart", "Autostart"), ] - for cls, attr, slug, title in section_page_specs: - self._lazy_section_specs[slug] = (cls, attr, title) - - for cls, attr, slug, title in [ - (WindowRulesPage, "_window_rules_page", "window_rules", "Window Rules"), - (LayerRulesPage, "_layer_rules_page", "layer_rules", "Layer Rules"), - (AutostartPage, "_autostart_page", "autostart", "Autostart"), - ]: - page = cls(self, on_dirty_changed=self._on_section_dirty, push_undo=self._undo.push, saved_sections=self.saved_sections) - setattr(self, attr, page) - widget = page.build(header=self._make_page_header(title)) - self._page_stack.add_named(widget, slug) - self._page_titles[slug] = title - self._section_pages.append(page) + for module, cls_name, attr, slug, title in section_page_specs: + self._lazy_section_specs[slug] = (module, cls_name, attr, title) from settings.pages.cursor import CursorPage as _CursorPage self._search_page_builder.add_entries(_CursorPage.get_search_entries()) # Pages are searchable too: every registered page gets a result row # that navigates straight to it (see SearchPage.build_results_widget). + from settings.pages.home import PAGE_REGISTRY self._search_page_builder.add_entries( [ { @@ -575,48 +523,48 @@ def _build_pages(self) -> tuple[list[dict], dict[str, dict]]: print(f'[TIMING] section_pages={_bt2-_bt1:.3f}s', file=__import__('sys').stderr) # Store standalone page specs for lazy building - standalone_page_specs: list[tuple[type, str, str, str]] = [ - (HomePage, "_home_page", "home", "Home"), - (AboutPage, "_about_page", "about", "About"), - (ChangelogPage, "_changelog_page", "changelog", "Changelog"), - (AppsPage, "_apps_page", "apps", "Applications"), - (AudioPage, "_audio_page", "audio", "Audio"), - (BluetoothPage, "_bluetooth_page", "bluetooth", "Bluetooth"), - (NetworkPage, "_network_page", "network", "Network"), - (DaemonPage, "_daemon_page", "daemon", "Daemon"), - (DiskPage, "_disk_page", "disks", "Disks"), - (DriverPage, "_driver_page", "driver", "Drivers"), - (FontsPage, "_fonts_page", "fonts", "Fonts"), - (GrubPage, "_grub_page", "grub", "Bootloader"), - (LogsPage, "_logs_page", "logs", "Logs"), - (LayoutsPage, "_layouts_page", "layouts", "Layouts"), - (PowerPage, "_power_page", "power", "Power"), - (QuickSharePage, "_quickshare_page", "quickshare", "Quick Share"), - (PendingChangesPage, "_pending_page", "pending", "Pending Changes"), - (ShellBarPage, "_shell_bar_page", "shell_bar", "Bar"), - (ShellDesktopPage, "_shell_desktop_page", "shell_desktop", "Desktop"), - (ShellDockPage, "_shell_dock_page", "shell_dock", "Dock"), - (ShellThemePage, "_shell_theme_page", "shell_theme", "Theme"), - (ShellNotchPage, "_shell_notch_page", "shell_notch", "Notch"), - (ShellSidebarPage, "_shell_sidebar_page", "shell_sidebar", "Sidebar"), - (ShellFramePage, "_shell_frame_page", "shell_frame", "Frame"), - (ShellLockPage, "_shell_lock_page", "shell_lock", "Lockscreen"), - (ShellWorkspacesPage, "_shell_workspaces_page", "shell_workspaces", "Workspaces"), - (ShellOverviewPage, "_shell_overview_page", "shell_overview", "Overview"), - (MiscPage, "_misc_page", "misc", "Miscellaneous"), - (ShellPresetsPage, "_shell_presets_page", "shell_presets", "Presets"), - (ThemesPage, "_themes_page", "themes", "Themes"), - (WallpapersPage, "_wallpapers_page", "wallpapers", "Wallpapers"), - (UsersPage, "_users_page", "users", "Users"), - (SettingsPage, "_settings_page", "settings", "Settings"), - (XdgPage, "_xdg_page", "xdg", "Default Apps"), + standalone_page_specs: list[tuple[str, str, str, str]] = [ + ("settings.pages.home", "HomePage", "_home_page", "home", "Home"), + ("settings.pages.about", "AboutPage", "_about_page", "about", "About"), + ("settings.pages.changelog", "ChangelogPage", "_changelog_page", "changelog", "Changelog"), + ("settings.pages.apps", "AppsPage", "_apps_page", "apps", "Applications"), + ("settings.pages.audio", "AudioPage", "_audio_page", "audio", "Audio"), + ("settings.pages.bluetooth", "BluetoothPage", "_bluetooth_page", "bluetooth", "Bluetooth"), + ("settings.pages.network", "NetworkPage", "_network_page", "network", "Network"), + ("settings.pages.daemon", "DaemonPage", "_daemon_page", "daemon", "Daemon"), + ("settings.pages.disk", "DiskPage", "_disk_page", "disks", "Disks"), + ("settings.pages.driver", "DriverPage", "_driver_page", "driver", "Drivers"), + ("settings.pages.fonts", "FontsPage", "_fonts_page", "fonts", "Fonts"), + ("settings.pages.grub", "GrubPage", "_grub_page", "grub", "Bootloader"), + ("settings.pages.logs", "LogsPage", "_logs_page", "logs", "Logs"), + ("settings.pages.layouts", "LayoutsPage", "_layouts_page", "layouts", "Layouts"), + ("settings.pages.power", "PowerPage", "_power_page", "power", "Power"), + ("settings.pages.quickshare", "QuickSharePage", "_quickshare_page", "quickshare", "Quick Share"), + ("settings.pages.pending", "PendingChangesPage", "_pending_page", "pending", "Pending Changes"), + ("settings.pages.shell_bar", "ShellBarPage", "_shell_bar_page", "shell_bar", "Bar"), + ("settings.pages.shell_desktop", "ShellDesktopPage", "_shell_desktop_page", "shell_desktop", "Desktop"), + ("settings.pages.shell_dock", "ShellDockPage", "_shell_dock_page", "shell_dock", "Dock"), + ("settings.pages.shell_theme", "ShellThemePage", "_shell_theme_page", "shell_theme", "Theme"), + ("settings.pages.shell_notch", "ShellNotchPage", "_shell_notch_page", "shell_notch", "Notch"), + ("settings.pages.shell_sidebar", "ShellSidebarPage", "_shell_sidebar_page", "shell_sidebar", "Sidebar"), + ("settings.pages.shell_frame", "ShellFramePage", "_shell_frame_page", "shell_frame", "Frame"), + ("settings.pages.shell_lock", "ShellLockPage", "_shell_lock_page", "shell_lock", "Lockscreen"), + ("settings.pages.shell_workspaces", "ShellWorkspacesPage", "_shell_workspaces_page", "shell_workspaces", "Workspaces"), + ("settings.pages.shell_overview", "ShellOverviewPage", "_shell_overview_page", "shell_overview", "Overview"), + ("settings.pages.misc", "MiscPage", "_misc_page", "misc", "Miscellaneous"), + ("settings.pages.shell_presets", "ShellPresetsPage", "_shell_presets_page", "shell_presets", "Presets"), + ("settings.pages.themes", "ThemesPage", "_themes_page", "themes", "Themes"), + ("settings.pages.wallpapers", "WallpapersPage", "_wallpapers_page", "wallpapers", "Wallpapers"), + ("settings.pages.users", "UsersPage", "_users_page", "users", "Users"), + ("settings.pages.settings", "SettingsPage", "_settings_page", "settings", "Settings"), + ("settings.pages.xdg", "XdgPage", "_xdg_page", "xdg", "Default Apps"), ] import os if any(f.startswith("BAT") for f in os.listdir("/sys/class/power_supply/") if os.path.isdir("/sys/class/power_supply/")): - standalone_page_specs.insert(5, (BatteryPage, "_battery_page", "battery", "Battery")) + standalone_page_specs.insert(5, ("settings.pages.battery", "BatteryPage", "_battery_page", "battery", "Battery")) - for cls, attr, slug, title in standalone_page_specs: - self._lazy_standalone_specs[slug] = (cls, attr, title) + for module, cls_name, attr, slug, title in standalone_page_specs: + self._lazy_standalone_specs[slug] = (module, cls_name, attr, title) _bt3 = time.monotonic() print(f'[TIMING] standalone_pages={_bt3-_bt2:.3f}s', file=__import__('sys').stderr) @@ -1240,182 +1188,157 @@ def _build_lazy_group_page(self, gid: str): self._section_pages.append(self._animations_page) self._refresh_all_modified_indicators() + @staticmethod + def _resolve_page_class(module: str, cls_name: str) -> type: + """Import a page class on demand from a ``module`` + class name.""" + import importlib + mod = importlib.import_module(module) + return getattr(mod, cls_name) + def _build_lazy_section_page(self, slug: str): """Build a deferred section page (binds, monitors, etc.).""" - cls, attr, title = self._lazy_section_specs.pop(slug) + module, cls_name, attr, title = self._lazy_section_specs.pop(slug) + cls = self._resolve_page_class(module, cls_name) page = cls(self, on_dirty_changed=self._on_section_dirty, push_undo=self._undo.push, saved_sections=self.saved_sections) setattr(self, attr, page) widget = page.build(header=self._make_page_header(title)) self._page_stack.add_named(widget, slug) self._page_titles[slug] = title self._section_pages.append(page) - from settings.pages.monitors import MonitorsPage - if cls is MonitorsPage: + if cls_name == "MonitorsPage": self._search_page_builder.add_entries(page.get_search_entries()) for key in self._option_rows: self._sync_option_row(key) def _build_lazy_standalone_page(self, slug: str): """Build a deferred standalone page (layouts, pending, wallpapers, settings).""" - from settings.pages.about import AboutPage - from settings.pages.apps import AppsPage - from settings.pages.audio import AudioPage - from settings.pages.battery import BatteryPage - from settings.pages.bluetooth import BluetoothPage - from settings.pages.changelog import ChangelogPage - from settings.pages.daemon import DaemonPage - from settings.pages.disk import DiskPage - from settings.pages.driver import DriverPage - from settings.pages.fonts import FontsPage - from settings.pages.grub import GrubPage - from settings.pages.logs import LogsPage - from settings.pages.misc import MiscPage - from settings.pages.network import NetworkPage - from settings.pages.pending import PendingChangesPage - from settings.pages.power import PowerPage - from settings.pages.quickshare import QuickSharePage - from settings.pages.shell_bar import ShellBarPage - from settings.pages.shell_desktop import ShellDesktopPage - from settings.pages.shell_dock import ShellDockPage - from settings.pages.shell_frame import ShellFramePage - from settings.pages.shell_lock import ShellLockPage - from settings.pages.shell_notch import ShellNotchPage - from settings.pages.shell_overview import ShellOverviewPage - from settings.pages.shell_presets import ShellPresetsPage - from settings.pages.shell_sidebar import ShellSidebarPage - from settings.pages.shell_theme import ShellThemePage - from settings.pages.shell_workspaces import ShellWorkspacesPage - from settings.pages.settings import SettingsPage - from settings.pages.themes import ThemesPage - from settings.pages.users import UsersPage - from settings.pages.wallpapers import WallpapersPage - from settings.pages.xdg import XdgPage - cls, attr, title = self._lazy_standalone_specs.pop(slug) + module, cls_name, attr, title = self._lazy_standalone_specs.pop(slug) + cls = self._resolve_page_class(module, cls_name) page = cls(self) setattr(self, attr, page) - with_chip = cls is not PendingChangesPage + with_chip = cls_name != "PendingChangesPage" header = self._make_page_header(title, with_pending_chip=with_chip) widget = page.build(header=header) self._page_stack.add_named(widget, slug) self._page_titles[slug] = title - if cls is AppsPage: + if cls_name == "AppsPage": page._notify_dirty = self._on_section_dirty # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is WallpapersPage: + elif cls_name == "WallpapersPage": page._notify_dirty = self._on_section_dirty # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is ThemesPage: + elif cls_name == "ThemesPage": page._notify_dirty = self._on_section_dirty # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is DiskPage: + elif cls_name == "DiskPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is DriverPage: + elif cls_name == "DriverPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is FontsPage: + elif cls_name == "FontsPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is GrubPage: + elif cls_name == "GrubPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is PowerPage: + elif cls_name == "PowerPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is BatteryPage: + elif cls_name == "BatteryPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is BluetoothPage: + elif cls_name == "BluetoothPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is NetworkPage: + elif cls_name == "NetworkPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is DaemonPage: + elif cls_name == "DaemonPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is LogsPage: + elif cls_name == "LogsPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is AudioPage: + elif cls_name == "AudioPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is ShellBarPage: + elif cls_name == "ShellBarPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is ShellDesktopPage: + elif cls_name == "ShellDesktopPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is ShellDockPage: + elif cls_name == "ShellDockPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is ShellThemePage: + elif cls_name == "ShellThemePage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is ShellSidebarPage: + elif cls_name == "ShellSidebarPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is ShellNotchPage: + elif cls_name == "ShellNotchPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is ShellFramePage: + elif cls_name == "ShellFramePage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is ShellLockPage: + elif cls_name == "ShellLockPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is ShellWorkspacesPage: + elif cls_name == "ShellWorkspacesPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is ShellOverviewPage: + elif cls_name == "ShellOverviewPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is MiscPage: + elif cls_name == "MiscPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is ShellPresetsPage: + elif cls_name == "ShellPresetsPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is AboutPage: + elif cls_name == "AboutPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is UsersPage: + elif cls_name == "UsersPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is QuickSharePage: + elif cls_name == "QuickSharePage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is SettingsPage: + elif cls_name == "SettingsPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) - elif cls is XdgPage: + elif cls_name == "XdgPage": page._on_dirty_changed = self._on_section_dirty # type: ignore[attr-defined] self._section_pages.append(page) # type: ignore[attr-defined] self._search_page_builder.add_entries(page.get_search_entries()) From 65f996cd324de0b3bb5dcfd54b0f8cf1f9067719 Mon Sep 17 00:00:00 2001 From: itsvlxd Date: Sun, 16 Aug 2026 12:48:47 +0300 Subject: [PATCH 02/11] feat(settings): add wallpapers live preview\ --- cmds/tools/settings/core/shell_config.py | 1 + cmds/tools/settings/pages/misc.py | 23 +++ cmds/tools/settings/pages/wallpapers.py | 222 +++++++++++++++++++---- cmds/tools/settings/style.css | 16 ++ 4 files changed, 227 insertions(+), 35 deletions(-) diff --git a/cmds/tools/settings/core/shell_config.py b/cmds/tools/settings/core/shell_config.py index 8b83d550..5894ded8 100644 --- a/cmds/tools/settings/core/shell_config.py +++ b/cmds/tools/settings/core/shell_config.py @@ -741,6 +741,7 @@ def save_lockscreen(data: dict) -> None: "recordingFps": 60, "recordingPortalEnabled": False, "emojiShowRecent": True, + "wallpaperAnimatedPreview": True, } diff --git a/cmds/tools/settings/pages/misc.py b/cmds/tools/settings/pages/misc.py index f1ce129e..65887515 100644 --- a/cmds/tools/settings/pages/misc.py +++ b/cmds/tools/settings/pages/misc.py @@ -126,6 +126,25 @@ def build(self, header: Adw.HeaderBar) -> Adw.ToolbarView: self._add_perf_switch(perf_group, key, label, subtitle=sub) content_box.append(perf_group) + wall_group = Adw.PreferencesGroup( + title="Wallpapers", + description="Wallpaper-related shell options.", + ) + wall_anim_row = Adw.SwitchRow( + title="Animated Wallpaper Previews", + subtitle="Show animated GIF previews for video wallpapers in the shell", + ) + wall_anim_row.set_active(bool(self._tools.get("wallpaperAnimatedPreview", TOOLS_DEFAULTS["wallpaperAnimatedPreview"]))) + wall_group.add(wall_anim_row) + self._wall_anim_row = wall_anim_row + + def _wall_anim_changed(*_args): + self._tools["wallpaperAnimatedPreview"] = wall_anim_row.get_active() + self._notify_dirty() + wall_anim_row.connect("notify::active", _wall_anim_changed) + + content_box.append(wall_group) + # ── Screenshot & Recording ── tools_group = Adw.PreferencesGroup( title="Screenshot and Recording", @@ -560,6 +579,7 @@ def discard(self) -> None: self._timer_enabled_row.set_active(self._tools.get("screenshotTimerEnabled", TOOLS_DEFAULTS["screenshotTimerEnabled"])) self._portal_row.set_active(self._tools.get("recordingPortalEnabled", TOOLS_DEFAULTS["recordingPortalEnabled"])) self._skin_tone_row.set_active(self._tools.get("emojiShowRecent", TOOLS_DEFAULTS["emojiShowRecent"])) + self._wall_anim_row.set_active(self._tools.get("wallpaperAnimatedPreview", TOOLS_DEFAULTS["wallpaperAnimatedPreview"])) if hasattr(self, "_dir_entries"): for key, entry in self._dir_entries.items(): entry.set_text(self._tools.get(key, TOOLS_DEFAULTS[key])) @@ -607,6 +627,9 @@ def get_search_entries(self) -> list[dict]: {"key": "misc:tools", "label": "Screenshot & Recording", "description": "Capture settings, save locations, and timed countdown", "_group_id": "misc", "_group_label": "Miscellaneous", "_section_label": "Screenshot & Recording"}, + {"key": "misc:wallpapers", "label": "Animated Wallpaper Previews", + "description": "Show animated GIF previews for video wallpapers in the shell", + "_group_id": "misc", "_group_label": "Miscellaneous", "_section_label": "Wallpapers"}, ] diff --git a/cmds/tools/settings/pages/wallpapers.py b/cmds/tools/settings/pages/wallpapers.py index 02f7d423..627b98e5 100644 --- a/cmds/tools/settings/pages/wallpapers.py +++ b/cmds/tools/settings/pages/wallpapers.py @@ -7,9 +7,10 @@ import threading from pathlib import Path -from gi.repository import Adw, GdkPixbuf, GLib, GObject, Gtk +from gi.repository import Adw, Gdk, GdkPixbuf, GLib, GObject, Gtk from lib.python.variable import get_var, get_module_default +from settings.core.shell_config import load_tools, save_tools from settings.ui import make_page_layout from settings.ui.row_actions import RowActions @@ -49,14 +50,17 @@ def _list_wallpapers(collection: str | None = None) -> list[dict]: is_video = orig_name.endswith((".mp4", ".mkv", ".webm")) display = Path(orig_name).stem.replace("-", " ").title() tags = f'{display.lower()} {"live" if is_video else "static"} {res}' + gif_path = FRAME_CACHE / f"{orig_name}.gif" if is_video else None wallpapers.append({ "name": display, "path": full_path, "orig_name": orig_name, "thumb": str(f), + "gif": str(gif_path) if gif_path and gif_path.is_file() else "", "active": orig_name == os.path.basename(current), "type": "live" if is_video else "static", "resolution": res, + "subfolder": str(Path(full_path).parent.name), "_search_tags": tags, }) wallpapers.sort(key=lambda w: (0 if w["active"] else 1, w["name"])) @@ -125,8 +129,18 @@ def __init__(self, window): self._all_wallpapers: list[dict] = [] self._search_term = "" self._page = 0 - self._page_size = 8 - self._columns = 2 + try: + self._page_size = int(get_var("WALL_SETTINGS_PAGE_SIZE", "8")) + if self._page_size not in (8, 12, 24, 48, 0): + self._page_size = 8 + except (ValueError, TypeError): + self._page_size = 8 + try: + self._columns = int(get_var("WALL_SETTINGS_COLUMNS", "2")) + if self._columns not in (2, 3): + self._columns = 2 + except (ValueError, TypeError): + self._columns = 2 self._pag_prev: Gtk.Button | None = None self._pag_next: Gtk.Button | None = None self._pag_label: Gtk.Label | None = None @@ -159,7 +173,10 @@ def __init__(self, window): self._gpu_row: Gtk.Widget | None = None self._pending_gpu: str | None = None self._gpu_modes: list[str] = [] + self._filters: list[str] = [] + self._filter_row: Gtk.Widget | None = None self._setting_value = False + self._wall_anim_preview: bool = bool(load_tools().get("wallpaperAnimatedPreview", True)) self._static_actions: RowActions | None = None self._bat_actions: RowActions | None = None self._col_actions: RowActions | None = None @@ -574,11 +591,23 @@ def build(self, header: Adw.HeaderBar | None = None) -> Gtk.Widget: pref_group.add(gpu_row) self._gpu_row = gpu_row + anim_preview_row = Adw.SwitchRow( + title="Enable Animated Previews", + subtitle="Show animated GIF previews for video wallpapers in the shell", + ) + _tools = load_tools() + anim_preview_row.set_active(bool(_tools.get("wallpaperAnimatedPreview", True))) + anim_preview_row.connect("notify::active", self._on_anim_preview_toggled) + pref_group.add(anim_preview_row) + self._wall_anim_row = anim_preview_row + content_box.append(pref_group) toolbar_row = self._build_toolbar_row() content_box.append(toolbar_row) + content_box.append(self._build_filter_row()) + flow = Gtk.FlowBox() flow.set_max_children_per_line(self._columns) flow.set_min_children_per_line(self._columns) @@ -730,6 +759,55 @@ def _build_toolbar_row(self) -> Gtk.Widget: return bar + def _build_filter_row(self) -> Gtk.Widget: + row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + row.set_margin_start(12) + row.set_margin_end(12) + row.set_margin_top(4) + row.set_margin_bottom(8) + row.set_halign(Gtk.Align.CENTER) + self._filter_row = row + self._refresh_filter_row() + return row + + def _refresh_filter_row(self) -> None: + if self._filter_row is None: + return + row = self._filter_row + while child := row.get_first_child(): + row.remove(child) + + valid_subs = {c for c in self._collections if c and c != "all"} + self._filters = [ + f for f in self._filters + if not (f.startswith("subfolder_") and f[10:] not in valid_subs) + ] + + for fid, label in (("static", "Static"), ("video", "Live")): + btn = Gtk.ToggleButton(label=label) + btn.set_active(fid in self._filters) + btn.add_css_class("wallpaper-filter-chip") + btn.connect("toggled", self._on_filter_toggled, fid) + row.append(btn) + + for col in self._collections: + if not col or col == "all": + continue + fid = f"subfolder_{col}" + btn = Gtk.ToggleButton(label=col[0].upper() + col[1:]) + btn.set_active(fid in self._filters) + btn.add_css_class("wallpaper-filter-chip") + btn.connect("toggled", self._on_filter_toggled, fid) + row.append(btn) + + def _on_filter_toggled(self, btn: Gtk.ToggleButton, fid: str) -> None: + if fid in self._filters: + self._filters.remove(fid) + else: + self._filters.append(fid) + self._page = 0 + self._rebuild() + def _page_size_index(self) -> int: options = [8, 12, 24, 48, 0] try: @@ -742,12 +820,16 @@ def _page_size_value(self, index: int) -> int: def _on_columns_changed(self, dd: Gtk.DropDown, _pspec) -> None: self._columns = 2 if dd.get_selected() == 0 else 3 + from lib.python.variable import set_var + set_var("WALL_SETTINGS_COLUMNS", str(self._columns)) self._rebuild() def _on_page_size_changed(self, dd: Gtk.DropDown, _pspec) -> None: if dd is None: return self._page_size = self._page_size_value(dd.get_selected()) + from lib.python.variable import set_var + set_var("WALL_SETTINGS_PAGE_SIZE", str(self._page_size)) self._page = 0 self._rebuild() @@ -769,9 +851,27 @@ def _total_pages(self) -> int: return max(1, math.ceil(len(matching) / self._page_size)) def _filtered_wallpapers(self) -> list[dict]: - if not self._search_term: - return list(self._all_wallpapers) - return [w for w in self._all_wallpapers if self._search_term in w.get("_search_tags", "")] + result = self._all_wallpapers + if self._search_term: + result = [w for w in result if self._search_term in w.get("_search_tags", "")] + if self._filters: + sub_f = {f[10:] for f in self._filters if f.startswith("subfolder_")} + type_f = [f for f in self._filters if not f.startswith("subfolder_")] + + def matches(w: dict) -> bool: + if sub_f and w.get("subfolder", "") not in sub_f: + return False + if type_f: + wtype = w.get("type", "") + if not any( + (f == "static" and wtype == "static") or (f == "video" and wtype == "live") + for f in type_f + ): + return False + return True + + result = [w for w in result if matches(w)] + return result def _update_pagination_bar(self) -> None: total = self._total_pages() @@ -900,6 +1000,8 @@ def _on_collection_changed(self, row, _pspec): from lib.python.variable import set_var set_var("RETRO_WALL_COLLECTION", name) self._pending_collection = name + self._filters = [f for f in self._filters if not f.startswith("subfolder_")] + self._refresh_filter_row() self._dirty = True self._notify_dirty() self._refresh_managed() @@ -966,6 +1068,15 @@ def _on_gpu_changed(self, row, _pspec, modes: list[str]): self._notify_dirty() self._refresh_managed() + def _on_anim_preview_toggled(self, switch, _pspec): + if self._setting_value: + return + self._wall_anim_preview = bool(switch.get_active()) + tools = load_tools() + tools["wallpaperAnimatedPreview"] = self._wall_anim_preview + save_tools(tools) + self._rebuild() + def is_dirty(self) -> bool: return self._dirty @@ -1069,26 +1180,60 @@ def _thumb_hover(self, pic: Gtk.Widget, icon: Gtk.Widget, hovering: bool) -> Non icon.set_visible(hovering) pic.set_opacity(0.5 if hovering else 1.0) + def _make_gif_picture(self, gif_path: str) -> Gtk.Widget: + pic = Gtk.Picture() + pic.set_content_fit(Gtk.ContentFit.COVER) + pic.set_can_shrink(True) + pic.add_css_class("wallpaper-thumb") + pic.set_hexpand(True) + pic.set_vexpand(True) + pic.set_size_request(0, 146) + try: + anim = GdkPixbuf.PixbufAnimation.new_from_file(gif_path) + iterator = anim.get_iter() + + def tick(): + iterator.advance() + frame = iterator.get_pixbuf() + if frame is not None: + pic.set_paintable(Gdk.Texture.new_for_pixbuf(frame)) + return True + + delay = max(iterator.get_delay_time(), 40) + source_id = GLib.timeout_add(delay, tick) + pic.connect("destroy", lambda *_a: GLib.source_remove(source_id)) + + first = iterator.get_pixbuf() + if first is not None: + pic.set_paintable(Gdk.Texture.new_for_pixbuf(first)) + except Exception: + pass + return pic + def _make_card(self, wp: dict) -> Gtk.Widget: box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) box.set_halign(Gtk.Align.FILL) box.set_hexpand(True) thumb_path = wp.get("thumb", "") - try: - pb = GdkPixbuf.Pixbuf.new_from_file_at_scale(thumb_path, 520, 292, True) - pic = Gtk.Picture.new_for_pixbuf(pb) - pic.set_content_fit(Gtk.ContentFit.COVER) - pic.add_css_class("wallpaper-thumb") - pic.set_can_shrink(True) - pic.set_hexpand(True) - pic.set_vexpand(True) - pic.set_size_request(0, 146) - except Exception: - pic = Gtk.Picture.new_for_filename("") - pic.set_hexpand(True) - pic.set_vexpand(True) - pic.set_size_request(0, 146) + gif_path = wp.get("gif", "") + if gif_path and os.path.isfile(gif_path) and self._wall_anim_preview: + pic = self._make_gif_picture(gif_path) + else: + try: + pb = GdkPixbuf.Pixbuf.new_from_file_at_scale(thumb_path, 520, 292, True) + pic = Gtk.Picture.new_for_pixbuf(pb) + pic.set_content_fit(Gtk.ContentFit.COVER) + pic.add_css_class("wallpaper-thumb") + pic.set_can_shrink(True) + pic.set_hexpand(True) + pic.set_vexpand(True) + pic.set_size_request(0, 146) + except Exception: + pic = Gtk.Picture.new_for_filename("") + pic.set_hexpand(True) + pic.set_vexpand(True) + pic.set_size_request(0, 146) thumb_overlay = Gtk.Overlay() thumb_overlay.set_child(pic) @@ -1305,6 +1450,7 @@ def _refresh_collections(self, select: str | None = None) -> None: self._col_row.set_selected(idx) self._setting_value = False self._update_col_delete_visibility() + self._refresh_filter_row() def _on_add(self, _btn): dialog = AddWallpapersDialog(self._window) @@ -1364,21 +1510,27 @@ def _on_preview(self, _gesture, _n_press, _x, _y, path: str, display: str): clamp.set_maximum_size(960) clamp.set_tightening_threshold(700) - try: - pb = GdkPixbuf.Pixbuf.new_from_file_at_scale(thumb, 1920, 1080, True) - pic = Gtk.Picture.new_for_pixbuf(pb) - except Exception: - pic = Gtk.Picture.new_for_filename("") - pic.set_content_fit(Gtk.ContentFit.CONTAIN) - pic.set_can_shrink(True) - pic.set_hexpand(True) - pic.set_vexpand(True) - pic.set_margin_top(16) - pic.set_margin_bottom(16) - pic.set_margin_start(16) - pic.set_margin_end(16) - - clamp.set_child(pic) + is_video = os.path.splitext(path)[1].lower() in (".mp4", ".mkv", ".webm") + if is_video and os.path.isfile(path): + content = Gtk.Video.new_for_filename(path) + content.set_loop(True) + content.set_autoplay(True) + else: + try: + pb = GdkPixbuf.Pixbuf.new_from_file_at_scale(thumb, 1920, 1080, True) + content = Gtk.Picture.new_for_pixbuf(pb) + content.set_content_fit(Gtk.ContentFit.CONTAIN) + content.set_can_shrink(True) + except Exception: + content = Gtk.Picture.new_for_filename("") + content.set_hexpand(True) + content.set_vexpand(True) + content.set_margin_top(16) + content.set_margin_bottom(16) + content.set_margin_start(16) + content.set_margin_end(16) + + clamp.set_child(content) scrolled = Gtk.ScrolledWindow() scrolled.set_child(clamp) scrolled.set_vexpand(True) diff --git a/cmds/tools/settings/style.css b/cmds/tools/settings/style.css index 2cf623cc..bd9759ff 100644 --- a/cmds/tools/settings/style.css +++ b/cmds/tools/settings/style.css @@ -99,6 +99,11 @@ row:hover .reset-button, color: @accent_color; } +/* Module picker rows — accent-colored category icon */ +.module-picker-row image { + color: @accent_color; +} + /* Active profile card — accent border + subtle tint */ .profile-active { border-left: 3px solid @accent_bg_color; @@ -533,3 +538,14 @@ colorchooserwidget { color: @error_color; } +/* Wallpaper filter chips (under the search bar) */ +.wallpaper-filter-chip { + border-radius: 9999px; +} +.wallpaper-filter-chip:checked { + background-color: @accent_bg_color; + color: @accent_fg_color; + font-weight: 700; + border: 1px solid @accent_bg_color; +} + From 391dffcdc991819e3d2c6427ca06c8e2b2db2a59 Mon Sep 17 00:00:00 2001 From: itsvlxd Date: Sun, 16 Aug 2026 12:49:36 +0300 Subject: [PATCH 03/11] feat(retroshell): add support for animated wallpapers preview --- modules/retroshell/files/config/Config.qml | 1 + .../dashboard/wallpapers/Wallpaper.qml | 5 + .../dashboard/wallpapers/WallpapersTab.qml | 123 +++++++++++++----- 3 files changed, 100 insertions(+), 29 deletions(-) diff --git a/modules/retroshell/files/config/Config.qml b/modules/retroshell/files/config/Config.qml index 930c38ab..f9c974ed 100644 --- a/modules/retroshell/files/config/Config.qml +++ b/modules/retroshell/files/config/Config.qml @@ -860,6 +860,7 @@ Singleton { property int recordingFps: 60 property bool recordingPortalEnabled: false property bool emojiShowRecent: true + property bool wallpaperAnimatedPreview: true } } diff --git a/modules/retroshell/files/modules/widgets/dashboard/wallpapers/Wallpaper.qml b/modules/retroshell/files/modules/widgets/dashboard/wallpapers/Wallpaper.qml index 32371abd..0ab14d7c 100644 --- a/modules/retroshell/files/modules/widgets/dashboard/wallpapers/Wallpaper.qml +++ b/modules/retroshell/files/modules/widgets/dashboard/wallpapers/Wallpaper.qml @@ -139,6 +139,11 @@ Item { return Quickshell.env("HOME") + "/.config/retro/wallpaper_thumbs/" + fileName + ".png"; } + function getGifPreviewPath(filePath) { + var fileName = filePath.split('/').pop(); + return Quickshell.env("HOME") + "/.config/retro/wallpaper_frames/" + fileName + ".gif"; + } + function getFramePath(filePath) { var fileName = filePath.split('/').pop(); return Quickshell.env("HOME") + "/.config/retro/wallpaper_frames/" + fileName + ".png"; diff --git a/modules/retroshell/files/modules/widgets/dashboard/wallpapers/WallpapersTab.qml b/modules/retroshell/files/modules/widgets/dashboard/wallpapers/WallpapersTab.qml index b1d138a3..32db4b04 100644 --- a/modules/retroshell/files/modules/widgets/dashboard/wallpapers/WallpapersTab.qml +++ b/modules/retroshell/files/modules/widgets/dashboard/wallpapers/WallpapersTab.qml @@ -239,18 +239,24 @@ FocusScope { if (!matchesSubfolder) return false; } - // Must match type filter (if any active) - if (typeFilters.length > 0) { - var matchesType = false; - for (var j = 0; j < typeFilters.length; j++) { - var f = typeFilters[j]; - if (f === "static" || f === fileType) { - matchesType = true; - break; + // Must match type filter (if any active) + if (typeFilters.length > 0) { + var matchesType = false; + for (var j = 0; j < typeFilters.length; j++) { + var f = typeFilters[j]; + if (f === "static") { + // Static = non-video (images / GIFs) + if (fileType !== "video") { + matchesType = true; + break; + } + } else if (f === fileType) { + matchesType = true; + break; + } + } + if (!matchesType) return false; } - } - if (!matchesType) return false; - } return true; }); @@ -1189,13 +1195,15 @@ FocusScope { // falta/nunca se genero el frame, o un overlay LIVE/N/A para videos/faltantes. Component { id: staticImageComponent - Image { - id: thumbImage - mipmap: true + Item { + id: thumbRoot + clip: true property string sourceFile: parent && parent.sourceFile ? parent.sourceFile : "" property string fileType: sourceFile && GlobalStates.wallpaperManager ? GlobalStates.wallpaperManager.getFileType(sourceFile) : "image" property bool usingOriginal: false + property bool gifTried: false + property bool animatedPreviews: Config.tools && Config.tools.wallpaperAnimatedPreview !== false function thumbnailSource() { if (!sourceFile || !GlobalStates.wallpaperManager) @@ -1205,28 +1213,85 @@ FocusScope { return "file://" + thumbnailPath + "?v=" + version; } - source: usingOriginal && fileType !== "video" ? "file://" + sourceFile : thumbnailSource() - fillMode: Image.PreserveAspectCrop - asynchronous: true - smooth: true - cache: false // Disable caching to reduce memory usage - sourceSize.width: wallpaperGridContainer.cellSize * Screen.devicePixelRatio - sourceSize.height: wallpaperGridContainer.cellSize * Screen.devicePixelRatio + function gifSource() { + if (!sourceFile || !GlobalStates.wallpaperManager) + return ""; + var gifPath = GlobalStates.wallpaperManager.getGifPreviewPath(sourceFile); + var version = GlobalStates.wallpaperManager.thumbnailsVersion; + return "file://" + gifPath + "?v=" + version; + } // Reset fallback when this (possibly reused) cell points at another file - onSourceFileChanged: usingOriginal = false + onSourceFileChanged: { + usingOriginal = false; + gifTried = false; + } - onStatusChanged: { - if (status === Image.Error && !usingOriginal && fileType !== "video") { - // Frame missing/broken -> show the original instead, never a blank cell - usingOriginal = true; + // Animated GIF preview for video wallpapers. + // Fits the cell height, centers horizontally, and lets the width + // overflow (clipped by thumbRoot) instead of stretching to a square. + AnimatedImage { + id: thumbGif + anchors.horizontalCenter: parent.horizontalCenter + anchors.verticalCenter: parent.verticalCenter + height: parent.height + width: { + var sw = sourceSize.width; + var sh = sourceSize.height; + if (sw > 0 && sh > 0) { + return Math.round(height * sw / sh); + } + return parent.width; + } + visible: thumbRoot.animatedPreviews && thumbRoot.fileType === "video" && !thumbRoot.gifTried && status !== Image.Error + source: thumbRoot.animatedPreviews && thumbRoot.fileType === "video" && !thumbRoot.gifTried ? thumbRoot.gifSource() : "" + smooth: true + cache: false + playing: visible + + onStatusChanged: { + if (status === Image.Error && thumbRoot.animatedPreviews && thumbRoot.fileType === "video" && !thumbRoot.gifTried) { + thumbRoot.gifTried = true; + } + } + } + + // Static frame: thumbnails for images, fallback thumbnail for videos + Image { + id: thumbImage + anchors.fill: parent + visible: !thumbGif.visible + mipmap: true + + source: { + if (thumbGif.visible) { + return ""; + } + if (thumbRoot.usingOriginal && thumbRoot.fileType !== "video") { + return "file://" + thumbRoot.sourceFile; + } + return thumbRoot.thumbnailSource(); + } + fillMode: Image.PreserveAspectCrop + asynchronous: true + smooth: true + cache: false // Disable caching to reduce memory usage + sourceSize.width: wallpaperGridContainer.cellSize * Screen.devicePixelRatio + sourceSize.height: wallpaperGridContainer.cellSize * Screen.devicePixelRatio + + onStatusChanged: { + if (status === Image.Error && !thumbRoot.usingOriginal && thumbRoot.fileType !== "video") { + // Frame missing/broken -> show the original instead, never a blank cell + thumbRoot.usingOriginal = true; + } } } // Overlay for cells with no usable frame (videos, missing originals) Rectangle { anchors.fill: parent - visible: thumbImage.status === Image.Error + visible: (thumbRoot.fileType === "video" && thumbRoot.gifTried && thumbImage.status === Image.Error) || + (thumbRoot.fileType !== "video" && thumbImage.status === Image.Error) color: Colors.surface Column { @@ -1235,7 +1300,7 @@ FocusScope { Text { anchors.horizontalCenter: parent.horizontalCenter - text: thumbImage.fileType === "video" ? Icons.play : Icons.image + text: thumbRoot.fileType === "video" ? Icons.play : Icons.image font.family: Icons.font font.pixelSize: 22 color: Colors.overSurfaceVariant @@ -1243,7 +1308,7 @@ FocusScope { Text { anchors.horizontalCenter: parent.horizontalCenter - text: thumbImage.fileType === "video" ? "LIVE" : "N/A" + text: thumbRoot.fileType === "video" ? "LIVE" : "N/A" font.family: Config.theme.font font.pixelSize: Config.theme.fontSize font.weight: Font.Bold From c458deec5e9b8001fed8bf4a6e8098382db8b429 Mon Sep 17 00:00:00 2001 From: itsvlxd Date: Sun, 16 Aug 2026 12:50:02 +0300 Subject: [PATCH 04/11] feat(lib): add wallpaper gif cache generation --- lib/wallpaper.sh | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/lib/wallpaper.sh b/lib/wallpaper.sh index 6e7d53f5..884dcb07 100755 --- a/lib/wallpaper.sh +++ b/lib/wallpaper.sh @@ -44,6 +44,37 @@ rx_wallpaper_generate_cache() { ffmpeg -nostdin -i "$target" -frames:v 1 "$output" -y -loglevel quiet fi fi + + local gif_out="$FRAME_CACHE/${filename}.gif" + local gif_res="$custom_res" + if [[ -z $gif_res ]]; then + gif_res=$(ffprobe -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 "$target" 2>/dev/null) + fi + local gw gh + if [[ $gif_res =~ ^[0-9]+x[0-9]+$ ]]; then + gw=$(( ${gif_res%x*} / 4 )) + gh=$(( ${gif_res#*x} / 4 )) + else + gw=480; gh=270 + fi + [[ $gw -lt 80 ]] && gw=80 + [[ $gh -lt 45 ]] && gh=45 + + local gif_needs=false + if [[ ! -f $gif_out ]]; then + gif_needs=true + elif [[ $target -nt $gif_out ]]; then + gif_needs=true + elif [[ -n $custom_res ]]; then + local gcur + gcur=$(identify -format "%wx%h" "$gif_out" 2>/dev/null) + [[ -n $gcur && $gcur != "${gw}x${gh}" ]] && gif_needs=true + fi + if [[ $gif_needs == true ]]; then + ffmpeg -nostdin -i "$target" -t 2 \ + -vf "fps=12,scale=${gw}:${gh}:force_original_aspect_ratio=decrease" \ + -loop 0 "$gif_out" -y -loglevel quiet + fi elif [[ ! -f $output ]]; then ln -sf "$target" "$output" fi From 08ccb25d2d7f8ed4836cdd4827fd75b098e3a420 Mon Sep 17 00:00:00 2001 From: itsvlxd Date: Sun, 16 Aug 2026 12:50:34 +0300 Subject: [PATCH 05/11] feat(driver): add support for modprobe drivers --- cmds/tools/driver.sh | 142 ++++++++++ scripts/driver_core.sh | 582 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 691 insertions(+), 33 deletions(-) diff --git a/cmds/tools/driver.sh b/cmds/tools/driver.sh index 1dccb21b..261d9b57 100755 --- a/cmds/tools/driver.sh +++ b/cmds/tools/driver.sh @@ -442,6 +442,143 @@ cmd_driver() { rx_table_spacer ;; + "net") + local net_target="${2,,}" + if [[ -z $net_target ]]; then + local nets + nets=$(bash "$driver_script" --scan | grep "^NET") + rx_table_header "󰤨" "Network Drivers" + if [[ -z $nets ]]; then + rx_log "info" "No network controllers detected" + else + while IFS= read -r n; do + IFS='|' read -r typ vendor model driver pkgs missing device_id <<<"$n" + local drv="${driver:-none}" + local miss="${missing:-all installed}" + if [[ $drv == "none" ]]; then + rx_table_simple "󰤨" "${model} — NO DRIVER BOUND (${miss})" "$ERROR" + else + rx_table_simple "󰤨" "${model} — ${drv}" "$SUCCESS" + fi + done <<<"$nets" + fi + rx_table_spacer + rx_log "info" "Usage: retro driver net [r8125|r8168|r8169]" + return 0 + fi + + case "$net_target" in + r8125|r8168|r8169) + if [[ $SKIP_PROMPT != "true" ]]; then + rx_log "info" "Switch Realtek driver to ${PINK}${net_target}${RESET}? ${PINK}[y/N]${RESET}: " + read -r confirm + [[ ! $confirm =~ ^[Yy]$ ]] && rx_log "info" "Aborted." && return 0 + fi + local net_res + net_res=$(bash "$driver_script" --net-switch "$net_target") + IFS='|' read -r net_status net_drv <<<"$net_res" + case "$net_status" in + SWITCHED) rx_log "success" "Switched Realtek driver to ${PINK}${net_drv}${RESET}" ;; + *) rx_log "error" "Failed to switch network driver: $net_res" ;; + esac + ;; + *) rx_log "error" "Unknown network driver: ${net_target}. Use r8125, r8168 or r8169" ;; + esac + ;; + + "blacklist") + local bl_action="${2,,}" + local bl_module="$3" + case "$bl_action" in + list) + rx_table_header "󰓅" "Blacklisted Modules" + local bl_list + bl_list=$(bash "$driver_script" --blacklist-list | sed 's/^BLACKLIST|//') + if [[ -z $bl_list ]]; then + rx_log "info" "No modules blacklisted" + else + while IFS= read -r mod; do + [[ -z $mod ]] && continue + rx_table_simple "󰓅" "$mod" "$PINK" + done <<<"$bl_list" + fi + rx_table_spacer + ;; + add|on) + [[ -z $bl_module ]] && rx_log "error" "Usage: retro driver blacklist add " && return 1 + if [[ $SKIP_PROMPT != "true" ]]; then + rx_log "info" "Blacklist ${PINK}${bl_module}${RESET}? ${PINK}[y/N]${RESET}: " + read -r confirm + [[ ! $confirm =~ ^[Yy]$ ]] && rx_log "info" "Aborted." && return 0 + fi + local bl_res + bl_res=$(pkexec bash "$driver_script" --blacklist-add "$bl_module") + if echo "$bl_res" | grep -qE "ADDED|ALREADY"; then + rx_log "success" "Blacklisted ${bl_module}" + else + rx_log "error" "Failed to blacklist ${bl_module}" + fi + ;; + remove|off) + [[ -z $bl_module ]] && rx_log "error" "Usage: retro driver blacklist remove " && return 1 + local bl_res + bl_res=$(pkexec bash "$driver_script" --blacklist-remove "$bl_module") + if echo "$bl_res" | grep -q "REMOVED"; then + rx_log "success" "Unblacklisted ${bl_module}" + else + rx_log "error" "Failed to unblacklist ${bl_module}" + fi + ;; + *) + rx_log "error" "Usage: retro driver blacklist [list|add|remove] " + return 1 + ;; + esac + ;; + + "mod-exists") + local me_module="$2" + [[ -z $me_module ]] && rx_log "error" "Usage: retro driver mod-exists " && return 1 + local me_res + me_res=$(bash "$driver_script" --module-exists "$me_module") + if echo "$me_res" | grep -q "^EXISTS"; then + rx_log "success" "Module found: ${me_res#EXISTS|}" + elif echo "$me_res" | grep -q "^NOT_FOUND"; then + rx_log "error" "Module not found: ${me_module}" + else + rx_log "error" "$me_res" + fi + ;; + + "module-list") + rx_table_header "󰓅" "Installed Kernel Modules" + local ml_count=0 + while IFS= read -r ml; do + [[ -z $ml ]] && continue + local m_name="${ml#MODULE|}" + m_name="${m_name%%|*}" + ((ml_count++)) + rx_table_row "󰓅" "$m_name" "" "$GRAY" "44" + done <<<"$(bash "$driver_script" --module-list)" + rx_table_separator + rx_log "info" "${ml_count} modules available" + ;; + + "module-info") + local mi_module="$2" + [[ -z $mi_module ]] && rx_log "error" "Usage: retro driver module-info " && return 1 + local mi_res + mi_res=$(bash "$driver_script" --module-info "$mi_module") + rx_table_header "󰓅" "Module: ${mi_module}" + while IFS= read -r line; do + [[ -z $line || "|" != *"$line"* ]] && [[ -z $line ]] && continue + IFS='|' read -r mi_key mi_val <<<"$line" + [[ -z $mi_key || -z $mi_val ]] && continue + rx_table_row "󰓅" "$mi_key" "$mi_val" "$GRAY" "22" + done <<<"$mi_res" + rx_table_spacer + ;; + "modules") local mod_list=("${@:2}") local mod_data=$(bash "$driver_script" --modules "${mod_list[@]}") @@ -857,6 +994,11 @@ cmd_driver() { rx_help_cmd "env" "Show GPU compute env (CUDA/ONEAPI/RADV)" rx_help_cmd "info " "Show detailed device info" rx_help_cmd "switch [xe|i915]" "Switch Intel GPU kernel driver" + rx_help_cmd "net [r8125|r8168|r8169]" "Show/switch Realtek ethernet driver" + rx_help_cmd "blacklist [list|add|remove] " "Manage modprobe blacklists" + rx_help_cmd "mod-exists " "Check if a kernel module exists" + rx_help_cmd "module-list" "List all installed kernel modules" + rx_help_cmd "module-info " "Show full module details" rx_help_cmd "modules [names...]" "List kernel module parameters" rx_help_cmd "modules-set" "Set a kernel module parameter" rx_help_cmd "conflicts" "Check for driver conflicts" diff --git a/scripts/driver_core.sh b/scripts/driver_core.sh index 816fcff0..83069a8e 100755 --- a/scripts/driver_core.sh +++ b/scripts/driver_core.sh @@ -136,41 +136,67 @@ detect_npu() { done } +_net_vendor_name() { + local vendor_id="$1" + case "$vendor_id" in + 8086) echo "intel" ;; + 10ec) echo "realtek" ;; + 14e4) echo "broadcom" ;; + 14c3) echo "mediatek" ;; + 1814) echo "mediatek" ;; + 168c) echo "atheros" ;; + 17cb) echo "qualcomm" ;; + 1969) echo "atheros" ;; + 1d6a) echo "qualcomm" ;; + 0bda) echo "realtek" ;; + 10df) echo "unknown" ;; + *) echo "unknown" ;; + esac +} + detect_network() { local networks=() - local wifi=$(lspci 2>/dev/null | grep -iE "Network controller|Wireless" | grep -vi "Neural" | head -1) - if [[ -n $wifi ]]; then - local pci_id=$(echo "$wifi" | awk '{print $1}') + if ! command -v lspci >/dev/null 2>&1; then + return 1 + fi + while IFS= read -r line; do + local pci_id=$(echo "$line" | awk '{print $1}') local nn_line=$(lspci -nn -s "$pci_id" 2>/dev/null) local vd_pair=$(echo "$nn_line" | grep -oP '\[([0-9a-f]{4}):([0-9a-f]{4})\]' | tr -d '[]') local vendor_id="${vd_pair%%:*}" + local device_id="${vd_pair##*:}" + [[ -z $device_id ]] && continue local model=$(echo "$nn_line" | sed 's/.*\]://;s/\s*\[[0-9a-f]*:[0-9a-f]*\].*//' | xargs) local driver=$(lspci -k -s "$pci_id" 2>/dev/null | grep "Kernel driver in use:" | awk -F': ' '{print $2}') - local vendor="unknown" - case "$vendor_id" in - 8086) vendor="intel" ;; - 10ec) vendor="realtek" ;; - 14e4) vendor="broadcom" ;; - 1814) vendor="mediatek" ;; - 168c) vendor="atheros" ;; - esac - networks+=("wifi|${vendor}|${model}|${driver}") - fi - local eth=$(lspci 2>/dev/null | grep -i "ethernet" | head -1) - if [[ -n $eth ]]; then - local pci_id=$(echo "$eth" | awk '{print $1}') + [[ -z $driver ]] && driver="none" + local vendor=$(_net_vendor_name "$vendor_id") + networks+=("wifi|${vendor}|${model}|${driver}|${device_id}") + done < <(lspci 2>/dev/null | grep -iE "Network controller|Wireless" | grep -vi "Neural") + while IFS= read -r line; do + local pci_id=$(echo "$line" | awk '{print $1}') local nn_line=$(lspci -nn -s "$pci_id" 2>/dev/null) local vd_pair=$(echo "$nn_line" | grep -oP '\[([0-9a-f]{4}):([0-9a-f]{4})\]' | tr -d '[]') local vendor_id="${vd_pair%%:*}" + local device_id="${vd_pair##*:}" + [[ -z $device_id ]] && continue local model=$(echo "$nn_line" | sed 's/.*\]://;s/\s*\[[0-9a-f]*:[0-9a-f]*\].*//' | xargs) local driver=$(lspci -k -s "$pci_id" 2>/dev/null | grep "Kernel driver in use:" | awk -F': ' '{print $2}') - local vendor="unknown" - case "$vendor_id" in - 8086) vendor="intel" ;; - 10ec) vendor="realtek" ;; - 14e4) vendor="broadcom" ;; - esac - networks+=("ethernet|${vendor}|${model}|${driver}") + [[ -z $driver ]] && driver="none" + local vendor=$(_net_vendor_name "$vendor_id") + networks+=("ethernet|${vendor}|${model}|${driver}|${device_id}") + done < <(lspci 2>/dev/null | grep -i "ethernet") + if command -v lsusb >/dev/null 2>&1; then + while IFS= read -r line; do + local vid=$(echo "$line" | grep -oP 'ID [0-9a-f]{4}:[0-9a-f]{4}' | awk '{print $2}' | cut -d: -f1) + local did=$(echo "$line" | grep -oP 'ID [0-9a-f]{4}:[0-9a-f]{4}' | awk '{print $2}' | cut -d: -f2) + [[ -z $vid || -z $did ]] && continue + local model=$(echo "$line" | sed 's/^.*ID [0-9a-f:]* //' | xargs) + local vendor=$(_net_vendor_name "$vid") + local driver="none" + local ifname=$(ip -o link 2>/dev/null | grep -i "$vid" | head -1 | awk -F': ' '{print $2}') + [[ -n $ifname ]] && driver="$ifname" + networks+=("ethernet|${vendor}|${model}|${driver}|${did}") + done < <(lsusb 2>/dev/null | grep -iE "RTL815[0-9]|RTL816[0-9]|AX8817[0-9]|AX88[12][0-9]|USB.*[Ee]thernet|[Ee]thernet.*USB|Network adapter|LAN adapter|2.5GbE|2\.5G" | grep -viE "bluetooth|hub|wireless") fi if [[ ${#networks[@]} -eq 0 ]]; then echo "NONE" @@ -300,26 +326,120 @@ get_npu_packages() { get_network_packages() { local type="$1" local vendor="$2" + local device_id="$3" case "$type" in wifi) case "$vendor" in - intel) echo "iwd linux-firmware" ;; - realtek) echo "linux-firmware rtl88xxau-aircrack-dkms-git" ;; + intel) echo "linux-firmware iwd" ;; + realtek) + case "$device_id" in + 8852be) echo "8852be-dkms-git linux-firmware" ;; + 8852ce) echo "8852be-dkms-git linux-firmware" ;; + 8851be) echo "8852be-dkms-git linux-firmware" ;; + 8821ce) echo "rtl8821ce-dkms-git linux-firmware" ;; + 8821cu) echo "rtl8821cu-dkms-git linux-firmware" ;; + 8822ce) echo "rtl88x2ce-dkms-git linux-firmware" ;; + 8822cu) echo "rtl8822cu-dkms-git linux-firmware" ;; + 8822bu) echo "rtl88x2bu-dkms-git linux-firmware" ;; + 8852au|8832au) echo "rtl8852au-dkms-git linux-firmware" ;; + 8852bu|8832bu) echo "rtl8852bu-dkms-git linux-firmware" ;; + 8852cu) echo "rtl8852cu-dkms-git linux-firmware" ;; + 8812au|8814au|8821au|8811au) echo "rtl8812au-openhd-dkms-git linux-firmware" ;; + *) echo "linux-firmware" ;; + esac + ;; broadcom) echo "broadcom-wl-dkms linux-firmware" ;; - mediatek) echo "linux-firmware" ;; + mediatek) echo "linux-firmware linux-firmware-mediatek" ;; atheros) echo "linux-firmware ath9k-htc-firmware" ;; + qualcomm) echo "linux-firmware" ;; *) echo "linux-firmware iwd" ;; esac ;; ethernet) case "$vendor" in - realtek) echo "r8168-dkms" ;; + realtek) + case "$device_id" in + 8125) echo "r8125-dkms linux-firmware" ;; + 8168|8111|8169|8101) echo "r8168-dkms linux-firmware" ;; + *) echo "linux-firmware" ;; + esac + ;; + mediatek) echo "linux-firmware" ;; *) echo "" ;; esac ;; esac } +get_network_driver_candidates() { + local type="$1" + local vendor="$2" + local device_id="$3" + case "$type" in + ethernet) + case "$vendor" in + realtek) + case "$device_id" in + 8125) echo "r8125-dkms|r8168-dkms" ;; + *) echo "r8168-dkms|r8125-dkms" ;; + esac + ;; + *) echo "" ;; + esac + ;; + wifi) + case "$vendor" in + realtek) + case "$device_id" in + 8852be|8852ce|8851be) echo "8852be-dkms-git" ;; + 8821ce) echo "rtl8821ce-dkms-git" ;; + 8821cu) echo "rtl8821cu-dkms-git" ;; + 8822ce) echo "rtl88x2ce-dkms-git" ;; + 8822bu) echo "rtl88x2bu-dkms-git" ;; + 8852au|8832au) echo "rtl8852au-dkms-git" ;; + 8852bu|8832bu) echo "rtl8852bu-dkms-git" ;; + 8852cu) echo "rtl8852cu-dkms-git" ;; + 8812au|8814au|8821au|8811au) echo "rtl8812au-openhd-dkms-git" ;; + *) echo "" ;; + esac + ;; + broadcom) echo "broadcom-wl-dkms" ;; + *) echo "" ;; + esac + ;; + esac +} + +aur_search_driver() { + local term="$1" + if ! command -v yay >/dev/null 2>&1 && ! command -v paru >/dev/null 2>&1; then + return 1 + fi + local helper="yay" + command -v paru >/dev/null 2>&1 && helper="paru" + local out + out=$($helper -Ss "$term" 2>/dev/null | grep -iE "dkms|driver" | awk '{print $1}' | head -3) + echo "$out" +} + +_aur_fetch_network_driver() { + local model="$1" + local device_id="$2" + local helper="yay" + command -v paru >/dev/null 2>&1 && helper="paru" + command -v "$helper" >/dev/null 2>&1 || return 1 + local terms=() + [[ -n $device_id ]] && terms+=("rtl${device_id}" "mt${device_id}" "${device_id}-dkms") + local chip=$(echo "$model" | grep -oiE "RTL[0-9a-z]+|MT[0-9]+|AX[0-9]+" | head -1) + [[ -n $chip ]] && terms+=("${chip,,}-dkms") + local found="" + for t in "${terms[@]}"; do + found=$($helper -Ss "$t" 2>/dev/null | grep -iE "dkms" | awk '{print $1}' | grep -viE "firmware|bluetooth" | head -1) + [[ -n $found ]] && break + done + echo "$found" +} + get_other_packages() { local type="$1" case "$type" in @@ -473,11 +593,17 @@ run_full_scan() { local networks=$(detect_network) if [[ $networks != "NONE" && -n $networks ]]; then while IFS= read -r net; do - IFS='|' read -r type vendor model driver <<<"$net" - local pkgs=$(get_network_packages "$type" "$vendor") + IFS='|' read -r type vendor model driver device_id <<<"$net" + local pkgs=$(get_network_packages "$type" "$vendor" "$device_id") local missing="" [[ -n $pkgs ]] && missing=$(_get_missing "$pkgs") - results+="NET|${vendor}|${model}|${driver}|${pkgs}|${missing}\n" + results+="NET|${vendor}|${model}|${driver}|${pkgs}|${missing}|${device_id}\n" + if [[ $driver == "none" ]]; then + local hint=$(get_network_driver_candidates "$type" "$vendor" "$device_id") + if [[ -n $hint ]]; then + results+="WARN|No driver bound for ${model} — install ${hint%%|*}||${hint}||\n" + fi + fi done <<<"$networks" fi local audio=$(detect_audio) @@ -527,7 +653,7 @@ run_full_install() { local missing_pkgs="" while IFS= read -r line; do [[ -z $line ]] && continue - IFS='|' read -r type vendor model driver pkgs missing <<<"$line" + IFS='|' read -r type vendor model driver pkgs missing device_id <<<"$line" case "$type" in GPU | CPU | NPU | NET | BT | FW | OTHER) [[ -n $missing ]] && missing_pkgs+=" $missing" @@ -546,9 +672,11 @@ run_full_install_confirmed() { local scan_data=$(run_full_scan) local missing_pkgs="" local gpu_vendors_found=() + local net_dkms="" + local aur_extra="" while IFS= read -r line; do [[ -z $line ]] && continue - IFS='|' read -r type vendor model driver pkgs missing <<<"$line" + IFS='|' read -r type vendor model driver pkgs missing device_id <<<"$line" case "$type" in GPU) gpu_vendors_found+=("$vendor") @@ -558,6 +686,18 @@ run_full_install_confirmed() { [[ -n $missing ]] && missing_pkgs+=" $missing" ;; esac + if [[ $type == "NET" && $driver == "none" && -z $missing ]]; then + local aur_pkg + aur_pkg=$(_aur_fetch_network_driver "$model" "$device_id") + if [[ -n $aur_pkg ]]; then + missing_pkgs+=" $aur_pkg" + aur_extra+=" $aur_pkg" + net_dkms="$vendor" + fi + fi + if [[ $type == "NET" && $missing == *"dkms"* ]]; then + net_dkms="$vendor" + fi done <<<"$scan_data" local unique_missing=$(echo "$missing_pkgs" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ') if [[ -z "$(echo "$unique_missing" | xargs)" ]]; then @@ -589,6 +729,14 @@ run_full_install_confirmed() { configure_mkinitcpio_nvidia fi + if [[ -n $net_dkms ]]; then + configure_network_driver "$net_dkms" "$unique_missing" + fi + + if [[ -n $aur_extra ]]; then + echo "AUR_INSTALLED:$aur_extra" + fi + if echo "$unique_missing" | grep -qE "dkms|nvidia|cuda|rocm"; then echo "INITRAMFS_UPDATE_NEEDED" fi @@ -724,6 +872,290 @@ _set_module_param() { echo "SET|${module}|${param}=${value}" } +MODPROBE_DIR="/etc/modprobe.d" +MODULES_LOAD_DIR="/etc/modules-load.d" + +_module_category() { + local mod="$1" + local kver=$(uname -r) + local path="" + if [[ -f /lib/modules/${kver}/modules.dep ]]; then + path=$(awk -v m="$mod" '{t=$0; sub(/:.*$/, "", t); split(t, a, "/"); n=a[length(a)]; sub(/\.ko(\.(gz|xz|zst))?$/, "", n); if(n==m){print t; exit}}' /lib/modules/${kver}/modules.dep) + fi + [[ -z $path ]] && path=$(grep -E "(^|/)${mod}(\.ko(\.(gz|xz|zst))?)?$" /lib/modules/${kver}/modules.builtin 2>/dev/null | head -1) + case "$path" in + *drivers/net/*|*drivers/net/wireless/*) echo "network" ;; + *drivers/net/ethernet/*) echo "network" ;; + *drivers/gpu/*|*drivers/video/*|*drivers/gpu/drm/*) echo "display" ;; + *drivers/media/*|*drivers/media/usb/*|*drivers/media/pci/*) echo "media" ;; + *drivers/input/*) echo "input" ;; + *drivers/audio/*|*sound/*) echo "audio" ;; + *drivers/bluetooth/*|*net/bluetooth/*) echo "bluetooth" ;; + *drivers/staging/*) echo "staging" ;; + *drivers/usb/*|*drivers/usb/serial/*) echo "usb" ;; + *drivers/char/*|*drivers/tty/*) echo "serial" ;; + *drivers/md/*|*drivers/scsi/*|*drivers/ata/*|*drivers/nvme/*) echo "storage" ;; + *drivers/mmc/*|*drivers/mtd/*) echo "storage" ;; + *drivers/hid/*|*drivers/hid/usbhid/*) echo "hid" ;; + *drivers/thermal/*) echo "thermal" ;; + *arch/*|*kernel/arch/*) echo "arch" ;; + *kernel/crypto/*|*crypto/*) echo "crypto" ;; + *kernel/fs/*|*fs/*) echo "filesystem" ;; + *updates/dkms/*|*extra/*) echo "dkms" ;; + *) echo "other" ;; + esac +} + +_list_all_modules() { + local kver=$(uname -r) + local mod_dir="/lib/modules/${kver}" + if [[ ! -f ${mod_dir}/modules.dep && ! -f ${mod_dir}/modules.builtin ]]; then + return 0 + fi + # Build a name -> full path map in a single awk pass (fast: one parse of + # modules.dep) instead of grepping the file per module. + local tmp + tmp=$(mktemp) + { + [[ -f ${mod_dir}/modules.dep ]] && awk '{p=$0; sub(/:.*$/, "", p); split(p, a, "/"); n=a[length(a)]; sub(/\.ko(\.(gz|xz|zst))?$/, "", n); if(n!="") print n "\t" p}' "${mod_dir}/modules.dep" + [[ -f ${mod_dir}/modules.builtin ]] && awk '{split($0, a, "/"); n=a[length(a)]; sub(/\.ko(\.(gz|xz|zst))?$/, "", n); if(n!="") print n "\t" $0}' "${mod_dir}/modules.builtin" + } | sort -u >"$tmp" + + while IFS=$'\t' read -r mod path; do + [[ -z $mod || $mod == *" "* ]] && continue + local cat="other" + case "$path" in + *drivers/net/*|*drivers/net/wireless/*) cat="network" ;; + *drivers/gpu/*|*drivers/video/*|*drivers/gpu/drm/*) cat="display" ;; + *drivers/media/*) cat="media" ;; + *drivers/input/*) cat="input" ;; + *drivers/audio/*|*sound/*) cat="audio" ;; + *drivers/bluetooth/*|*net/bluetooth/*) cat="bluetooth" ;; + *drivers/staging/*) cat="staging" ;; + *drivers/usb/*) cat="usb" ;; + *drivers/char/*|*drivers/tty/*) cat="serial" ;; + *drivers/md/*|*drivers/scsi/*|*drivers/ata/*|*drivers/nvme/*|*drivers/mmc/*|*drivers/mtd/*) cat="storage" ;; + *drivers/hid/*) cat="hid" ;; + *drivers/thermal/*) cat="thermal" ;; + *arch/*|*kernel/arch/*) cat="arch" ;; + *crypto/*) cat="crypto" ;; + *fs/*) cat="filesystem" ;; + *updates/dkms/*|*extra/*) cat="dkms" ;; + esac + echo "MODULE|${mod}|${cat}" + done <"$tmp" + rm -f "$tmp" +} + +_module_descs() { + # Batch descriptions for many modules in ONE subprocess invocation. + # Reads all module names from argv and prints DESC|| + # for each, reusing the builtin modinfo blob where possible. + local kver=$(uname -r) + local mod_dir="/lib/modules/${kver}" + local blob="" + if [[ -f ${mod_dir}/modules.builtin.modinfo ]]; then + blob=$(tr '\0' '\n' < "${mod_dir}/modules.builtin.modinfo" 2>/dev/null | grep -E "\.description=" | sed -E 's/\.description=/\t/') + fi + local mod + for mod in "$@"; do + [[ -z $mod ]] && continue + if [[ -n $blob ]]; then + local desc="" + desc=$(echo "$blob" | awk -F'\t' -v m="$mod" '$1==m{print $2; exit}') + if [[ -n $desc ]]; then + echo "DESC|${mod}|${desc}" + continue + fi + fi + local d="" + d=$(modinfo -F description "$mod" 2>/dev/null) + echo "DESC|${mod}|${d}" + done +} + +_module_info() { + local mod="$1" + [[ -z $mod ]] && { echo "ERROR|missing_module"; return 1; } + if [[ ! $mod =~ ^[A-Za-z0-9_-]+$ ]]; then echo "ERROR|invalid_module|${mod}" + return 1 + fi + local kver=$(uname -r) + local desc="" + if [[ -f /lib/modules/${kver}/modules.builtin.modinfo ]]; then + desc=$(tr '\0' '\n' < /lib/modules/${kver}/modules.builtin.modinfo 2>/dev/null | grep -E "^${mod}\.description=" | head -1 | cut -d= -f2-) + fi + [[ -z $desc ]] && desc=$(modinfo -F description "$mod" 2>/dev/null) + local author=$(modinfo -F author "$mod" 2>/dev/null) + local license=$(modinfo -F license "$mod" 2>/dev/null) + local version=$(modinfo -F version "$mod" 2>/dev/null) + local depends=$(modinfo -F depends "$mod" 2>/dev/null) + local firmware=$(modinfo -F firmware "$mod" 2>/dev/null | head -1) + local builtin="no" + if grep -qE "(^|/)${mod}(\.ko(\.(gz|xz|zst))?)?$" /lib/modules/${kver}/modules.builtin 2>/dev/null; then + builtin="yes" + fi + echo "DESC|${desc}" + echo "AUTHOR|${author}" + echo "LICENSE|${license}" + echo "VERSION|${version}" + echo "DEPENDS|${depends}" + echo "FIRMWARE|${firmware}" + echo "BUILTIN|${builtin}" + echo "CATEGORY|$(_module_category "$mod")" +} + +_list_modprobe_files() { + if [[ ! -d $MODPROBE_DIR ]]; then + return 0 + fi + for f in "$MODPROBE_DIR"/*.conf; do + [[ -f $f ]] || continue + echo "FILE|$(basename "$f")" + while IFS= read -r line; do + echo "CONTENT|${line}" + done <"$f" + done +} + +_list_blacklisted() { + if [[ ! -d $MODPROBE_DIR ]]; then + return 0 + fi + grep -rhE "^\s*blacklist\s+" "$MODPROBE_DIR" 2>/dev/null | awk '{print $2}' | sort -u | while read -r mod; do + echo "BLACKLIST|${mod}" + done +} + +_modprobe_blacklist_add() { + local module="$1" + [[ -z $module ]] && { echo "ERROR|missing_module"; return 1; } + if [[ ! $module =~ ^[A-Za-z0-9_-]+$ ]]; then + echo "ERROR|invalid_module|${module}" + return 1 + fi + local exists + exists=$(_module_exists "$module") + if [[ $exists != EXISTS* ]]; then + echo "ERROR|module_not_found|${module}" + return 1 + fi + local conf="${MODPROBE_DIR}/blacklist-${module}.conf" + if grep -qE "^\s*blacklist\s+${module}\b" "$conf" 2>/dev/null; then + echo "ALREADY|${module}" + return 0 + fi + echo "blacklist ${module}" | sudo tee "$conf" >/dev/null + echo "ADDED|${module}" +} + +_modprobe_blacklist_remove() { + local module="$1" + [[ -z $module ]] && { echo "ERROR|missing_module"; return 1; } + local conf="${MODPROBE_DIR}/blacklist-${module}.conf" + if [[ -f $conf ]]; then + sudo rm -f "$conf" + fi + echo "REMOVED|${module}" +} + +_list_modules_load() { + if [[ ! -d $MODULES_LOAD_DIR ]]; then + return 0 + fi + grep -rhE "^\s*[a-zA-Z0-9_]+" "$MODULES_LOAD_DIR" 2>/dev/null | awk '{print $1}' | sort -u | while read -r mod; do + echo "LOAD|${mod}" + done +} + +_module_exists() { + local module="$1" + [[ -z $module ]] && { echo "ERROR|missing_module"; return 1; } + if [[ ! $module =~ ^[A-Za-z0-9_-]+$ ]]; then + echo "ERROR|invalid_module|${module}" + return 1 + fi + local canonical + canonical=$(modinfo -F name "$module" 2>/dev/null) + if [[ -z $canonical ]]; then + canonical=$(modinfo -k "$(uname -r)" -F name "$module" 2>/dev/null) + fi + if [[ -n $canonical ]]; then + echo "EXISTS|${canonical}" + return 0 + fi + echo "NOT_FOUND|${module}" + return 1 +} + +_modules_load_add() { + local module="$1" + [[ -z $module ]] && { echo "ERROR|missing_module"; return 1; } + if [[ ! $module =~ ^[A-Za-z0-9_-]+$ ]]; then + echo "ERROR|invalid_module|${module}" + return 1 + fi + local exists + exists=$(_module_exists "$module") + if [[ $exists != EXISTS* ]]; then + echo "ERROR|module_not_found|${module}" + return 1 + fi + local conf="${MODULES_LOAD_DIR}/${module}.conf" + if grep -qE "^\s*${module}\b" "$conf" 2>/dev/null; then + echo "ALREADY|${module}" + return 0 + fi + echo "${module}" | sudo tee "$conf" >/dev/null + echo "ADDED|${module}" +} + +_modules_load_remove() { + local module="$1" + [[ -z $module ]] && { echo "ERROR|missing_module"; return 1; } + local conf="${MODULES_LOAD_DIR}/${module}.conf" + if [[ -f $conf ]]; then + sudo rm -f "$conf" + fi + echo "REMOVED|${module}" +} + +_module_state() { + local module="$1" + [[ -z $module ]] && { echo "ERROR|missing_module"; return 1; } + if lsmod 2>/dev/null | awk '{print $1}' | grep -qx "$module"; then + echo "LOADED|${module}" + else + echo "NOT_LOADED|${module}" + fi +} + +_module_load() { + local module="$1" + [[ -z $module ]] && { echo "ERROR|missing_module"; return 1; } + sudo modprobe "$module" 2>&1 + local rc=$? + if [[ $rc -eq 0 ]]; then + echo "LOADED|${module}" + else + echo "ERROR|${module}" + fi + return $rc +} + +_module_unload() { + local module="$1" + [[ -z $module ]] && { echo "ERROR|missing_module"; return 1; } + sudo modprobe -r "$module" 2>&1 + local rc=$? + if [[ $rc -eq 0 ]]; then + echo "UNLOADED|${module}" + else + echo "ERROR|${module}" + fi + return $rc +} + _check_driver_conflicts() { local conflicts="" if pacman -Qq nvidia >/dev/null 2>&1 && pacman -Qq xf86-video-nouveau >/dev/null 2>&1; then @@ -984,7 +1416,7 @@ _list_recommended_packages() { local seen="" while IFS= read -r line; do [[ -z $line ]] && continue - IFS='|' read -r type vendor model driver pkgs missing <<<"$line" + IFS='|' read -r type vendor model driver pkgs missing device_id <<<"$line" [[ -z $pkgs ]] && continue for p in $pkgs; do if ! echo " $seen " | grep -q " $p "; then @@ -1145,6 +1577,73 @@ configure_mkinitcpio_nvidia() { echo "result=success|action=mkinit_configured" } +configure_network_driver() { + local vendor="$1" + local installed_pkgs="$2" + if [[ $vendor != "realtek" ]]; then + echo "result=skipped|reason=no_blacklist_needed" + return 0 + fi + + if echo "$installed_pkgs" | grep -qE "r8125-dkms"; then + local bl="/etc/modprobe.d/blacklist-r8169.conf" + if ! grep -q "blacklist r8169" "$bl" 2>/dev/null; then + echo "blacklist r8169" | sudo tee "$bl" >/dev/null + echo "result=blacklist_r8169" + else + echo "result=blacklist_already_set" + fi + local load="/etc/modules-load.d/r8125.conf" + if ! grep -q "^r8125" "$load" 2>/dev/null; then + echo "r8125" | sudo tee "$load" >/dev/null + fi + elif echo "$installed_pkgs" | grep -qE "r8168-dkms"; then + local bl="/etc/modprobe.d/blacklist-r8169.conf" + if ! grep -q "blacklist r8169" "$bl" 2>/dev/null; then + echo "blacklist r8169" | sudo tee "$bl" >/dev/null + echo "result=blacklist_r8169" + fi + local load="/etc/modules-load.d/r8168.conf" + if ! grep -q "^r8168" "$load" 2>/dev/null; then + echo "r8168" | sudo tee "$load" >/dev/null + fi + fi + + if command -v mkinitcpio >/dev/null 2>&1; then + sudo mkinitcpio -P 2>&1 | head -5 + fi + echo "result=network_driver_configured" +} + +_net_driver_blacklist() { + local target="$1" + local bl="/etc/modprobe.d/blacklist-r8169.conf" + case "$target" in + r8125) + echo "blacklist r8169" | sudo tee "$bl" >/dev/null + echo "r8125" | sudo tee /etc/modules-load.d/r8125.conf >/dev/null + sudo modprobe -r r8169 2>/dev/null + sudo modprobe r8125 2>/dev/null + echo "SWITCHED|r8125" + ;; + r8168) + echo "blacklist r8169" | sudo tee "$bl" >/dev/null + echo "r8168" | sudo tee /etc/modules-load.d/r8168.conf >/dev/null + sudo modprobe -r r8169 2>/dev/null + sudo modprobe r8168 2>/dev/null + echo "SWITCHED|r8168" + ;; + r8169) + sudo rm -f "$bl" 2>/dev/null + sudo rm -f /etc/modules-load.d/r8125.conf /etc/modules-load.d/r8168.conf 2>/dev/null + sudo modprobe -r r8125 r8168 2>/dev/null + sudo modprobe r8169 2>/dev/null + echo "SWITCHED|r8169" + ;; + *) echo "ERROR|invalid_network_driver" ;; + esac +} + show_hypr_env() { local env_file="$RETRO_CONFIG/env.lua" @@ -1310,8 +1809,25 @@ case "$1" in "--sys-ai-env") sys_check_ai_env ;; "--kernel-warn") _kernel_warnings ;; "--switch") _switch_driver "$2" ;; + "--net-switch") _net_driver_blacklist "$2" ;; + "--net-configure") configure_network_driver "$2" "$3" ;; + "--net-drivers") get_network_driver_candidates "$2" "$3" "$4" ;; "--modules") _list_module_params "${@:2}" ;; "--modules-set") _set_module_param "$2" "$3" "$4" ;; + "--modprobe-files") _list_modprobe_files ;; + "--blacklist-list") _list_blacklisted ;; + "--blacklist-add") _modprobe_blacklist_add "$2" ;; + "--blacklist-remove") _modprobe_blacklist_remove "$2" ;; + "--modules-load-list") _list_modules_load ;; + "--modules-load-add") _modules_load_add "$2" ;; + "--modules-load-remove") _modules_load_remove "$2" ;; + "--module-state") _module_state "$2" ;; + "--module-load") _module_load "$2" ;; + "--module-unload") _module_unload "$2" ;; + "--module-exists") _module_exists "$2" ;; + "--module-list") _list_all_modules ;; + "--module-info") _module_info "$2" ;; + "--module-descs") _module_descs "${@:2}" ;; "--conflicts") _check_driver_conflicts ;; "--dual-gpu") _detect_dual_gpu ;; "--optimus") _setup_optimus ;; From cef2b1b462af88b1d0501e2423b89e6caa575cb1 Mon Sep 17 00:00:00 2001 From: itsvlxd Date: Sun, 16 Aug 2026 12:50:50 +0300 Subject: [PATCH 06/11] feat(settings): add driver modprobe management --- cmds/tools/settings/pages/driver.py | 454 +++++++++++++++++++++++++++- 1 file changed, 447 insertions(+), 7 deletions(-) diff --git a/cmds/tools/settings/pages/driver.py b/cmds/tools/settings/pages/driver.py index 44bcd66a..3103c683 100644 --- a/cmds/tools/settings/pages/driver.py +++ b/cmds/tools/settings/pages/driver.py @@ -1,4 +1,5 @@ import os +import re import subprocess import threading from collections.abc import Iterable @@ -30,6 +31,21 @@ def _run(args: list[str], timeout: int = 15) -> str: return "" +def _can_sudo() -> bool: + """True when the current user can elevate (root, or wheel/sudo group).""" + if os.geteuid() == 0: + return True + try: + r = subprocess.run( + ["id", "-nG"], capture_output=True, text=True, timeout=5, + stdin=subprocess.DEVNULL, + ) + groups = r.stdout.split() + return bool({"wheel", "sudo"} & set(groups)) + except Exception: + return False + + class DriverPage: def __init__(self, window: "RetroSettingsWindow"): self._window = window @@ -50,6 +66,13 @@ def __init__(self, window: "RetroSettingsWindow"): self._current_driver: str = "" self._memory_summary: str = "" self._gpu_mem_label: str = "" + self._can_sudo: bool = False + self._blacklisted: list[str] = [] + self._modules_load: list[str] = [] + self._modprobe_files: list[dict] = [] + self._module_states: dict[str, str] = {} + self._all_modules: list[dict] = [] + self._module_desc_cache: dict[str, dict] = {} def build(self, header: Adw.HeaderBar) -> Adw.ToolbarView: toolbar_view, _, self._content_box, _ = make_page_layout(header=header) @@ -88,10 +111,14 @@ def worker(): current = _run(["--current-driver"]) mem = memory_summary() gpu_mem = gpu_memory_label() - GLib.idle_add(self._on_data_loaded, scan, pkgs, conflicts, specs, env, temps, updates, current, mem, gpu_mem) + blacklisted = _run(["--blacklist-list"]) + modules_load = _run(["--modules-load-list"]) + modprobe_files = _run(["--modprobe-files"]) + module_list = _run(["--module-list"]) + GLib.idle_add(self._on_data_loaded, scan, pkgs, conflicts, specs, env, temps, updates, current, mem, gpu_mem, blacklisted, modules_load, modprobe_files, module_list) threading.Thread(target=worker, daemon=True).start() - def _on_data_loaded(self, scan: str, pkgs: str, conflicts: str, specs: str, env: str, temps: str, updates: str, current: str, mem: str, gpu_mem: str) -> None: + def _on_data_loaded(self, scan: str, pkgs: str, conflicts: str, specs: str, env: str, temps: str, updates: str, current: str, mem: str, gpu_mem: str, blacklisted: str = "", modules_load: str = "", modprobe_files: str = "", module_list: str = "") -> None: self._parse_scan(scan) self._parse_packages(pkgs) self._conflicts = [c for c in conflicts.splitlines() if c.strip()] @@ -100,6 +127,36 @@ def _on_data_loaded(self, scan: str, pkgs: str, conflicts: str, specs: str, env: self._parse_temps(temps) self._memory_summary = mem self._gpu_mem_label = gpu_mem + self._can_sudo = _can_sudo() + + self._blacklisted = [] + for line in blacklisted.splitlines(): + if line.startswith("BLACKLIST|"): + self._blacklisted.append(line.split("|", 1)[1]) + + self._modules_load = [] + for line in modules_load.splitlines(): + if line.startswith("LOAD|"): + self._modules_load.append(line.split("|", 1)[1]) + + self._modprobe_files = [] + current_file = None + for line in modprobe_files.splitlines(): + if line.startswith("FILE|"): + current_file = {"name": line.split("|", 1)[1], "lines": []} + self._modprobe_files.append(current_file) + elif line.startswith("CONTENT|") and current_file is not None: + current_file["lines"].append(line.split("|", 1)[1]) + + self._all_modules = [] + for line in module_list.splitlines(): + if line.startswith("MODULE|"): + parts = line.split("|") + name = parts[1].strip() if len(parts) > 1 else "" + category = parts[2].strip() if len(parts) > 2 else "other" + if name: + self._all_modules.append({"name": name, "category": category}) + self._all_modules.sort(key=lambda m: m["name"]) self._updates = [] if "UPDATES|" in updates: @@ -129,12 +186,10 @@ def _parse_scan(self, raw: str) -> None: line = line.strip() if not line: continue - parts = line.split("|", 5) + parts = line.split("|", 6) if len(parts) < 6: continue - typ, vendor, model, driver, pkgs, missing = ( - parts[0], parts[1], parts[2], parts[3], parts[4], parts[5] - ) + typ, vendor, model, driver, pkgs, missing = parts[:6] entry = { "type": typ, "vendor": vendor, "model": model, "driver": driver, "pkgs": pkgs, "missing": missing, @@ -265,6 +320,8 @@ def _rebuild_ui(self) -> None: self._build_hardware_section() + self._build_advanced_section() + def _resolve_brand_path(self, vendor: str, component: str, model: str = "") -> str | None: if vendor == "intel": if component == "gpu": @@ -491,7 +548,12 @@ def _build_hw_row(self, group: Adw.PreferencesGroup, label: str | None, comp: di row.add_prefix(Gtk.Image.new_from_icon_name(icon)) missing = comp.get("missing", "") - if missing: + if comp.get("type") == "NET" and comp.get("driver") == "none": + badge = Gtk.Label(label="No driver") + badge.add_css_class("error") + badge.set_valign(Gtk.Align.CENTER) + row.add_suffix(badge) + elif missing: badge = Gtk.Label(label=f"{len(missing.split())} missing") badge.add_css_class("error") badge.set_valign(Gtk.Align.CENTER) @@ -568,6 +630,384 @@ def _build_packages_section(self) -> None: group.add(expander) self._content_box.append(group) + def _build_advanced_section(self) -> None: + group = Adw.PreferencesGroup(title="Advanced") + group.set_description("Modprobe blacklists, modules-load and live module control") + + if not self._can_sudo: + row = Adw.ActionRow(title="Elevated access required") + row.set_subtitle("Join the wheel/sudo group to manage kernel modules") + group.add(row) + self._content_box.append(group) + return + + bl_expander = Adw.ExpanderRow(title="Blacklisted modules") + bl_expander.add_prefix(Gtk.Image.new_from_icon_name("action-unavailable-symbolic")) + if self._blacklisted: + for mod in self._blacklisted: + bl_expander.add_row(self._make_blacklist_row(mod)) + else: + empty = Adw.ActionRow(title="No modules blacklisted") + empty.set_activatable(False) + bl_expander.add_row(empty) + group.add(bl_expander) + + bl_add_row = Adw.ActionRow(title="Blacklist a module", subtitle="Pick a module to blacklist") + bl_add_row.add_suffix(self._module_picker("", self._on_pick_blacklist)) + group.add(bl_add_row) + + load_expander = Adw.ExpanderRow(title="Modules loaded at boot (modules-load.d)") + load_expander.add_prefix(Gtk.Image.new_from_icon_name("system-run-symbolic")) + if self._modules_load: + for mod in self._modules_load: + load_expander.add_row(self._make_modules_load_row(mod)) + else: + empty = Adw.ActionRow(title="No extra modules forced at boot") + empty.set_activatable(False) + load_expander.add_row(empty) + group.add(load_expander) + + boot_add_row = Adw.ActionRow(title="Load module at boot", subtitle="Pick a module to load on startup") + boot_add_row.add_suffix(self._module_picker("", self._on_pick_boot)) + group.add(boot_add_row) + + files_expander = Adw.ExpanderRow(title="modprobe.d configuration files") + files_expander.add_prefix(Gtk.Image.new_from_icon_name("text-x-script-symbolic")) + for f in self._modprobe_files: + files_expander.add_row(self._make_file_row(f)) + if not self._modprobe_files: + empty = Adw.ActionRow(title="No modprobe.d files") + empty.set_activatable(False) + files_expander.add_row(empty) + group.add(files_expander) + + self._content_box.append(group) + + def _make_blacklist_row(self, mod: str) -> Adw.ActionRow: + row = Adw.ActionRow(title=mod, subtitle="blacklisted in /etc/modprobe.d") + rm_btn = Gtk.Button(icon_name="edit-delete-symbolic") + rm_btn.set_tooltip_text("Remove from blacklist") + rm_btn.add_css_class("flat") + rm_btn.connect("clicked", lambda _b, m=mod: self._pkexec_action( + ["--blacklist-remove", m], + success=f"Unblacklisted {m}", refresh=True, + )) + row.add_suffix(rm_btn) + return row + + def _make_modules_load_row(self, mod: str) -> Adw.ActionRow: + row = Adw.ActionRow(title=mod, subtitle="loaded at boot") + rm_btn = Gtk.Button(icon_name="edit-delete-symbolic") + rm_btn.set_tooltip_text("Remove from boot load") + rm_btn.add_css_class("flat") + rm_btn.connect("clicked", lambda _b, m=mod: self._pkexec_action( + ["--modules-load-remove", m], + success=f"Removed {m} from boot load", refresh=True, + )) + row.add_suffix(rm_btn) + return row + + def _make_file_row(self, f: dict) -> Adw.ActionRow: + name = f.get("name", "") + content = "\n".join(f.get("lines", [])) or "(empty)" + row = Adw.ActionRow(title=name) + sub = Gtk.Label(label=content) + sub.set_wrap(True) + sub.set_xalign(0.0) + sub.set_max_width_chars(60) + sub.add_css_class("dim-label") + sub.add_css_class("caption") + sub.set_margin_top(4) + row.add_suffix(sub) + row.set_activatable(False) + return row + + _MODULE_ICONS = { + "network": "network-wireless-symbolic", + "display": "video-display-symbolic", + "media": "camera-video-symbolic", + "input": "input-keyboard-symbolic", + "audio": "audio-speakers-symbolic", + "bluetooth": "bluetooth-symbolic", + "storage": "drive-harddisk-symbolic", + "usb": "usb-symbolic", + "serial": "serial-symbolic", + "hid": "input-gaming-symbolic", + "thermal": "thermometer-symbolic", + "crypto": "security-high-symbolic", + "filesystem": "folder-symbolic", + "arch": "cpu-symbolic", + "dkms": "application-x-addon-symbolic", + "staging": "dialog-warning-symbolic", + "other": "application-x-executable-symbolic", + } + + def _module_icon(self, category: str) -> Gtk.Image: + icon = Gtk.Image.new_from_icon_name(self._MODULE_ICONS.get(category, "application-x-executable-symbolic")) + icon.set_pixel_size(16) + return icon + + def _module_picker(self, current: str, on_pick) -> Gtk.Button: + """A compact searchable dropdown of every installed kernel module. + + The list is rendered lazily (25 matches per page, appended on scroll). + Each row is built only after its name, icon and description have all + been fetched together, so no row appears half-populated. A category + dropdown sits inline with the search bar to filter by module class. + """ + _PAGE = 25 + btn = Gtk.Button(label=current or "Choose module\u2026") + btn.set_valign(Gtk.Align.CENTER) + btn.set_size_request(-1, 28) + btn.set_tooltip_text("Choose a kernel module\u2026") + + search = Gtk.SearchEntry() + search.set_placeholder_text("Search modules\u2026") + search.set_hexpand(True) + + categories = ["all"] + [c for c in self._category_names() if c != "all"] + cat_model = Gtk.StringList.new([c.title() for c in categories]) + cat_dd = Gtk.DropDown(model=cat_model) + cat_dd.set_selected(0) + cat_dd.set_tooltip_text("Filter by category") + + search_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + search_row.set_margin_top(8) + search_row.set_margin_start(8) + search_row.set_margin_end(8) + search_row.set_margin_bottom(4) + search_row.append(search) + search_row.append(cat_dd) + + picker_list = Gtk.ListBox() + picker_list.set_selection_mode(Gtk.SelectionMode.SINGLE) + + scrolled = Gtk.ScrolledWindow() + scrolled.set_child(picker_list) + scrolled.set_min_content_height(200) + scrolled.set_max_content_height(320) + scrolled.set_vexpand(True) + + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + vbox.set_size_request(360, -1) + vbox.append(search_row) + vbox.append(scrolled) + + popover = Gtk.Popover() + popover.set_parent(btn) + popover.set_child(vbox) + + state = { + "search": search, "cat": cat_dd, "list": picker_list, + "scrolled": scrolled, "popover": popover, "on_pick": on_pick, + "page": 0, "query": "", "category": "all", "done": False, + } + + def matches_all() -> list[dict]: + q = state["query"].strip().lower() + cat = state["category"] + result = [] + for m in self._all_modules: + if cat != "all" and m.get("category", "other") != cat: + continue + if q and q not in m["name"].lower(): + continue + result.append(m) + return result + + def append_chunk() -> None: + matches = matches_all() + total = len(matches) + if total == 0: + empty = Gtk.Label(label="No modules found") + empty.add_css_class("dim-label") + empty.set_margin_top(12) + empty.set_margin_bottom(12) + empty.set_halign(Gtk.Align.CENTER) + picker_list.append(empty) + state["done"] = True + return + start = state["page"] * _PAGE + end = min(start + _PAGE, total) + chunk = matches[start:end] + state["done"] = end >= total + self._load_descs(chunk) + existing = set() + child = picker_list.get_first_child() + while child is not None: + existing.add(child.get_title()) + child = child.get_next_sibling() + for m in chunk: + if m["name"] in existing: + continue + picker_list.append(self._make_module_row(m)) + if not state["done"]: + GLib.idle_add(load_more_if_needed) + + def fill() -> None: + while child := picker_list.get_first_child(): + picker_list.remove(child) + state["page"] = 0 + state["done"] = False + append_chunk() + + def load_more_if_needed() -> None: + if state["done"]: + return + adj = scrolled.get_vadjustment() + if adj is not None and adj.get_upper() - (adj.get_value() + adj.get_page_size()) < 40: + state["page"] += 1 + append_chunk() + + def on_search_changed(_e) -> None: + state["query"] = search.get_text() + fill() + + def on_cat_changed(_d, _pspec) -> None: + idx = cat_dd.get_selected() + state["category"] = categories[idx] if 0 <= idx < len(categories) else "all" + fill() + + def on_row_activated(_list, row: Gtk.ListBoxRow) -> None: + mod = row.get_title() + popover.popdown() + on_pick(mod) + + adj = scrolled.get_vadjustment() + if adj is not None: + adj.connect("value-changed", lambda _a: load_more_if_needed()) + + search.connect("search-changed", on_search_changed) + cat_dd.connect("notify::selected", on_cat_changed) + picker_list.connect("row-activated", on_row_activated) + btn.connect("clicked", lambda _b: self._toggle_picker(popover, fill)) + popover.connect("closed", lambda _p: search.set_text("")) + return btn + + def _toggle_picker(self, popover: Gtk.Popover, fill) -> None: + if popover.get_visible(): + popover.popdown() + else: + fill() + popover.popup() + def _category_names(self) -> list[str]: + names = {m.get("category", "other") for m in self._all_modules} + return sorted(names) or ["other"] + + def _load_descs(self, chunk: list[dict]) -> None: + """Fetch descriptions for a chunk in one batch call, filling the cache. + + The batch call keeps this fast (a single subprocess invocation), so + the picker can render rows synchronously without blocking noticeably. + """ + names = [m["name"] for m in chunk if m["name"] not in self._module_desc_cache] + if not names: + return + out = _run(["--module-descs", *names]) + for line in out.splitlines(): + if line.startswith("DESC|"): + _, name, desc = line.split("|", 2) + self._module_desc_cache.setdefault(name, {})["desc"] = desc + for name in names: + self._module_desc_cache.setdefault(name, {}) + + def _make_module_row(self, m: dict) -> Adw.ActionRow: + row = Adw.ActionRow(title=m["name"]) + row.add_css_class("module-picker-row") + row.add_prefix(self._module_icon(m.get("category", "other"))) + desc = self._module_desc_cache.get(m["name"], {}).get("desc", "") + if desc: + row.set_subtitle(desc[:80] + ("\u2026" if len(desc) > 80 else "")) + info_btn = Gtk.Button(icon_name="help-about-symbolic") + info_btn.add_css_class("flat") + info_btn.set_tooltip_text("Module details") + info_btn.set_size_request(-1, 28) + info_btn.connect("clicked", lambda _b, mod=m: self._show_module_info(info_btn, mod)) + row.add_suffix(info_btn) + row.set_activatable(True) + return row + + def _show_module_info(self, anchor: Gtk.Widget, m: dict) -> None: + info = dict(self._module_desc_cache.get(m["name"]) or {}) + if len(info) <= 1: + full = _run(["--module-info", m["name"]]) + for line in full.splitlines(): + if "|" in line: + k, _, v = line.partition("|") + info[k.lower()] = v + self._module_desc_cache[m["name"]] = info + lines = [] + lines.append(f"{m['name']}") + if info.get("desc"): + lines.append(f"{info['desc']}") + fields = [ + ("Author", "author"), ("License", "license"), ("Version", "version"), + ("Depends", "depends"), ("Firmware", "firmware"), ("Built-in", "builtin"), + ("Category", "category"), + ] + for label, key in fields: + val = info.get(key) + if val: + lines.append(f"{label}: {val}") + body = "\n".join(lines) + pop = Gtk.Popover() + pop.set_parent(anchor) + label = Gtk.Label(label=body) + label.set_use_markup(True) + label.set_wrap(True) + label.set_max_width_chars(60) + label.set_margin_top(8) + label.set_margin_bottom(8) + label.set_margin_start(12) + label.set_margin_end(12) + pop.set_child(label) + pop.popup() + + def _on_pick_blacklist(self, mod: str) -> None: + self._validate_and_apply(mod, "blacklist") + + def _on_pick_boot(self, mod: str) -> None: + self._validate_and_apply(mod, "boot") + + def _validate_and_apply(self, mod: str, kind: str) -> None: + if not re.fullmatch(r"[A-Za-z0-9_-]+", mod): + self._window.show_toast("Invalid module name", timeout=4) + return + + def check(): + res = _run(["--module-exists", mod]) + if res.startswith("EXISTS|"): + canonical = res.split("|", 1)[1] + if kind == "blacklist": + self._pkexec_action(["--blacklist-add", canonical], success=f"Blacklisted {canonical}", refresh=True) + else: + self._pkexec_action(["--modules-load-add", canonical], success=f"Loading {canonical} at boot", refresh=True) + else: + GLib.idle_add(lambda: self._window.show_toast(f"Module not found: {mod}", timeout=4)) + + threading.Thread(target=check, daemon=True).start() + + def _pkexec_action(self, args: list[str], success: str, refresh: bool = False) -> None: + self._window.show_toast("Requesting elevated access\u2026", timeout=3) + full_args = ["pkexec", "bash", _DRIVER_CORE, *args] + + def worker(): + try: + r = subprocess.run(full_args, capture_output=True, text=True, timeout=600, stdin=subprocess.DEVNULL) + except Exception as e: + GLib.idle_add(lambda: self._window.show_bug_toast("Action failed", detail=str(e), timeout=6)) + return + out = (r.stdout or "") + (r.stderr or "") + if r.returncode == 0: + GLib.idle_add(lambda: self._window.show_toast(success)) + if refresh: + GLib.idle_add(self._load_data) + else: + tail = "\n".join(out.strip().splitlines()[-8:]) + GLib.idle_add(lambda: self._window.show_bug_toast("Action failed", detail=tail or str(r.returncode), timeout=8)) + + threading.Thread(target=worker, daemon=True).start() + def _on_dropdown_driver_switch(self, _drop, _pspec, drop) -> None: idx = drop.get_selected() target = "xe" if idx == 1 else "i915" From b9d25be8974ce7159fc5bb775eb091172348a4ec Mon Sep 17 00:00:00 2001 From: itsvlxd Date: Sun, 16 Aug 2026 20:16:03 +0300 Subject: [PATCH 07/11] feat(driver): add gpu specific driver detection --- cmds/tools/driver.sh | 50 ++++++- scripts/driver_core.sh | 297 +++++++++++++++++++++++++++++++++-------- 2 files changed, 294 insertions(+), 53 deletions(-) diff --git a/cmds/tools/driver.sh b/cmds/tools/driver.sh index 261d9b57..eceb9cbc 100755 --- a/cmds/tools/driver.sh +++ b/cmds/tools/driver.sh @@ -77,7 +77,7 @@ cmd_driver() { while IFS= read -r line; do [[ -z $line ]] && continue - IFS='|' read -r type vendor model driver pkgs missing <<<"$line" + IFS='|' read -r type vendor model driver pkgs missing device_id <<<"$line" case "$type" in GPU) @@ -254,6 +254,27 @@ cmd_driver() { export SKIP_PROMPT=true fi + if [[ $flag == "extra" ]]; then + local extra_out + extra_out=$(bash "$driver_script" --install-extra) + local extra_exit=$? + if echo "$extra_out" | grep -q "NO_EXTRA_DRIVERS"; then + rx_log "info" "No extra optional drivers available for this system" + return 0 + fi + if echo "$extra_out" | grep -q "EXTRA_MISSING:"; then + local extra_list + extra_list=$(echo "$extra_out" | sed -n 's/^EXTRA_MISSING://p' | awk '{print $1}') + rx_log "info" "Installing optional extra drivers: ${PINK}${extra_list}${RESET}" + fi + if echo "$extra_out" | grep -q "EXTRA_INSTALL_COMPLETE"; then + rx_log "success" "Extra optional drivers installed" + return 0 + fi + rx_log "error" "Extra driver install failed or nothing to install" + return $extra_exit + fi + local first_pass first_pass=$(bash "$driver_script" --install) [[ -z $first_pass ]] && rx_log "error" "Failed to scan for missing drivers" && return 1 @@ -303,6 +324,33 @@ cmd_driver() { fi ;; + "uninstall") + if [[ $flag != "extra" ]]; then + rx_log "error" "Usage: retro driver uninstall extra" + return 1 + fi + local uni_out + uni_out=$(bash "$driver_script" --extra-uninstall) + if echo "$uni_out" | grep -q "EXTRA_UNINSTALL_PKGS:"; then + local uni_list + uni_list=$(echo "$uni_out" | sed -n 's/^EXTRA_UNINSTALL_PKGS://p' | awk '{print $1}') + rx_log "info" "Removing optional extra drivers: ${PINK}${uni_list}${RESET}" + fi + if echo "$uni_out" | grep -q "EXTRA_UNINSTALL_COMPLETE"; then + rx_log "success" "Extra optional drivers removed" + return 0 + elif echo "$uni_out" | grep -q "EXTRA_UNINSTALL_CANCELLED"; then + rx_log "info" "Extra driver removal cancelled" + return 0 + elif echo "$uni_out" | grep -q "NO_EXTRA_DRIVERS_INSTALLED"; then + rx_log "info" "No extra optional drivers installed" + return 0 + else + rx_log "error" "Extra driver removal failed" + return 1 + fi + ;; + "update") local upd_data=$(bash "$driver_script" --check-updates) IFS='|' read -r key count pkgs <<<"$upd_data" diff --git a/scripts/driver_core.sh b/scripts/driver_core.sh index 83069a8e..2409b947 100755 --- a/scripts/driver_core.sh +++ b/scripts/driver_core.sh @@ -280,6 +280,37 @@ detect_other() { done } +_gpu_generation() { + # Heuristic from the model string: returns "turing_plus" for GPUs that + # work with nvidia-open-dkms, "legacy" otherwise. + local model="$1" + if echo "$model" | grep -qiE "RTX (20[0-9]{2}|30[0-9]{2}|40[0-9]{2}|50[0-9]{2}|16[0-9]{2})|GTX 16|Turing|Ampere|Ada|Blackwell"; then + echo "turing_plus" + else + echo "legacy" + fi +} + +_nvidia_dkms_pkg() { + # Prefer the proprietary dkms for legacy GPUs when available, else fall + # back to the open driver so the list stays installable. + if pacman -Si nvidia-dkms >/dev/null 2>&1; then + echo "nvidia-dkms" + else + echo "nvidia-open-dkms" + fi +} + +get_gpu_ai_packages() { + local vendor="$1" + case "$vendor" in + intel) echo "intel-compute-runtime level-zero-loader" ;; + nvidia) echo "cuda cudnn nvidia-container-toolkit" ;; + amd) echo "rocm-hip-sdk" ;; + *) echo "" ;; + esac +} + get_gpu_packages() { local vendor="$1" local model="$2" @@ -287,25 +318,24 @@ get_gpu_packages() { case "$vendor" in intel) local pkgs="vulkan-intel lib32-vulkan-intel intel-media-driver libva-intel-driver intel-gpu-tools libva-utils" - if _kernel_version_ge 6 8; then - if echo "$model" | grep -qiE "arc|meteor|lunar|battlemage"; then - pkgs+=" level-zero-loader intel-compute-runtime" - fi - fi if echo "$model" | grep -qiE "HD Graphics [2-5]|UHD Graphics 6[0-2]"; then pkgs+=" xf86-video-intel" fi echo "$pkgs" ;; nvidia) - local pkgs="nvidia-open-dkms nvidia-utils lib32-nvidia-utils nvidia-settings lib32-opencl-nvidia" - pkgs+=" cuda cudnn nvidia-container-toolkit" - pkgs+=" libva-utils vdpauinfo" + local gen=$(_gpu_generation "$model") + local pkgs="" + if [[ $gen == "turing_plus" ]]; then + pkgs="nvidia-open-dkms" + else + pkgs=$(_nvidia_dkms_pkg) + fi + pkgs+=" nvidia-utils lib32-nvidia-utils nvidia-settings nvidia-prime lib32-opencl-nvidia libva-utils vdpauinfo" echo "$pkgs" ;; amd) local pkgs="mesa lib32-mesa vulkan-radeon lib32-vulkan-radeon xf86-video-amdgpu libva-utils vdpauinfo radeontop" - pkgs+=" rocm-hip-sdk" echo "$pkgs" ;; *) @@ -333,25 +363,20 @@ get_network_packages() { intel) echo "linux-firmware iwd" ;; realtek) case "$device_id" in - 8852be) echo "8852be-dkms-git linux-firmware" ;; - 8852ce) echo "8852be-dkms-git linux-firmware" ;; - 8851be) echo "8852be-dkms-git linux-firmware" ;; - 8821ce) echo "rtl8821ce-dkms-git linux-firmware" ;; - 8821cu) echo "rtl8821cu-dkms-git linux-firmware" ;; - 8822ce) echo "rtl88x2ce-dkms-git linux-firmware" ;; - 8822cu) echo "rtl8822cu-dkms-git linux-firmware" ;; - 8822bu) echo "rtl88x2bu-dkms-git linux-firmware" ;; - 8852au|8832au) echo "rtl8852au-dkms-git linux-firmware" ;; - 8852bu|8832bu) echo "rtl8852bu-dkms-git linux-firmware" ;; - 8852cu) echo "rtl8852cu-dkms-git linux-firmware" ;; - 8812au|8814au|8821au|8811au) echo "rtl8812au-openhd-dkms-git linux-firmware" ;; + b852|c852|b851|8852be) echo "8852be-dkms-git linux-firmware" ;; + c821) echo "rtl8821ce-dkms-git linux-firmware" ;; + c822|b822) echo "rtl88x2ce-dkms-git linux-firmware" ;; + b82c|c82c) echo "rtl88x2bu-dkms-git linux-firmware" ;; + b885|c885) echo "rtl8852au-dkms-git linux-firmware" ;; + 8812|8814|881a|881b|8821|8811) echo "rtl8812au-openhd-dkms-git linux-firmware" ;; + 8188|8189|818a|818b|818c) echo "rtl8188eu-dkms-git linux-firmware" ;; *) echo "linux-firmware" ;; esac ;; broadcom) echo "broadcom-wl-dkms linux-firmware" ;; mediatek) echo "linux-firmware linux-firmware-mediatek" ;; atheros) echo "linux-firmware ath9k-htc-firmware" ;; - qualcomm) echo "linux-firmware" ;; + qualcomm) echo "linux-firmware linux-firmware-qcom" ;; *) echo "linux-firmware iwd" ;; esac ;; @@ -360,7 +385,7 @@ get_network_packages() { realtek) case "$device_id" in 8125) echo "r8125-dkms linux-firmware" ;; - 8168|8111|8169|8101) echo "r8168-dkms linux-firmware" ;; + 8168|8167|8169|8101|8111) echo "r8168-dkms linux-firmware" ;; *) echo "linux-firmware" ;; esac ;; @@ -391,15 +416,13 @@ get_network_driver_candidates() { case "$vendor" in realtek) case "$device_id" in - 8852be|8852ce|8851be) echo "8852be-dkms-git" ;; - 8821ce) echo "rtl8821ce-dkms-git" ;; - 8821cu) echo "rtl8821cu-dkms-git" ;; - 8822ce) echo "rtl88x2ce-dkms-git" ;; - 8822bu) echo "rtl88x2bu-dkms-git" ;; - 8852au|8832au) echo "rtl8852au-dkms-git" ;; - 8852bu|8832bu) echo "rtl8852bu-dkms-git" ;; - 8852cu) echo "rtl8852cu-dkms-git" ;; - 8812au|8814au|8821au|8811au) echo "rtl8812au-openhd-dkms-git" ;; + b852|c852|b851|8852be) echo "8852be-dkms-git" ;; + c821) echo "rtl8821ce-dkms-git" ;; + c822|b822) echo "rtl88x2ce-dkms-git" ;; + b82c|c82c) echo "rtl88x2bu-dkms-git" ;; + b885|c885) echo "rtl8852au-dkms-git" ;; + 8812|8814|881a|881b|8821|8811) echo "rtl8812au-openhd-dkms-git" ;; + 8188|8189|818a|818b|818c) echo "rtl8188eu-dkms-git" ;; *) echo "" ;; esac ;; @@ -429,17 +452,37 @@ _aur_fetch_network_driver() { command -v paru >/dev/null 2>&1 && helper="paru" command -v "$helper" >/dev/null 2>&1 || return 1 local terms=() - [[ -n $device_id ]] && terms+=("rtl${device_id}" "mt${device_id}" "${device_id}-dkms") - local chip=$(echo "$model" | grep -oiE "RTL[0-9a-z]+|MT[0-9]+|AX[0-9]+" | head -1) - [[ -n $chip ]] && terms+=("${chip,,}-dkms") + # Prefer searching by chip name from the model string (RTL8852BE -> rtl8852be-dkms). + local chip=$(echo "$model" | grep -oiE "RTL[0-9]+[A-Z]*|MT[0-9]+|MT792[0-9]|AX[0-9]+" | head -1 | tr '[:upper:]' '[:lower:]') + [[ -n $chip ]] && terms+=("${chip}-dkms" "${chip}") + # Fall back to mapping known hex device ids to their package names. + if [[ -n $device_id ]]; then + local known=$(_realtek_wifi_pkg_by_id "$device_id") + [[ -n $known ]] && terms+=("$known") + fi local found="" for t in "${terms[@]}"; do - found=$($helper -Ss "$t" 2>/dev/null | grep -iE "dkms" | awk '{print $1}' | grep -viE "firmware|bluetooth" | head -1) + found=$($helper -Ss "$t" 2>/dev/null | grep -iE "dkms" | awk '{print $1}' | grep -viE "firmware|bluetooth|openhd" | head -1) [[ -n $found ]] && break done echo "$found" } +_realtek_wifi_pkg_by_id() { + local device_id="$1" + case "$device_id" in + b852|c852|b851) echo "8852be-dkms-git" ;; + c821) echo "rtl8821ce-dkms-git" ;; + c82c) echo "rtl8821cu-dkms-git" ;; + c822|b822) echo "rtl88x2ce-dkms-git" ;; + b82c) echo "rtl88x2bu-dkms-git" ;; + b885|c885) echo "rtl8852au-dkms-git" ;; + 8812|8814|881a|881b) echo "rtl8812au-openhd-dkms-git" ;; + 8188|8189|818a) echo "rtl8188eu-dkms-git" ;; + *) echo "" ;; + esac +} + get_other_packages() { local type="$1" case "$type" in @@ -452,7 +495,20 @@ get_other_packages() { } get_firmware_packages() { - echo "linux-firmware sof-firmware" + local pkgs="linux-firmware sof-firmware" + # Vendor-specific firmware subpackages when matching hardware is present. + local gpus=$(detect_gpus) + if echo "$gpus" | grep -qE "^\S+\|\S+\|intel\|"; then + pkgs+=" linux-firmware-intel" + fi + local nets=$(detect_network) + if echo "$nets" | grep -qE "\|mediatek\|"; then + pkgs+=" linux-firmware-mediatek" + fi + if echo "$nets" | grep -qE "\|qualcomm\||\|atheros\|"; then + pkgs+=" linux-firmware-qcom" + fi + echo "$pkgs" } get_profile_packages() { @@ -585,9 +641,9 @@ run_full_scan() { if [[ $npus != "NONE" && -n $npus ]]; then while IFS= read -r npu; do IFS='|' read -r vendor model driver <<<"$npu" - local pkgs=$(get_npu_packages "$vendor") - local missing=$(_get_missing "$pkgs") - results+="NPU|${vendor}|${model}|${driver}|${pkgs}|${missing}\n" + # NPU drivers are optional extras; keep the row for display but + # never count them among missing MAIN driver packages. + results+="NPU|${vendor}|${model}|${driver}||\n" done <<<"$npus" fi local networks=$(detect_network) @@ -704,45 +760,178 @@ run_full_install_confirmed() { echo "ALL_DRIVERS_INSTALLED" return 0 fi - echo "MISSING:$unique_missing" + rx_log_file "info" "MISSING: $unique_missing" install_packages "$unique_missing" local install_status=$? if [[ $install_status -eq 0 ]]; then for vendor in "${gpu_vendors_found[@]}"; do case "$vendor" in nvidia) - configure_nvidia_drm - configure_ai_env "nvidia" + configure_nvidia_drm >/dev/null + configure_ai_env "nvidia" >/dev/null ;; intel) - configure_ai_env "intel" + configure_ai_env "intel" >/dev/null ;; amd) - configure_ai_env "amd" + configure_ai_env "amd" >/dev/null ;; esac done - generate_hypr_env + generate_hypr_env >/dev/null if echo "$unique_missing" | grep -qE "nvidia-open-dkms|nvidia"; then - configure_mkinitcpio_nvidia + configure_mkinitcpio_nvidia >/dev/null fi if [[ -n $net_dkms ]]; then - configure_network_driver "$net_dkms" "$unique_missing" + configure_network_driver "$net_dkms" "$unique_missing" >/dev/null fi if [[ -n $aur_extra ]]; then - echo "AUR_INSTALLED:$aur_extra" + rx_log_file "info" "AUR_INSTALLED: $aur_extra" fi if echo "$unique_missing" | grep -qE "dkms|nvidia|cuda|rocm"; then - echo "INITRAMFS_UPDATE_NEEDED" + rx_log_file "info" "INITRAMFS_UPDATE_NEEDED" fi - echo "INSTALL_COMPLETE" + rx_log_file "info" "INSTALL_COMPLETE" + return 0 else - echo "INSTALL_FAILED" + rx_log_file "error" "INSTALL_FAILED" + return 1 + fi +} + +_extra_driver_set() { + # All optional/AI packages relevant to the detected hardware: per-GPU + # compute stacks plus NPU accelerators. Prints space-separated names. + local scan_data=$(run_full_scan) + local extra_pkgs="" + while IFS= read -r line; do + [[ -z $line ]] && continue + IFS='|' read -r type vendor model driver pkgs missing device_id <<<"$line" + if [[ $type == "GPU" ]]; then + local ai_pkgs=$(get_gpu_ai_packages "$vendor") + [[ -n $ai_pkgs ]] && extra_pkgs+=" $ai_pkgs" + fi + done <<<"$scan_data" + local npus=$(detect_npu) + if [[ $npus != "NONE" && -n $npus ]]; then + while IFS= read -r npu; do + IFS='|' read -r vendor model driver <<<"$npu" + local npu_pkgs=$(get_npu_packages "$vendor") + [[ -n $npu_pkgs ]] && extra_pkgs+=" $npu_pkgs" + done <<<"$npus" + fi + echo "$extra_pkgs" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ' +} + +run_extra_install() { + # Optional/AI drivers only: CUDA/ROCm/oneAPI stacks plus other extras. + # The base install (--install) never touches these. + local scan_data=$(run_full_scan) + local extra_pkgs=$(_extra_driver_set) + local gpu_vendors_found=() + while IFS= read -r line; do + [[ -z $line ]] && continue + IFS='|' read -r type vendor model driver pkgs missing device_id <<<"$line" + [[ $type == "GPU" ]] && gpu_vendors_found+=("$vendor") + done <<<"$scan_data" + + if [[ -z "$(echo "$extra_pkgs" | xargs)" ]]; then + echo "NO_EXTRA_DRIVERS" + return 0 + fi + echo "EXTRA_MISSING:$extra_pkgs" + install_packages "$extra_pkgs" + local install_status=$? + if [[ $install_status -eq 0 ]]; then + for vendor in "${gpu_vendors_found[@]}"; do + configure_ai_env "$vendor" >/dev/null + done + generate_hypr_env >/dev/null + if echo "$extra_pkgs" | grep -qE "rocm|intel-compute-runtime|level-zero"; then + rx_log_file "info" "INITRAMFS_UPDATE_NEEDED" + fi + echo "EXTRA_INSTALL_COMPLETE" + else + echo "EXTRA_INSTALL_FAILED" + return 1 + fi +} + +list_extra_drivers() { + # Report which optional/AI packages are missing (no install). + local unique_extra=$(_extra_driver_set) + if [[ -z "$(echo "$unique_extra" | xargs)" ]]; then + echo "NONE" + return 0 + fi + local missing="" + for p in $unique_extra; do + _is_pkg_installed "$p" || missing+=" $p" + done + missing=$(echo "$missing" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ') + if [[ -z "$(echo "$missing" | xargs)" ]]; then + echo "NONE" + return 0 + fi + echo "$missing" +} + +list_installed_extra_drivers() { + # Report which optional/AI packages are installed (no changes). + local unique_extra=$(_extra_driver_set) + if [[ -z "$(echo "$unique_extra" | xargs)" ]]; then + echo "NONE" + return 0 + fi + local installed="" + for p in $unique_extra; do + _is_pkg_installed "$p" && installed+=" $p" + done + installed=$(echo "$installed" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ') + if [[ -z "$(echo "$installed" | xargs)" ]]; then + echo "NONE" + return 0 + fi + echo "$installed" +} + +run_extra_uninstall() { + # Remove installed optional/AI drivers, with an interactive prompt. + local installed=$(_extra_driver_set) + local to_remove="" + for p in $installed; do + _is_pkg_installed "$p" && to_remove+=" $p" + done + to_remove=$(echo "$to_remove" | tr ' ' '\n' | sort -u | grep -v '^$' | tr '\n' ' ') + if [[ -z "$(echo "$to_remove" | xargs)" ]]; then + echo "NO_EXTRA_DRIVERS_INSTALLED" + return 0 + fi + echo "EXTRA_UNINSTALL_PKGS:$to_remove" + printf "Remove optional extra drivers? [y/N]: " >&2 + read -r confirm + if [[ ! $confirm =~ ^[Yy]$ ]]; then + echo "EXTRA_UNINSTALL_CANCELLED" + return 0 + fi + local helper="yay" + command -v paru >/dev/null 2>&1 && helper="paru" + if command -v "$helper" >/dev/null 2>&1; then + $helper -Rns --noconfirm $to_remove 2>&1 + else + sudo pacman -Rns --noconfirm $to_remove 2>&1 + fi + local rm_status=$? + if [[ $rm_status -eq 0 ]]; then + generate_hypr_env >/dev/null + echo "EXTRA_UNINSTALL_COMPLETE" + else + echo "EXTRA_UNINSTALL_FAILED" return 1 fi } @@ -1803,6 +1992,10 @@ case "$1" in "--scan") run_full_scan ;; "--install") run_full_install ;; "--install-confirmed") run_full_install_confirmed ;; + "--install-extra") run_extra_install ;; + "--extra-list") list_extra_drivers ;; + "--extra-installed") list_installed_extra_drivers ;; + "--extra-uninstall") run_extra_uninstall ;; "--verify") verify_install "$2" ;; "--info") show_device_info "$2" ;; "--services") get_service_hints ;; From b4c6171b6b2e551317dd406149699f3c77009eed Mon Sep 17 00:00:00 2001 From: itsvlxd Date: Sun, 16 Aug 2026 20:24:41 +0300 Subject: [PATCH 08/11] feat(settings): add driver optional install --- cmds/tools/settings/core/config.py | 5 ++ cmds/tools/settings/pages/driver.py | 131 ++++++++++++++++++++++++++-- cmds/tools/settings/style.css | 10 +++ cmds/tools/settings/window.py | 3 + 4 files changed, 140 insertions(+), 9 deletions(-) diff --git a/cmds/tools/settings/core/config.py b/cmds/tools/settings/core/config.py index 13d33fde..c54034b7 100644 --- a/cmds/tools/settings/core/config.py +++ b/cmds/tools/settings/core/config.py @@ -231,6 +231,7 @@ class ConfigSections: layer_rules: list[str] | None = None window_rules_nodes: list["Rule"] | None = None layer_rules_nodes: list["Rule"] | None = None + gestures: list[str] | None = None RETRO_SETTINGS_DIR = Path.home() / ".config" / "retro" @@ -467,6 +468,7 @@ def ensure_managed_path_matches_mode(stored: str) -> str | None: # never emitted (``migrate()`` rewrites every v2 line to v3 in-memory). KEYWORD_WINDOWRULEV2 = "windowrulev2" KEYWORD_LAYERRULE = "layerrule" +KEYWORD_GESTURE = "gesture" # Non-bind Hyprland keywords settings actively manages a page for. Bind # variants (``bind``, ``binde``, ``bindm``, …) are checked separately via @@ -485,6 +487,7 @@ def ensure_managed_path_matches_mode(stored: str) -> str | None: KEYWORD_WINDOWRULE, KEYWORD_WINDOWRULEV2, KEYWORD_LAYERRULE, + KEYWORD_GESTURE, ) ) @@ -674,6 +677,8 @@ def _build_document(values: dict[str, str], sections: ConfigSections) -> Documen _add_section(doc, "Workspaces", sections.workspaces) if sections.binds: _add_section(doc, "Keybinds", sections.binds) + if sections.gestures: + _add_section(doc, "Gestures", sections.gestures) # Window rules sit before autostart so any rule overrides are in effect # before exec'd processes spawn matching windows on reload. # Use pre-built Rule nodes when available (bypasses migrate's broken diff --git a/cmds/tools/settings/pages/driver.py b/cmds/tools/settings/pages/driver.py index 3103c683..76d753c2 100644 --- a/cmds/tools/settings/pages/driver.py +++ b/cmds/tools/settings/pages/driver.py @@ -2,6 +2,7 @@ import re import subprocess import threading +import time from collections.abc import Iterable from typing import TYPE_CHECKING @@ -73,6 +74,9 @@ def __init__(self, window: "RetroSettingsWindow"): self._module_states: dict[str, str] = {} self._all_modules: list[dict] = [] self._module_desc_cache: dict[str, dict] = {} + self._extra_missing: str = "" + self._extra_installed: str = "" + self._load_gen = 0 def build(self, header: Adw.HeaderBar) -> Adw.ToolbarView: toolbar_view, _, self._content_box, _ = make_page_layout(header=header) @@ -100,6 +104,9 @@ def build(self, header: Adw.HeaderBar) -> Adw.ToolbarView: return toolbar_view def _load_data(self) -> None: + self._load_gen += 1 + gen = self._load_gen + def worker(): scan = _run(["--scan"]) pkgs = _run(["--packages"]) @@ -115,10 +122,14 @@ def worker(): modules_load = _run(["--modules-load-list"]) modprobe_files = _run(["--modprobe-files"]) module_list = _run(["--module-list"]) - GLib.idle_add(self._on_data_loaded, scan, pkgs, conflicts, specs, env, temps, updates, current, mem, gpu_mem, blacklisted, modules_load, modprobe_files, module_list) + extra = _run(["--extra-list"]) + extra_installed = _run(["--extra-installed"]) + GLib.idle_add(self._on_data_loaded, gen, scan, pkgs, conflicts, specs, env, temps, updates, current, mem, gpu_mem, blacklisted, modules_load, modprobe_files, module_list, extra, extra_installed) threading.Thread(target=worker, daemon=True).start() - def _on_data_loaded(self, scan: str, pkgs: str, conflicts: str, specs: str, env: str, temps: str, updates: str, current: str, mem: str, gpu_mem: str, blacklisted: str = "", modules_load: str = "", modprobe_files: str = "", module_list: str = "") -> None: + def _on_data_loaded(self, gen: int, scan: str, pkgs: str, conflicts: str, specs: str, env: str, temps: str, updates: str, current: str, mem: str, gpu_mem: str, blacklisted: str = "", modules_load: str = "", modprobe_files: str = "", module_list: str = "", extra: str = "", extra_installed: str = "") -> None: + if gen != self._load_gen: + return self._parse_scan(scan) self._parse_packages(pkgs) self._conflicts = [c for c in conflicts.splitlines() if c.strip()] @@ -166,6 +177,14 @@ def _on_data_loaded(self, scan: str, pkgs: str, conflicts: str, specs: str, env: self._current_driver = current.strip() if current else "" + self._extra_missing = "" + if extra.strip() and extra.strip() != "NONE": + self._extra_missing = extra.strip() + + self._extra_installed = "" + if extra_installed.strip() and extra_installed.strip() != "NONE": + self._extra_installed = extra_installed.strip() + self._missing_pkgs = [] for comp in self._scan_data: m = comp.get("missing", "") @@ -245,10 +264,10 @@ def _parse_temps(self, raw: str) -> None: if len(parts) == 2 and parts[1].isdigit(): self._temps[parts[0]] = int(parts[1]) - def _make_warning_row(self, title: str, subtitle: str, btn_label: str, on_click) -> Adw.ActionRow: + def _make_warning_row(self, title: str, subtitle: str, btn_label: str, on_click, icon_name: str = "dialog-warning-symbolic", css_class: str = "warning-row") -> Adw.ActionRow: row = Adw.ActionRow(title=title, subtitle=subtitle) - row.add_css_class("warning-row") - icon = Gtk.Image.new_from_icon_name("dialog-warning-symbolic") + row.add_css_class(css_class) + icon = Gtk.Image.new_from_icon_name(icon_name) icon.set_valign(Gtk.Align.CENTER) row.add_prefix(icon) btn = Gtk.Button(label=btn_label) @@ -315,6 +334,31 @@ def _rebuild_ui(self) -> None: )) any_warning = True + if self._extra_missing: + extra_count = len(self._extra_missing.split()) + extra_names = ", ".join(self._extra_missing.split()[:5]) + if extra_count > 5: + extra_names += f" and {extra_count - 5} more" + group.add(self._make_warning_row( + "Optional extra drivers available", + f"Optional extra drivers not installed by default \u2014 {extra_names}", + "Install Extras", lambda _b: self._install_extra(), + icon_name="dialog-information-symbolic", css_class="info-row", + )) + any_warning = True + elif self._extra_installed: + extra_count = len(self._extra_installed.split()) + extra_names = ", ".join(self._extra_installed.split()[:5]) + if extra_count > 5: + extra_names += f" and {extra_count - 5} more" + group.add(self._make_warning_row( + "Optional extra drivers installed", + f"Optional extra drivers \u2014 {extra_names}", + "Uninstall Extras", lambda _b: self._uninstall_extra(), + icon_name="dialog-information-symbolic", css_class="info-row", + )) + any_warning = True + if any_warning: self._content_box.append(group) @@ -553,6 +597,11 @@ def _build_hw_row(self, group: Adw.PreferencesGroup, label: str | None, comp: di badge.add_css_class("error") badge.set_valign(Gtk.Align.CENTER) row.add_suffix(badge) + elif comp.get("type") == "NPU": + badge = Gtk.Label(label="Optional") + badge.add_css_class("dim-label") + badge.set_valign(Gtk.Align.CENTER) + row.add_suffix(badge) elif missing: badge = Gtk.Label(label=f"{len(missing.split())} missing") badge.add_css_class("error") @@ -937,9 +986,9 @@ def _show_module_info(self, anchor: Gtk.Widget, m: dict) -> None: info[k.lower()] = v self._module_desc_cache[m["name"]] = info lines = [] - lines.append(f"{m['name']}") + lines.append(f"{GLib.markup_escape_text(m['name'])}") if info.get("desc"): - lines.append(f"{info['desc']}") + lines.append(GLib.markup_escape_text(info["desc"])) fields = [ ("Author", "author"), ("License", "license"), ("Version", "version"), ("Depends", "depends"), ("Firmware", "firmware"), ("Built-in", "builtin"), @@ -948,7 +997,7 @@ def _show_module_info(self, anchor: Gtk.Widget, m: dict) -> None: for label, key in fields: val = info.get(key) if val: - lines.append(f"{label}: {val}") + lines.append(f"{GLib.markup_escape_text(label)}: {GLib.markup_escape_text(val)}") body = "\n".join(lines) pop = Gtk.Popover() pop.set_parent(anchor) @@ -1021,7 +1070,71 @@ def _install_missing(self) -> None: self._window.show_toast("No missing drivers to install") return self._run_terminal("retro driver install") - GLib.timeout_add(2000, self._delayed_refresh) + self._poll_main_install() + + def _install_extra(self) -> None: + if not self._extra_missing: + self._window.show_toast("No extra drivers to install") + return + self._run_terminal("retro driver install extra") + self._poll_extra_installed() + + def _uninstall_extra(self) -> None: + if not self._extra_installed: + self._window.show_toast("No extra drivers installed") + return + self._run_terminal("retro driver uninstall extra") + self._poll_extra_uninstalled() + + def _poll_extra_installed(self, timeout_s: int = 45) -> None: + """Refresh once the extra install finishes (extras no longer missing).""" + deadline = time.time() + timeout_s + + def check(): + if time.time() > deadline: + GLib.idle_add(self._delayed_refresh) + return + missing = _run(["--extra-list"]) + if not missing or missing == "NONE": + GLib.idle_add(self._delayed_refresh) + return + GLib.timeout_add(2000, check) + GLib.timeout_add(1500, check) + + def _poll_extra_uninstalled(self, timeout_s: int = 45) -> None: + """Refresh once the extra uninstall finishes (extras no longer installed).""" + deadline = time.time() + timeout_s + + def check(): + if time.time() > deadline: + GLib.idle_add(self._delayed_refresh) + return + installed = _run(["--extra-installed"]) + if not installed or installed == "NONE": + GLib.idle_add(self._delayed_refresh) + return + GLib.timeout_add(2000, check) + GLib.timeout_add(1500, check) + + def _poll_main_install(self, timeout_s: int = 120) -> None: + """Refresh once the main install finishes (no missing drivers remain).""" + deadline = time.time() + timeout_s + + def check(): + if time.time() > deadline: + GLib.idle_add(self._delayed_refresh) + return + scan = _run(["--scan"]) + still_missing = False + for line in scan.splitlines(): + if "|" in line and line.split("|", 6)[-1].strip(): + still_missing = True + break + if not still_missing: + GLib.idle_add(self._delayed_refresh) + return + GLib.timeout_add(3000, check) + GLib.timeout_add(1500, check) def _full_refresh(self) -> None: self._load_data() diff --git a/cmds/tools/settings/style.css b/cmds/tools/settings/style.css index bd9759ff..e12ad3a4 100644 --- a/cmds/tools/settings/style.css +++ b/cmds/tools/settings/style.css @@ -252,6 +252,16 @@ row:selected .nav-icon, color: @warning_color; } +/* Informational row (e.g. optional extra drivers) */ +.info-row { + background-color: alpha(@accent_bg_color, 0.10); + border-radius: 8px; + margin: 4px 0; +} +.info-row image { + color: @accent_color; +} + /* Unoptimized badge — wallpaper exceeds target resolution */ .badge.unoptimized-badge { color: @warning_color; diff --git a/cmds/tools/settings/window.py b/cmds/tools/settings/window.py index e8750b5d..ca65a2de 100644 --- a/cmds/tools/settings/window.py +++ b/cmds/tools/settings/window.py @@ -1612,6 +1612,9 @@ def emit_if[T: SectionPage]( lambda _p: bool(config.collect_bind_section(saved_sections)), lambda p: p.get_bind_lines(), ) + saved_gestures = config.collect_section(saved_sections, config.KEYWORD_GESTURE) + if saved_gestures: + sections.gestures = saved_gestures sections.monitors = emit_if( self._monitors_page, lambda _p: bool(config.collect_section(saved_sections, config.KEYWORD_MONITOR)), From c64e7b74b1dfb435ae2021ab0c6bc7f626d577df Mon Sep 17 00:00:00 2001 From: itsvlxd Date: Sun, 16 Aug 2026 20:25:09 +0300 Subject: [PATCH 09/11] chore(branch): change file mode to 755 --- cmds/system/branch.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 cmds/system/branch.sh diff --git a/cmds/system/branch.sh b/cmds/system/branch.sh old mode 100644 new mode 100755 From ed7ac8159aaee174edacec9f82cb34fe7475a152 Mon Sep 17 00:00:00 2001 From: itsvlxd Date: Sun, 16 Aug 2026 20:25:31 +0300 Subject: [PATCH 10/11] feat(retro): add default hl gesture --- modules/retro/files/settings.lua | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modules/retro/files/settings.lua b/modules/retro/files/settings.lua index a8c57faa..f926c222 100644 --- a/modules/retro/files/settings.lua +++ b/modules/retro/files/settings.lua @@ -3,6 +3,12 @@ -- Settings hl.config({}) +hl.gesture({ + fingers = 3, + direction = "horizontal", + action = "workspace", +}) + -- Window rules hl.window_rule({ name = "zen-opacity", From 4d3dc9558626686deb234dd705f979a0a95ba81a Mon Sep 17 00:00:00 2001 From: itsvlxd Date: Sun, 16 Aug 2026 20:25:41 +0300 Subject: [PATCH 11/11] feat(tests): add driver mapping test --- tests/driver_mapping_test.sh | 76 ++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100755 tests/driver_mapping_test.sh diff --git a/tests/driver_mapping_test.sh b/tests/driver_mapping_test.sh new file mode 100755 index 00000000..c448098c --- /dev/null +++ b/tests/driver_mapping_test.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# Description: Verify driver_core.sh device-id -> package mappings + +if [[ -z $RETRO_DIR ]]; then + export RETRO_DIR="$(dirname "$(readlink -f "$0")")" +else + export RETRO_DIR="$RETRO_DIR" +fi + +FAILED=0 + +check_pkg() { + local desc="$1" + local expected="$2" + local actual="$3" + if [[ "$actual" == "$expected" ]]; then + echo "PASS: $desc" + else + echo "FAIL: $desc -> expected '$expected', got '$actual'" + FAILED=1 + fi +} + +# Realtek WiFi hex device-ids (as extracted by detect_network from lspci) +check_pkg "RTL8852BE (b852)" "8852be-dkms-git linux-firmware" \ + "$(source "$RETRO_DIR/scripts/driver_core.sh"; get_network_packages wifi realtek b852)" +check_pkg "RTL8852CE (c852)" "8852be-dkms-git linux-firmware" \ + "$(source "$RETRO_DIR/scripts/driver_core.sh"; get_network_packages wifi realtek c852)" +check_pkg "RTL8821CE (c821)" "rtl8821ce-dkms-git linux-firmware" \ + "$(source "$RETRO_DIR/scripts/driver_core.sh"; get_network_packages wifi realtek c821)" +check_pkg "RTL8822CE (c822)" "rtl88x2ce-dkms-git linux-firmware" \ + "$(source "$RETRO_DIR/scripts/driver_core.sh"; get_network_packages wifi realtek c822)" +check_pkg "RTL8812AU (8812)" "rtl8812au-openhd-dkms-git linux-firmware" \ + "$(source "$RETRO_DIR/scripts/driver_core.sh"; get_network_packages wifi realtek 8812)" + +# Ethernet +check_pkg "RTL8125 (8125)" "r8125-dkms linux-firmware" \ + "$(source "$RETRO_DIR/scripts/driver_core.sh"; get_network_packages ethernet realtek 8125)" +check_pkg "RTL8168 (8168)" "r8168-dkms linux-firmware" \ + "$(source "$RETRO_DIR/scripts/driver_core.sh"; get_network_packages ethernet realtek 8168)" + +# Vendor firmware +check_pkg "Qualcomm WiFi firmware" "linux-firmware linux-firmware-qcom" \ + "$(source "$RETRO_DIR/scripts/driver_core.sh"; get_network_packages wifi qualcomm 1234)" +check_pkg "MediaTek WiFi firmware" "linux-firmware linux-firmware-mediatek" \ + "$(source "$RETRO_DIR/scripts/driver_core.sh"; get_network_packages wifi mediatek 1234)" + +# Driver candidates +check_pkg "candidate RTL8125" "r8125-dkms|r8168-dkms" \ + "$(source "$RETRO_DIR/scripts/driver_core.sh"; get_network_driver_candidates ethernet realtek 8125)" +check_pkg "candidate RTL8852BE" "8852be-dkms-git" \ + "$(source "$RETRO_DIR/scripts/driver_core.sh"; get_network_driver_candidates wifi realtek b852)" + +# AI extras +check_pkg "nvidia AI extras" "cuda cudnn nvidia-container-toolkit" \ + "$(source "$RETRO_DIR/scripts/driver_core.sh"; get_gpu_ai_packages nvidia)" +check_pkg "amd AI extras" "rocm-hip-sdk" \ + "$(source "$RETRO_DIR/scripts/driver_core.sh"; get_gpu_ai_packages amd)" +check_pkg "intel AI extras" "intel-compute-runtime level-zero-loader" \ + "$(source "$RETRO_DIR/scripts/driver_core.sh"; get_gpu_ai_packages intel)" + +# Extra-installed list never errors and returns either NONE or package names +extra_installed=$(bash "$RETRO_DIR/scripts/driver_core.sh" --extra-installed 2>/dev/null) +if [[ $extra_installed == "NONE" || -n $extra_installed ]]; then + echo "PASS: extra-installed returns a valid value" +else + echo "FAIL: extra-installed returned empty" + FAILED=1 +fi + +if [[ $FAILED -eq 0 ]]; then + echo "PASS: All driver mappings correct" + exit 0 +else + exit 1 +fi