perf(ags/runner): better code for runner, show results from multiple plugins, use interface to make plugins and more perfomance implementations

This commit is contained in:
retrozinndev
2025-03-12 14:11:42 -03:00
parent 0ce13b47be
commit f0cec3ff84
7 changed files with 244 additions and 220 deletions
+14
View File
@@ -9,8 +9,19 @@ import { Time, timeout } from "astal/time";
import { OSD, OSDModes, setOSDMode } from "./window/OSD"; import { OSD, OSDModes, setOSDMode } from "./window/OSD";
import { ControlCenter } from "./window/ControlCenter"; 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); let osdTimer: (Time|undefined);
const runnerPlugins: Array<Runner.Plugin> = [
new PluginApps(),
new PluginShell(),
new PluginWebSearch()
];
App.start({ App.start({
instanceName: "astal", instanceName: "astal",
requestHandler(request: string, response: (result: any) => void) { requestHandler(request: string, response: (result: any) => void) {
@@ -26,6 +37,9 @@ App.start({
Wireplumber.getDefault().getDefaultSink().connect("notify::volume", () => Wireplumber.getDefault().getDefaultSink().connect("notify::volume", () =>
!Windows.isVisible(ControlCenter) && triggerOSD(OSDModes.SINK)); !Windows.isVisible(ControlCenter) && triggerOSD(OSDModes.SINK));
console.log(`[LOG] Adding runner plugins`);
runnerPlugins.map(plugin => Runner.addPlugin(plugin));
} }
}); });
+15 -9
View File
@@ -2,14 +2,20 @@ import AstalHyprland from "gi://AstalHyprland";
import { getAstalApps } from "../apps"; import { getAstalApps } from "../apps";
import { ResultWidget, ResultWidgetProps } from "../../widget/runner/ResultWidget"; import { ResultWidget, ResultWidgetProps } from "../../widget/runner/ResultWidget";
import AstalApps from "gi://AstalApps"; import AstalApps from "gi://AstalApps";
import { Runner } from "../../window/Runner";
export function handleApplications(search: string): (Array<ResultWidget>|null) { export class PluginApps implements Runner.Plugin {
return getAstalApps().fuzzy_query(search).map((app: AstalApps.Application) => // Do not provide prefix, so it's always ran.
new ResultWidget({ public readonly name = "Apps";
title: app.get_name(),
description: app.get_description(), public handle(text: string) {
icon: app.iconName, return getAstalApps().fuzzy_query(text).map((app: AstalApps.Application) =>
onClick: () => AstalHyprland.get_default().dispatch("exec", app.get_executable()) new ResultWidget({
} as ResultWidgetProps) title: app.get_name(),
) || null; description: app.get_description(),
icon: app.iconName,
onClick: () => AstalHyprland.get_default().dispatch("exec", app.get_executable())
} as ResultWidgetProps)
) || null;
}
} }
+12 -8
View File
@@ -1,14 +1,18 @@
import { ResultWidget, ResultWidgetProps } from "../../widget/runner/ResultWidget"; import { ResultWidget, ResultWidgetProps } from "../../widget/runner/ResultWidget";
import AstalHyprland from "gi://AstalHyprland"; import AstalHyprland from "gi://AstalHyprland";
import { GLib } from "astal"; import { GLib } from "astal";
import { Runner } from "../../window/Runner";
export function handleShell(command: string): ResultWidget { export class PluginShell implements Runner.Plugin {
const userShell = GLib.getenv("SHELL") || "/usr/bin/env bash"; public readonly prefix = '!';
#shell = GLib.getenv("SHELL") || "/usr/bin/env sh";
return new ResultWidget({ public handle(command: string): ResultWidget {
onClick: () => AstalHyprland.get_default().dispatch("exec", `${userShell} -c "${command}"`), return new ResultWidget({
title: `Run: \`${command}\``, onClick: () => AstalHyprland.get_default().dispatch("exec", `${this.#shell} -c "${command}"`),
description: userShell, title: `Run: \`${command}\``,
icon: "utilities-terminal-symbolic" description: this.#shell,
} as ResultWidgetProps); icon: "utilities-terminal-symbolic"
} as ResultWidgetProps)
}
} }
+32 -34
View File
@@ -1,42 +1,40 @@
import AstalHyprland from "gi://AstalHyprland"; import AstalHyprland from "gi://AstalHyprland";
import { ResultWidget, ResultWidgetProps } from "../../widget/runner/ResultWidget"; import { ResultWidget, ResultWidgetProps } from "../../widget/runner/ResultWidget";
import { Runner } from "../../window/Runner";
export enum SearchEngine { const searchEngines = {
GOOGLE, duckduckgo: "https://duckduckgo.com/?q=",
DUCKDUCKGO, google: "https://google.com/search?q=",
YAHOO yahoo: "https://search.yahoo.com/search?p="
} };
export const SearchEngineMap: Map<SearchEngine, string> = new Map([ let engine: string = searchEngines.google;
[ SearchEngine.DUCKDUCKGO, "https://duckduckgo.com/?q=" ],
[ SearchEngine.GOOGLE, "https://google.com/search?q=" ],
[ SearchEngine.YAHOO, "https://search.yahoo.com/search?p=" ]
]);
let searchEngine: SearchEngine = SearchEngine.GOOGLE; export class PluginWebSearch implements Runner.Plugin {
#engineString: string;
export function handleWebSearch(search: string): ResultWidget { public readonly prefix = '?';
public readonly name = "Web Search";
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";
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);
} }
+107 -99
View File
@@ -21,103 +21,111 @@ export function NotificationWidget(notification: AstalNotifd.Notification|number
notification notification
: AstalNotifd.get_default().get_notification(notification); : AstalNotifd.get_default().get_notification(notification);
return new Widget.Box({ return new Widget.EventBox({
className: `notification ${getUrgencyString(notification)}`, onClick: () => {
homogeneous: false, if(notification.actions.length >= 1) {
expand: false, notification.invoke(notification.actions[0]!.id);
orientation: Gtk.Orientation.VERTICAL, onClose && onClose(notification);
children: [ }
new Widget.Box({ },
className: "top", child: new Widget.Box({
orientation: Gtk.Orientation.HORIZONTAL, className: `notification ${getUrgencyString(notification)}`,
hexpand: true, homogeneous: false,
vexpand: false, expand: false,
children: [ orientation: Gtk.Orientation.VERTICAL,
new Widget.Icon({ children: [
className: "icon app-icon", new Widget.Box({
icon: Astal.Icon.lookup_icon(notification.appIcon) ? className: "top",
notification.appIcon orientation: Gtk.Orientation.HORIZONTAL,
: (Astal.Icon.lookup_icon(notification.appName.toLowerCase()) ? hexpand: true,
notification.appName.toLowerCase() vexpand: false,
: "image-missing" children: [
), new Widget.Icon({
setup: (_) => _.get_icon() === "image-missing" && className: "icon app-icon",
_.set_visible(false), icon: Astal.Icon.lookup_icon(notification.appIcon) ?
halign: Gtk.Align.START, notification.appIcon
css: "font-size: 16px;" : (Astal.Icon.lookup_icon(notification.appName.toLowerCase()) ?
}), notification.appName.toLowerCase()
new Widget.Label({ : "image-missing"
className: "app-name", ),
halign: Gtk.Align.START, setup: (_) => _.get_icon() === "image-missing" &&
hexpand: true, _.set_visible(false),
label: notification.appName || "Unknown Application" halign: Gtk.Align.START,
} as Widget.LabelProps), css: "font-size: 16px;"
new Widget.Button({ }),
className: "close nf", new Widget.Label({
halign: Gtk.Align.END, className: "app-name",
onClick: () => onClose && onClose(notification), halign: Gtk.Align.START,
image: new Widget.Icon({ hexpand: true,
className: "close icon", label: notification.appName || "Unknown Application"
icon: "window-close-symbolic" } as Widget.LabelProps),
} as Widget.IconProps) new Widget.Button({
} as Widget.ButtonProps) className: "close nf",
] halign: Gtk.Align.END,
} as Widget.BoxProps), onClick: () => onClose && onClose(notification),
Separator({ image: new Widget.Icon({
orientation: Gtk.Orientation.VERTICAL, className: "close icon",
alpha: 10 icon: "window-close-symbolic"
}), } as Widget.IconProps)
new Widget.Box({ } as Widget.ButtonProps)
className: "content", ]
orientation: Gtk.Orientation.HORIZONTAL, } as Widget.BoxProps),
children: [ Separator({
new Widget.Box({ orientation: Gtk.Orientation.VERTICAL,
className: "image", alpha: 10
visible: Boolean(notification.image), }),
css: `box.image { background-image: url('${notification.image}'); }` new Widget.Box({
} as Widget.BoxProps), className: "content",
new Widget.Box({ orientation: Gtk.Orientation.HORIZONTAL,
className: "text", children: [
orientation: Gtk.Orientation.VERTICAL, new Widget.Box({
expand: true, className: "image",
children: [ visible: Boolean(notification.image),
new Widget.Label({ css: `box.image { background-image: url('${notification.image}'); }`
className: "summary", } as Widget.BoxProps),
useMarkup: true, new Widget.Box({
xalign: 0, className: "text",
truncate: true, orientation: Gtk.Orientation.VERTICAL,
label: notification.summary expand: true,
}), children: [
new Widget.Label({ new Widget.Label({
className: "body", className: "summary",
useMarkup: true, useMarkup: true,
halign: Gtk.Align.START, xalign: 0,
xalign: 0, truncate: true,
truncate: false, label: notification.summary
wrap: true, }),
wrapMode: Pango.WrapMode.WORD, new Widget.Label({
label: notification.body className: "body",
} as Widget.LabelProps) useMarkup: true,
] halign: Gtk.Align.START,
} as Widget.BoxProps) xalign: 0,
] truncate: false,
} as Widget.BoxProps), wrap: true,
new Widget.Box({ wrapMode: Pango.WrapMode.WORD,
className: "actions button-row", label: notification.body
hexpand: true, } as Widget.LabelProps)
visible: notification.actions.length > 0, ]
children: notification.actions.map((action: AstalNotifd.Action) => } as Widget.BoxProps)
new Widget.Button({ ]
className: "action", } as Widget.BoxProps),
label: action.label, new Widget.Box({
hexpand: true, className: "actions button-row",
onClicked: () => { hexpand: true,
notification.invoke(action.id); visible: notification.actions.length > 1,
onClose && onClose(notification); children: notification.actions.map((action: AstalNotifd.Action) =>
} new Widget.Button({
} as Widget.ButtonProps) className: "action",
) label: action.label,
} as Widget.BoxProps) hexpand: true,
] onClicked: () => {
} as Widget.BoxProps) notification.invoke(action.id);
onClose && onClose(notification);
}
} as Widget.ButtonProps)
)
} as Widget.BoxProps)
]
} as Widget.BoxProps),
} as Widget.EventBoxProps);
} }
+2 -2
View File
@@ -18,10 +18,10 @@ export const ControlCenter: Widget.Window = PopupWindow({
halign: Gtk.Align.END, halign: Gtk.Align.END,
valign: Gtk.Align.START, valign: Gtk.Align.START,
visible: false, visible: false,
vexpand: true, expand: true,
child: new Widget.Box({ child: new Widget.Box({
orientation: Gtk.Orientation.VERTICAL, orientation: Gtk.Orientation.VERTICAL,
vexpand: true, expand: true,
children: [ children: [
new Widget.Box({ new Widget.Box({
className: "control-center-container", className: "control-center-container",
+62 -68
View File
@@ -2,9 +2,6 @@ import { AstalIO, timeout, Variable } from "astal";
import { Gdk, Gtk, Widget } from "astal/gtk3"; import { Gdk, Gtk, Widget } from "astal/gtk3";
import { PopupWindow, PopupWindowProps } from "../widget/PopupWindow"; import { PopupWindow, PopupWindowProps } from "../widget/PopupWindow";
import { updateApps } from "../scripts/apps"; 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"; import { ResultWidget, ResultWidgetProps } from "../widget/runner/ResultWidget";
export let runnerInstance: (Widget.Window|null) = null; export let runnerInstance: (Widget.Window|null) = null;
@@ -50,26 +47,48 @@ export namespace Runner {
resultsPlaceholder?: () => Array<ResultWidget>; resultsPlaceholder?: () => Array<ResultWidget>;
}; };
export const prefixes = new Map<string, (entry: string) => (ResultWidget|Array<ResultWidget>|null)>([ const plugins = new Set<Runner.Plugin>([]);
[ "!", handleShell ],
[ "?", handleWebSearch ], 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<ResultWidget>|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<Runner.Plugin> {
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) { export function RunnerWindow(props?: RunnerProps): (Widget.Window|null) {
let subs: Array<() => void> = []; let subs: Array<() => void> = [];
const entryText: Variable<string> = new Variable<string>(""); const entryText: Variable<string> = new Variable<string>("");
let results: (Array<ResultWidget>|null) = props?.resultsPlaceholder ? props.resultsPlaceholder() : null;
let selectedResultIndex = 0;
const searchEntry = new Widget.Entry({ const searchEntry = new Widget.Entry({
className: "search", className: "search",
onChanged: (entry) => entryText.set(entry.text), onChanged: (entry) => entryText.set(entry.text),
placeholderText: props?.entryPlaceHolder || "", placeholderText: props?.entryPlaceHolder || "",
onActivate: (_) => { onActivate: (entry) => {
const resultWidget = resultsList.get_selected_row()?.get_child(); const resultWidget = resultsList.get_selected_row()?.get_child();
if(resultWidget instanceof ResultWidget) { if(resultWidget instanceof ResultWidget) {
resultWidget.onClick(); resultWidget.onClick();
_.isFocus = false; entry.isFocus = false;
} }
}, },
primary_icon_name: "system-search" primary_icon_name: "system-search"
@@ -80,54 +99,44 @@ export namespace Runner {
expand: true expand: true
} as Gtk.ListBox.ConstructorProps); } as Gtk.ListBox.ConstructorProps);
subs.push(entryText().subscribe((text: string) => { function updateResultsList(entryText: string) {
const trimmedText = text.trim(); const calledPlugins: Array<Plugin> = getPlugins().filter((plugin) => plugin.prefix && entryText.startsWith(plugin.prefix) ?
const pluginResult: (ResultWidget|Array<ResultWidget>|null|undefined) = handlePrefix( plugin : null).concat(getPlugins().filter(plugin => plugin.prefix === undefined));
trimmedText)?.(trimmedText.replace(trimmedText.charAt(0), ""));
results = Boolean(pluginResult) ?
(!Array.isArray(pluginResult) ?
[ pluginResult! ]
: pluginResult)
: null;
if(resultsList.get_children().length > 0) { const widgets: Array<ResultWidget> = calledPlugins.map(plugin => plugin.handle(
resultsList.get_children().map((listItem: Gtk.Widget) => { plugin.prefix ? entryText.replace(plugin.prefix, "") : entryText
resultsList.remove(listItem); )).filter(value => value !== undefined && value !== null).flat(1);
listItem.destroy();
});
}
if(results && results.length > 0 && searchEntry.text.trim().length > 0) { // Remove all previous results
results.map((resultWidget: ResultWidget) => { resultsList.get_children().map((listItem: Gtk.Widget) => {
resultsList.insert(resultWidget, -1); resultsList.remove(listItem);
listItem.destroy();
});
const listBoxChild = resultsList.get_row_at_index(resultsList.get_children().length - 1)!; // Insert placeholder if somehow no results are found
const resWidget = listBoxChild.get_child(); if((!entryText || !widgets || widgets.length === 0) && props?.resultsPlaceholder)
if(listBoxChild && resWidget instanceof ResultWidget) { widgets.push(...props.resultsPlaceholder());
resultsList.connect("row-activated", (_, row: Gtk.ListBoxRow) => {
const rWidget = row.get_child()!; // Insert results inside GtkListBox
if(rWidget instanceof ResultWidget) { widgets.map((resultWidget: ResultWidget) => {
if(!onClickTimeout) { resultsList.insert(resultWidget, -1);
rWidget.onClick();
// Timeout, so it doesn't fire the executable a hundred times :skull: resultsList.connect("row-activated", (_, row: Gtk.ListBoxRow) => {
onClickTimeout = timeout(500, () => onClickTimeout = undefined); 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)); subs.push(entryText().subscribe((text: string) => {
} updateResultsList(text.trim());
} resultsList.select_row(resultsList.get_row_at_index(0));
selectedResultIndex = 0;
resultsList.select_row(resultsList.get_row_at_index(selectedResultIndex));
})); }));
if(!runnerInstance) if(!runnerInstance)
@@ -170,19 +179,4 @@ export namespace Runner {
return runnerInstance; return runnerInstance;
} }
export function handlePrefix(text: string): (((a: string) => (Array<ResultWidget>|ResultWidget|null)) | null) {
const prefix = text.charAt(0);
let result: (((a: string) => ResultWidget|Array<ResultWidget>|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;
}
} }