From f0cec3ff84c2526ef80de709dca42ad33bdb2f90 Mon Sep 17 00:00:00 2001 From: retrozinndev Date: Wed, 12 Mar 2025 14:11:42 -0300 Subject: [PATCH] :zap: perf(ags/runner): better code for runner, show results from multiple plugins, use interface to make plugins and more perfomance implementations --- ags/app.ts | 14 +++ ags/scripts/runner/apps.ts | 24 ++-- ags/scripts/runner/shell.ts | 20 ++-- ags/scripts/runner/websearch.ts | 66 +++++----- ags/widget/Notification.ts | 206 +++++++++++++++++--------------- ags/window/ControlCenter.ts | 4 +- ags/window/Runner.ts | 130 ++++++++++---------- 7 files changed, 244 insertions(+), 220 deletions(-) diff --git a/ags/app.ts b/ags/app.ts index 5e863fc..7c13c19 100644 --- a/ags/app.ts +++ b/ags/app.ts @@ -9,8 +9,19 @@ import { Time, timeout } from "astal/time"; import { OSD, OSDModes, setOSDMode } from "./window/OSD"; import { ControlCenter } from "./window/ControlCenter"; +import { Runner } from "./window/Runner"; +import { PluginApps } from "./scripts/runner/apps"; +import { PluginShell } from "./scripts/runner/shell"; +import { PluginWebSearch } from "./scripts/runner/websearch"; + let osdTimer: (Time|undefined); +const runnerPlugins: Array = [ + new PluginApps(), + new PluginShell(), + new PluginWebSearch() +]; + App.start({ instanceName: "astal", requestHandler(request: string, response: (result: any) => void) { @@ -26,6 +37,9 @@ App.start({ Wireplumber.getDefault().getDefaultSink().connect("notify::volume", () => !Windows.isVisible(ControlCenter) && triggerOSD(OSDModes.SINK)); + + console.log(`[LOG] Adding runner plugins`); + runnerPlugins.map(plugin => Runner.addPlugin(plugin)); } }); diff --git a/ags/scripts/runner/apps.ts b/ags/scripts/runner/apps.ts index 52554a5..ac403e1 100644 --- a/ags/scripts/runner/apps.ts +++ b/ags/scripts/runner/apps.ts @@ -2,14 +2,20 @@ import AstalHyprland from "gi://AstalHyprland"; import { getAstalApps } from "../apps"; import { ResultWidget, ResultWidgetProps } from "../../widget/runner/ResultWidget"; import AstalApps from "gi://AstalApps"; +import { Runner } from "../../window/Runner"; -export function handleApplications(search: string): (Array|null) { - return getAstalApps().fuzzy_query(search).map((app: AstalApps.Application) => - new ResultWidget({ - title: app.get_name(), - description: app.get_description(), - icon: app.iconName, - onClick: () => AstalHyprland.get_default().dispatch("exec", app.get_executable()) - } as ResultWidgetProps) - ) || null; +export class PluginApps implements Runner.Plugin { + // Do not provide prefix, so it's always ran. + public readonly name = "Apps"; + + public handle(text: string) { + return getAstalApps().fuzzy_query(text).map((app: AstalApps.Application) => + new ResultWidget({ + title: app.get_name(), + description: app.get_description(), + icon: app.iconName, + onClick: () => AstalHyprland.get_default().dispatch("exec", app.get_executable()) + } as ResultWidgetProps) + ) || null; + } } diff --git a/ags/scripts/runner/shell.ts b/ags/scripts/runner/shell.ts index 81ec982..8ddb70c 100644 --- a/ags/scripts/runner/shell.ts +++ b/ags/scripts/runner/shell.ts @@ -1,14 +1,18 @@ import { ResultWidget, ResultWidgetProps } from "../../widget/runner/ResultWidget"; import AstalHyprland from "gi://AstalHyprland"; import { GLib } from "astal"; +import { Runner } from "../../window/Runner"; -export function handleShell(command: string): ResultWidget { - const userShell = GLib.getenv("SHELL") || "/usr/bin/env bash"; +export class PluginShell implements Runner.Plugin { + public readonly prefix = '!'; + #shell = GLib.getenv("SHELL") || "/usr/bin/env sh"; - return new ResultWidget({ - onClick: () => AstalHyprland.get_default().dispatch("exec", `${userShell} -c "${command}"`), - title: `Run: \`${command}\``, - description: userShell, - icon: "utilities-terminal-symbolic" - } as ResultWidgetProps); + public handle(command: string): ResultWidget { + return new ResultWidget({ + onClick: () => AstalHyprland.get_default().dispatch("exec", `${this.#shell} -c "${command}"`), + title: `Run: \`${command}\``, + description: this.#shell, + icon: "utilities-terminal-symbolic" + } as ResultWidgetProps) + } } diff --git a/ags/scripts/runner/websearch.ts b/ags/scripts/runner/websearch.ts index b2c887a..2283405 100644 --- a/ags/scripts/runner/websearch.ts +++ b/ags/scripts/runner/websearch.ts @@ -1,42 +1,40 @@ import AstalHyprland from "gi://AstalHyprland"; import { ResultWidget, ResultWidgetProps } from "../../widget/runner/ResultWidget"; +import { Runner } from "../../window/Runner"; -export enum SearchEngine { - GOOGLE, - DUCKDUCKGO, - YAHOO -} +const searchEngines = { + duckduckgo: "https://duckduckgo.com/?q=", + google: "https://google.com/search?q=", + yahoo: "https://search.yahoo.com/search?p=" +}; -export const SearchEngineMap: Map = new Map([ - [ SearchEngine.DUCKDUCKGO, "https://duckduckgo.com/?q=" ], - [ SearchEngine.GOOGLE, "https://google.com/search?q=" ], - [ SearchEngine.YAHOO, "https://search.yahoo.com/search?p=" ] -]); +let engine: string = searchEngines.google; -let searchEngine: SearchEngine = SearchEngine.GOOGLE; - -export function handleWebSearch(search: string): ResultWidget { - - let engineString: string; - - switch(searchEngine as SearchEngine) { - case SearchEngine.GOOGLE: - engineString = "Google"; - case SearchEngine.YAHOO: - engineString = "Yahoo"; - case SearchEngine.DUCKDUCKGO: - engineString = "DuckDuckGo"; - default: engineString = "Web"; +export class PluginWebSearch implements Runner.Plugin { + #engineString: string; + public readonly prefix = '?'; + public readonly name = "Web Search"; + constructor() { + switch(engine) { + case searchEngines.google: + this.#engineString = "Google"; + case searchEngines.yahoo: + this.#engineString = "Yahoo"; + case searchEngines.duckduckgo: + this.#engineString = "DuckDuckGo"; + default: this.#engineString = "Web"; + } + } + public handle(search: string): ResultWidget { + return new ResultWidget({ + icon: "system-search-symbolic", + title: search || "", + description: `Search with ${this.#engineString}`, + onClick: () => AstalHyprland.get_default().dispatch( + "exec", + `xdg-open \"${engine + search}\"` + ) + } as ResultWidgetProps); } - - return new ResultWidget({ - icon: "system-search-symbolic", - title: search || "", - description: `Search with ${engineString}`, - onClick: () => AstalHyprland.get_default().dispatch( - "exec", - `xdg-open "${SearchEngineMap.get(searchEngine)! + search.replaceAll(" ", "%20")}"` - ) - } as ResultWidgetProps); } diff --git a/ags/widget/Notification.ts b/ags/widget/Notification.ts index 80d6e18..5fc39d5 100644 --- a/ags/widget/Notification.ts +++ b/ags/widget/Notification.ts @@ -21,103 +21,111 @@ export function NotificationWidget(notification: AstalNotifd.Notification|number notification : AstalNotifd.get_default().get_notification(notification); - return new Widget.Box({ - className: `notification ${getUrgencyString(notification)}`, - homogeneous: false, - expand: false, - orientation: Gtk.Orientation.VERTICAL, - children: [ - new Widget.Box({ - className: "top", - orientation: Gtk.Orientation.HORIZONTAL, - hexpand: true, - vexpand: false, - children: [ - new Widget.Icon({ - className: "icon app-icon", - icon: Astal.Icon.lookup_icon(notification.appIcon) ? - notification.appIcon - : (Astal.Icon.lookup_icon(notification.appName.toLowerCase()) ? - notification.appName.toLowerCase() - : "image-missing" - ), - setup: (_) => _.get_icon() === "image-missing" && - _.set_visible(false), - halign: Gtk.Align.START, - css: "font-size: 16px;" - }), - new Widget.Label({ - className: "app-name", - halign: Gtk.Align.START, - hexpand: true, - label: notification.appName || "Unknown Application" - } as Widget.LabelProps), - new Widget.Button({ - className: "close nf", - halign: Gtk.Align.END, - onClick: () => onClose && onClose(notification), - image: new Widget.Icon({ - className: "close icon", - icon: "window-close-symbolic" - } as Widget.IconProps) - } as Widget.ButtonProps) - ] - } as Widget.BoxProps), - Separator({ - orientation: Gtk.Orientation.VERTICAL, - alpha: 10 - }), - new Widget.Box({ - className: "content", - orientation: Gtk.Orientation.HORIZONTAL, - children: [ - new Widget.Box({ - className: "image", - visible: Boolean(notification.image), - css: `box.image { background-image: url('${notification.image}'); }` - } as Widget.BoxProps), - new Widget.Box({ - className: "text", - orientation: Gtk.Orientation.VERTICAL, - expand: true, - children: [ - new Widget.Label({ - className: "summary", - useMarkup: true, - xalign: 0, - truncate: true, - label: notification.summary - }), - new Widget.Label({ - className: "body", - useMarkup: true, - halign: Gtk.Align.START, - xalign: 0, - truncate: false, - wrap: true, - wrapMode: Pango.WrapMode.WORD, - label: notification.body - } as Widget.LabelProps) - ] - } as Widget.BoxProps) - ] - } as Widget.BoxProps), - new Widget.Box({ - className: "actions button-row", - hexpand: true, - visible: notification.actions.length > 0, - children: notification.actions.map((action: AstalNotifd.Action) => - new Widget.Button({ - className: "action", - label: action.label, - hexpand: true, - onClicked: () => { - notification.invoke(action.id); - onClose && onClose(notification); - } - } as Widget.ButtonProps) - ) - } as Widget.BoxProps) - ] - } as Widget.BoxProps) + return new Widget.EventBox({ + onClick: () => { + if(notification.actions.length >= 1) { + notification.invoke(notification.actions[0]!.id); + onClose && onClose(notification); + } + }, + child: new Widget.Box({ + className: `notification ${getUrgencyString(notification)}`, + homogeneous: false, + expand: false, + orientation: Gtk.Orientation.VERTICAL, + children: [ + new Widget.Box({ + className: "top", + orientation: Gtk.Orientation.HORIZONTAL, + hexpand: true, + vexpand: false, + children: [ + new Widget.Icon({ + className: "icon app-icon", + icon: Astal.Icon.lookup_icon(notification.appIcon) ? + notification.appIcon + : (Astal.Icon.lookup_icon(notification.appName.toLowerCase()) ? + notification.appName.toLowerCase() + : "image-missing" + ), + setup: (_) => _.get_icon() === "image-missing" && + _.set_visible(false), + halign: Gtk.Align.START, + css: "font-size: 16px;" + }), + new Widget.Label({ + className: "app-name", + halign: Gtk.Align.START, + hexpand: true, + label: notification.appName || "Unknown Application" + } as Widget.LabelProps), + new Widget.Button({ + className: "close nf", + halign: Gtk.Align.END, + onClick: () => onClose && onClose(notification), + image: new Widget.Icon({ + className: "close icon", + icon: "window-close-symbolic" + } as Widget.IconProps) + } as Widget.ButtonProps) + ] + } as Widget.BoxProps), + Separator({ + orientation: Gtk.Orientation.VERTICAL, + alpha: 10 + }), + new Widget.Box({ + className: "content", + orientation: Gtk.Orientation.HORIZONTAL, + children: [ + new Widget.Box({ + className: "image", + visible: Boolean(notification.image), + css: `box.image { background-image: url('${notification.image}'); }` + } as Widget.BoxProps), + new Widget.Box({ + className: "text", + orientation: Gtk.Orientation.VERTICAL, + expand: true, + children: [ + new Widget.Label({ + className: "summary", + useMarkup: true, + xalign: 0, + truncate: true, + label: notification.summary + }), + new Widget.Label({ + className: "body", + useMarkup: true, + halign: Gtk.Align.START, + xalign: 0, + truncate: false, + wrap: true, + wrapMode: Pango.WrapMode.WORD, + label: notification.body + } as Widget.LabelProps) + ] + } as Widget.BoxProps) + ] + } as Widget.BoxProps), + new Widget.Box({ + className: "actions button-row", + hexpand: true, + visible: notification.actions.length > 1, + children: notification.actions.map((action: AstalNotifd.Action) => + new Widget.Button({ + className: "action", + label: action.label, + hexpand: true, + onClicked: () => { + notification.invoke(action.id); + onClose && onClose(notification); + } + } as Widget.ButtonProps) + ) + } as Widget.BoxProps) + ] + } as Widget.BoxProps), + } as Widget.EventBoxProps); } diff --git a/ags/window/ControlCenter.ts b/ags/window/ControlCenter.ts index 8ac873d..2e8e87e 100644 --- a/ags/window/ControlCenter.ts +++ b/ags/window/ControlCenter.ts @@ -18,10 +18,10 @@ export const ControlCenter: Widget.Window = PopupWindow({ halign: Gtk.Align.END, valign: Gtk.Align.START, visible: false, - vexpand: true, + expand: true, child: new Widget.Box({ orientation: Gtk.Orientation.VERTICAL, - vexpand: true, + expand: true, children: [ new Widget.Box({ className: "control-center-container", diff --git a/ags/window/Runner.ts b/ags/window/Runner.ts index 37a6e43..8ace524 100644 --- a/ags/window/Runner.ts +++ b/ags/window/Runner.ts @@ -2,9 +2,6 @@ import { AstalIO, timeout, Variable } from "astal"; import { Gdk, Gtk, Widget } from "astal/gtk3"; import { PopupWindow, PopupWindowProps } from "../widget/PopupWindow"; import { updateApps } from "../scripts/apps"; -import { handleShell } from "../scripts/runner/shell"; -import { handleWebSearch } from "../scripts/runner/websearch"; -import { handleApplications } from "../scripts/runner/apps"; import { ResultWidget, ResultWidgetProps } from "../widget/runner/ResultWidget"; export let runnerInstance: (Widget.Window|null) = null; @@ -50,26 +47,48 @@ export namespace Runner { resultsPlaceholder?: () => Array; }; - export const prefixes = new Map (ResultWidget|Array|null)>([ - [ "!", handleShell ], - [ "?", handleWebSearch ], - ]); + const plugins = new Set([]); + + export interface Plugin { + /** prefix to call the plugin. if undefined, will be triggered like applications plugin */ + readonly prefix?: string; + /** name of the plugin. e.g.: websearch, shell */ + readonly name?: string; + /** handle the user input to return results (does not contain prefix) */ + readonly handle: (inputText: string) => (ResultWidget|Array|null|undefined); + } + + export function addPlugin(plugin: Runner.Plugin, force?: boolean) { + if(!force && plugin.prefix && plugins.has(plugin)) + throw new Error(`Runner plugin with prefix ${plugin.prefix} already exists`); + + plugins.add(plugin); + } + + export function getPlugins(): Array { + return [...plugins.values()]; + } + + /** Removes a plugin from the runner plugin list + * @returns true if plugin was removed or false if plugin wasn't found + */ + export function removePlugin(plugin: Plugin): boolean { + return plugins.delete(plugin); + } export function RunnerWindow(props?: RunnerProps): (Widget.Window|null) { let subs: Array<() => void> = []; const entryText: Variable = new Variable(""); - let results: (Array|null) = props?.resultsPlaceholder ? props.resultsPlaceholder() : null; - let selectedResultIndex = 0; const searchEntry = new Widget.Entry({ className: "search", onChanged: (entry) => entryText.set(entry.text), placeholderText: props?.entryPlaceHolder || "", - onActivate: (_) => { + onActivate: (entry) => { const resultWidget = resultsList.get_selected_row()?.get_child(); if(resultWidget instanceof ResultWidget) { resultWidget.onClick(); - _.isFocus = false; + entry.isFocus = false; } }, primary_icon_name: "system-search" @@ -80,54 +99,44 @@ export namespace Runner { expand: true } as Gtk.ListBox.ConstructorProps); - subs.push(entryText().subscribe((text: string) => { - const trimmedText = text.trim(); - const pluginResult: (ResultWidget|Array|null|undefined) = handlePrefix( - trimmedText)?.(trimmedText.replace(trimmedText.charAt(0), "")); - results = Boolean(pluginResult) ? - (!Array.isArray(pluginResult) ? - [ pluginResult! ] - : pluginResult) - : null; + function updateResultsList(entryText: string) { + const calledPlugins: Array = getPlugins().filter((plugin) => plugin.prefix && entryText.startsWith(plugin.prefix) ? + plugin : null).concat(getPlugins().filter(plugin => plugin.prefix === undefined)); - if(resultsList.get_children().length > 0) { - resultsList.get_children().map((listItem: Gtk.Widget) => { - resultsList.remove(listItem); - listItem.destroy(); - }); - } + const widgets: Array = calledPlugins.map(plugin => plugin.handle( + plugin.prefix ? entryText.replace(plugin.prefix, "") : entryText + )).filter(value => value !== undefined && value !== null).flat(1); + + // Remove all previous results + resultsList.get_children().map((listItem: Gtk.Widget) => { + resultsList.remove(listItem); + listItem.destroy(); + }); - if(results && results.length > 0 && searchEntry.text.trim().length > 0) { - results.map((resultWidget: ResultWidget) => { - resultsList.insert(resultWidget, -1); + // Insert placeholder if somehow no results are found + if((!entryText || !widgets || widgets.length === 0) && props?.resultsPlaceholder) + widgets.push(...props.resultsPlaceholder()); - const listBoxChild = resultsList.get_row_at_index(resultsList.get_children().length - 1)!; - const resWidget = listBoxChild.get_child(); - if(listBoxChild && resWidget instanceof ResultWidget) { - resultsList.connect("row-activated", (_, row: Gtk.ListBoxRow) => { - const rWidget = row.get_child()!; - if(rWidget instanceof ResultWidget) { - if(!onClickTimeout) { - rWidget.onClick(); - // Timeout, so it doesn't fire the executable a hundred times :skull: - onClickTimeout = timeout(500, () => onClickTimeout = undefined); - } - } - }); + // Insert results inside GtkListBox + widgets.map((resultWidget: ResultWidget) => { + resultsList.insert(resultWidget, -1); + + resultsList.connect("row-activated", (_, row: Gtk.ListBoxRow) => { + const rWidget = row.get_child()!; + if(rWidget instanceof ResultWidget) { + if(!onClickTimeout) { + rWidget.onClick(); + // Timeout, so it doesn't fire the executable a hundred times :skull: + onClickTimeout = timeout(500, () => onClickTimeout = undefined); + } } }); - } else { - if(props?.resultsPlaceholder) { - const widgets = props.resultsPlaceholder(); - resultsList.get_children().map((res) => - resultsList.remove(res)); + }); + } - widgets.map((widget) => resultsList.insert(widget, -1)); - } - } - - selectedResultIndex = 0; - resultsList.select_row(resultsList.get_row_at_index(selectedResultIndex)); + subs.push(entryText().subscribe((text: string) => { + updateResultsList(text.trim()); + resultsList.select_row(resultsList.get_row_at_index(0)); })); if(!runnerInstance) @@ -170,19 +179,4 @@ export namespace Runner { return runnerInstance; } - - export function handlePrefix(text: string): (((a: string) => (Array|ResultWidget|null)) | null) { - const prefix = text.charAt(0); - let result: (((a: string) => ResultWidget|Array|null)|null) = null; - - if(/([a-z]|[A-Z]|[0-9])/.test(prefix)) - result = handleApplications; - - [...prefixes.keys()].map((curPrefix: string) => { - if(curPrefix === prefix) - result = prefixes.get(curPrefix)!; - }); - - return result; - } }