From c574fa87f9f1bd4ec91d08cc850363946e13f5cf Mon Sep 17 00:00:00 2001 From: retrozinndev Date: Sun, 11 May 2025 20:53:19 -0300 Subject: [PATCH] :sparkles: ags(runner): add new functions and organize existing --- ags/runner/Runner.ts | 435 +++++++++++++++++-------------- ags/runner/plugins/wallpapers.ts | 4 +- ags/scripts/arg-handler.ts | 39 ++- 3 files changed, 253 insertions(+), 225 deletions(-) diff --git a/ags/runner/Runner.ts b/ags/runner/Runner.ts index 68f9de5..37ec63f 100644 --- a/ags/runner/Runner.ts +++ b/ags/runner/Runner.ts @@ -1,13 +1,77 @@ -import { AstalIO, timeout, Variable } from "astal"; +import { AstalIO, timeout } from "astal"; import { Astal, Gdk, Gtk, Widget } from "astal/gtk3"; import { PopupWindow, PopupWindowProps } from "../widget/PopupWindow"; import { updateApps } from "../scripts/apps"; import { ResultWidget, ResultWidgetProps } from "../widget/runner/ResultWidget"; import { Windows } from "../windows"; -export let runnerInstance: (Gtk.Window|null) = null; +export namespace Runner { +export type RunnerProps = { + halign?: Gtk.Align; + valign?: Gtk.Align; + width?: number; + height?: number; + entryPlaceHolder?: string; + initialText?: string; + showResultsPlaceHolderOnStartup?: boolean; +}; -export function startRunnerDefault(initialText?: string) { +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; + /** ran on runner open */ + readonly init?: () => void; + /** handle the user input to return results (does not include plugin's prefix) */ + readonly handle: (inputText: string) => (ResultWidget|Array|null|undefined); + /** ran on runner close */ + readonly onClose?: () => void; + /** hide other plugins when using this plugin */ + prioritize?: boolean; +} + +export let instance: (Widget.Window|null) = null; +let gtkEntry: (Widget.Entry|null) = null; +const plugins = new Set(); + +export function close() { instance?.close(); } + +export function regExMatch(search: string, item: string): boolean { + search = search.replace(/[\\^$.*?()[\]{}|]/g, "\\$&"); + return new RegExp(`${search.split('').map(c => + `.*(${c.toLowerCase()}|${c.toUpperCase()}).*`).join('')}` + ).test(item); +} + + +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.delete(plugin); + 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 setEntryText(text: string): void { + gtkEntry?.set_text(text); + gtkEntry?.set_position(gtkEntry.textLength); + + gtkEntry?.grab_focus_without_selecting(); +} + +export function openDefault(initialText?: string) { return Runner.openRunner({ entryPlaceHolder: "Start typing...", showResultsPlaceHolderOnStartup: false, @@ -16,222 +80,189 @@ export function startRunnerDefault(initialText?: string) { () => [ new ResultWidget({ icon: "application-x-executable-symbolic", - title: "Run your applications", - description: "Type the name of the application to search" + title: "Use your applications", + description: "Search for any app installed in your computer", + closeOnClick: false, + onClick: () => gtkEntry?.grab_focus() } as ResultWidgetProps), new ResultWidget({ - icon: "media-playback-start-symbolic", - title: "Control media", - description: "Use prefix ':' to run" + icon: "edit-paste-symbolic", + title: "See your clipboard history", + description: "Start your search with '>' to go through your clipboard history", + closeOnClick: false, + onClick: () => setEntryText('>') + } as ResultWidgetProps), + new ResultWidget({ + icon: "image-x-generic-symbolic", + title: "Change your wallpaper", + description: "Add '#' at the start to search through the wallpapers folder!", + closeOnClick: false, + onClick: () => setEntryText('#'), } as ResultWidgetProps), new ResultWidget({ icon: "utilities-terminal-symbolic", title: "Run shell commands", - description: "Start typing with '!' prefix to run shell commands" + description: "Add '!' before your command to run it (pro tip: add a second '!' to show command output)", + closeOnClick: false, + onClick: () => setEntryText('!!') + } as ResultWidgetProps), + new ResultWidget({ + icon: "media-playback-start-symbolic", + title: "Control media", + description: "Type ':' to control playing media", + closeOnClick: false, + onClick: () => setEntryText(':') } as ResultWidgetProps), new ResultWidget({ icon: "applications-internet-symbolic", title: "Search the Web", - description: "Start typing with '?' prefix to search the web" + description: "Start typing with '?' prefix to search the web", + closeOnClick: false, + onClick: () => setEntryText('?') } as ResultWidgetProps) ]); } -export namespace Runner { - export type RunnerProps = { - halign?: Gtk.Align; - valign?: Gtk.Align; - width?: number; - height?: number; - entryPlaceHolder?: string; - initialText?: string; - showResultsPlaceHolderOnStartup?: boolean; - }; +export function openRunner(props?: RunnerProps, placeholder?: () => Array): Widget.Window { + let onClickTimeout: (AstalIO.Time|undefined); - export function close() { runnerInstance?.close(); } - - 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; - /** ran on runner open */ - readonly init?: () => void; - /** handle the user input to return results (does not include plugin's prefix) */ - readonly handle: (inputText: string) => (ResultWidget|Array|null|undefined); - /** ran on runner close */ - readonly onClose?: () => void; - /** hide other plugins when using this plugin **/ - prioritize?: boolean; - } - - 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.delete(plugin); - 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 openRunner(props?: RunnerProps, placeholder?: () => Array): (Gtk.Window|null) { - let subs: Array<() => void> = []; - const entryText: Variable = new Variable(""); - let onClickTimeout: (AstalIO.Time|undefined); - - const searchEntry = new Widget.Entry({ - className: "search", - onChanged: (entry) => entryText.set(entry.text), - placeholderText: props?.entryPlaceHolder || "", - onActivate: (entry) => { - const resultWidget = resultsList.get_selected_row()?.get_child(); - if(resultWidget instanceof ResultWidget) { - entry.isFocus = false; - resultWidget.onClick(); - resultWidget.closeOnClick && Runner.close(); - } - }, - primary_icon_name: "system-search" - } as Widget.EntryProps); - - const resultsList: Gtk.ListBox = new Gtk.ListBox({ - visible: true, - expand: true - } as Gtk.ListBox.ConstructorProps); - - if(props?.showResultsPlaceHolderOnStartup && placeholder) { - const placeholderWidgets = placeholder(); - placeholderWidgets.map(widget => - resultsList.insert(widget, -1)); - } - - function cleanResults() { - resultsList.get_children().map((listItem) => { - resultsList.remove(listItem); - listItem.destroy(); - }); - } - - function getPluginResults(input: string): Array { - let calledPlugins: Array = getPlugins().filter((plugin) => - plugin.prefix ? (input.startsWith(plugin.prefix) ? true : false) : true - ).sort((plugin) => plugin.prefix != null ? 0 : 1); - - for(const plugin of calledPlugins) { - if(plugin.prioritize) { - calledPlugins = [ plugin ]; - break; - } + gtkEntry = new Widget.Entry({ + className: "search", + placeholderText: props?.entryPlaceHolder || "", + onChanged: (self) => { + updateResultsList(self.text); + resultsList.get_row_at_index(0) && + resultsList.select_row(resultsList.get_row_at_index(0)); + }, + onActivate: (entry) => { + const resultWidget = resultsList.get_selected_row()?.get_child(); + if(resultWidget instanceof ResultWidget) { + entry.isFocus = false; + resultWidget.onClick(); + resultWidget.closeOnClick && Runner.close(); } + }, + primary_icon_name: "system-search" + } as Widget.EntryProps); - return calledPlugins.map(plugin => plugin.handle( - plugin.prefix ? input.replace(plugin.prefix, "") : input) - ).filter(value => value !== undefined && value !== null).flat(1); - } + const resultsList: Gtk.ListBox = new Gtk.ListBox({ + visible: true, + expand: true + } as Gtk.ListBox.ConstructorProps); - function updateResultsList(entryText: string) { - const widgets: Array = []; - - // Remove all previous results - cleanResults(); - - widgets.push(...getPluginResults(entryText)) - - // Insert placeholder if there are no results - if(placeholder && widgets.length === 0) - widgets.push(...placeholder()); - - // 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) return; - - // Timeout, so it doesn't fire the event a hundred times :skull: - onClickTimeout = timeout(500, () => onClickTimeout = undefined); - rWidget.onClick(); - rWidget.closeOnClick && Runner.close(); - } - }); - }); - } - - subs.push(entryText().subscribe((text: string) => { - updateResultsList(text.trim()); - resultsList.select_row(resultsList.get_row_at_index(0)); - })); - - if(!runnerInstance) - runnerInstance = Windows.createWindowForFocusedMonitor((mon: number): (Widget.Window) => PopupWindow({ - namespace: "runner", - monitor: mon, - widthRequest: props?.width ?? 750, - heightRequest: props?.height ?? 450, - marginTop: 240, - anchor: Astal.WindowAnchor.TOP | Astal.WindowAnchor.BOTTOM, - setup: () => { - // Init plugins - plugins.forEach(plugin => plugin.init && plugin.init()); - if(props?.initialText) { - searchEntry.set_text(props.initialText); - searchEntry.set_position(searchEntry.textLength); - } - }, - onKeyPressEvent: (_, event: Gdk.Event) => { - const keyVal = event.get_keyval()[1]; - - if(!searchEntry.has_focus && keyVal !== Gdk.KEY_F5 - && keyVal !== Gdk.KEY_Down && keyVal !== Gdk.KEY_Up - && keyVal !== Gdk.KEY_Return) { - searchEntry.grab_focus_without_selecting(); - return; - } - - event.get_keyval()[1] === Gdk.KEY_F5 && - updateApps(); - }, - onDestroy: () => { - subs.map(sub => sub()); - - [...plugins.values()].map(plugin => - plugin && plugin.onClose && plugin.onClose()); - runnerInstance = null; - }, - child: new Widget.Box({ - className: "runner main", - orientation: Gtk.Orientation.VERTICAL, - expand: false, - valign: Gtk.Align.START, - children: [ - searchEntry, - new Widget.Scrollable({ - className: "results-scrollable", - vscroll: Gtk.PolicyType.AUTOMATIC, - hscroll: Gtk.PolicyType.NEVER, - expand: true, - propagateNaturalHeight: true, - maxContentHeight: props?.height ?? 450, - child: resultsList - }) - ] - } as Widget.BoxProps) - } as PopupWindowProps))(); - - return runnerInstance; + if(props?.showResultsPlaceHolderOnStartup && placeholder) { + const placeholderWidgets = placeholder(); + placeholderWidgets.map(widget => + resultsList.insert(widget, -1)); } + + function cleanResults() { + resultsList.get_children().map((listItem) => { + resultsList.remove(listItem); + }); + } + + function getPluginResults(input: string): Array { + let calledPlugins: Array = getPlugins().filter((plugin) => + plugin.prefix ? (input.startsWith(plugin.prefix) ? true : false) : true + ).sort((plugin) => plugin.prefix != null ? 0 : 1); + + for(const plugin of calledPlugins) { + if(plugin.prioritize) { + calledPlugins = [ plugin ]; + break; + } + } + + return calledPlugins.map(plugin => plugin.handle( + plugin.prefix ? input.replace(plugin.prefix, "") : input) + ).filter(value => value !== undefined && value !== null).flat(1); + } + + function updateResultsList(entryText: string) { + const widgets: Array = []; + + // Remove all previous results + cleanResults(); + + widgets.push(...getPluginResults(entryText)) + + // Insert placeholder if there are no results + if(placeholder && widgets.length === 0) + widgets.push(...placeholder()); + + // 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) return; + + // Timeout, so it doesn't fire the event a hundred times :skull: + onClickTimeout = timeout(500, () => onClickTimeout = undefined); + rWidget.onClick(); + rWidget.closeOnClick && Runner.close(); + } + }); + }); + } + + if(!instance) + instance = Windows.createWindowForFocusedMonitor((mon: number): (Widget.Window) => PopupWindow({ + namespace: "runner", + monitor: mon, + widthRequest: props?.width ?? 750, + heightRequest: props?.height ?? 450, + marginTop: 240, + anchor: Astal.WindowAnchor.TOP | Astal.WindowAnchor.BOTTOM, + setup: () => { + // Init plugins + plugins.forEach(plugin => plugin.init && plugin.init()); + + if(props?.initialText) + Runner.setEntryText(props.initialText); + }, + onKeyPressEvent: (_, event: Gdk.Event) => { + const keyVal = event.get_keyval()[1]; + + if(!gtkEntry!.has_focus && keyVal !== Gdk.KEY_F5 + && keyVal !== Gdk.KEY_Down && keyVal !== Gdk.KEY_Up + && keyVal !== Gdk.KEY_Return) { + gtkEntry!.grab_focus_without_selecting(); + return; + } + + event.get_keyval()[1] === Gdk.KEY_F5 && + updateApps(); + }, + onDestroy: () => { + gtkEntry = null; + [...plugins.values()].map(plugin => + plugin && plugin.onClose && plugin.onClose()); + instance = null; + }, + child: new Widget.Box({ + className: "runner main", + orientation: Gtk.Orientation.VERTICAL, + expand: false, + valign: Gtk.Align.START, + children: [ + gtkEntry, + new Widget.Scrollable({ + className: "results-scrollable", + vscroll: Gtk.PolicyType.AUTOMATIC, + hscroll: Gtk.PolicyType.NEVER, + expand: true, + propagateNaturalHeight: true, + maxContentHeight: props?.height ?? 450, + child: resultsList + }) + ] + } as Widget.BoxProps) + } as PopupWindowProps))(); + + return instance!; +} } diff --git a/ags/runner/plugins/wallpapers.ts b/ags/runner/plugins/wallpapers.ts index 2d3eb9d..09b21fd 100644 --- a/ags/runner/plugins/wallpapers.ts +++ b/ags/runner/plugins/wallpapers.ts @@ -26,9 +26,7 @@ export class PluginWallpapers implements Runner.Plugin { handle(search: string) { if(this.#files!.length > 0) return this.#files!.filter(file => // not the best way to search, but it works - new RegExp(`${search.split('').map(c => - `.*(${c.toLowerCase()}|${c.toUpperCase()}).*`).join('')}` - ).test(file.split('/')[file.split('/').length-1]) + Runner.regExMatch(search, file.split('/')[file.split('/').length-1]) ).map(path => new ResultWidget({ title: path.split('/')[path.split('/').length-1].replace(/\..*$/, ""), onClick: () => Wallpaper.getDefault().setWallpaper(path) diff --git a/ags/scripts/arg-handler.ts b/ags/scripts/arg-handler.ts index 8d09f75..fa81ff0 100644 --- a/ags/scripts/arg-handler.ts +++ b/ags/scripts/arg-handler.ts @@ -2,9 +2,9 @@ import { Wireplumber } from "./volume"; import { Windows } from "../windows"; import { restartInstance } from "./reload-handler"; -import { runnerInstance, startRunnerDefault } from "../runner/Runner"; import { showWorkspaceNumbers } from "../widget/bar/Workspaces"; import { timeout } from "astal"; +import { Runner } from "../runner/Runner"; export function handleArguments(request: string): any { @@ -31,9 +31,9 @@ export function handleArguments(request: string): any { `${name}: ${Windows.isVisible(name) ? "open" : "closed" }`).join('\n'); case "runner": - !runnerInstance ? - startRunnerDefault(args[1] || undefined) - : runnerInstance.close(); + !Runner.instance ? + Runner.openDefault(args[1] || undefined) + : Runner.close(); return "Opening runner..." case "show-ws-numbers": @@ -154,22 +154,21 @@ Options: } function getHelp(): string { - return ` -Manage Astal Windows and do more stuff. From -retrozinndev's Hyprland Dots, using Astal and AGS by Aylur. + return `Manage Astal Windows and do more stuff. From + retrozinndev's Hyprland Dots, using Astal and AGS by Aylur. -Options: - open [window_name]: sets specified window's visibility to true. - close [window_name]: sets specified window's visibility to false. - toggle [window_name]: toggles visibility of specified window. - windows: shows available windows to control. - reload: creates a new astal instance and removes this one. - volume: wireplumber volume controller, see "volume help". - runner: open the application runner. - show-ws-numbers: show or hide workspace numbers in bar. - h, help: shows this help message. + Options: + open [window_name]: sets specified window's visibility to true. + close [window_name]: sets specified window's visibility to false. + toggle [window_name]: toggles visibility of specified window. + windows: shows available windows to control. + reload: creates a new astal instance and removes this one. + volume: wireplumber volume controller, see "volume help". + runner [initial_text]: open the application runner. + show-ws-numbers: show or hide workspace numbers in bar. + h, help: shows this help message. -2025 (c) retrozinndev's Hyprland-Dots, licensed under the MIT License. -https://github.com/retrozinndev/Hyprland-Dots -`.trim(); + 2025 (c) retrozinndev's Hyprland-Dots, licensed under the MIT License. + https://github.com/retrozinndev/Hyprland-Dots + `.split('\n').map(l => l.replace(/^ {8}/, "")).join('\n'); }