From 467de2235a4821b32a3faa70712487debcdf52d8 Mon Sep 17 00:00:00 2001 From: retrozinndev Date: Wed, 23 Jul 2025 20:41:23 -0300 Subject: [PATCH] :sparkles: chore(runner): try a new approach to show results, make plugins return properties instead of the actual widget returning the results as objects seems to be a better approach, also, the new way of showing results doesn't work for some reason(i didn't discover it yet lol) --- ags/runner/Runner.tsx | 243 ++++++++++++---------------- ags/runner/plugins/apps.ts | 9 +- ags/runner/plugins/clipboard.ts | 33 ++++ ags/runner/plugins/clipboard.tsx | 28 ---- ags/runner/plugins/media.ts | 93 ++++++----- ags/runner/plugins/shell.ts | 9 +- ags/runner/plugins/wallpapers.ts | 14 +- ags/runner/plugins/websearch.ts | 22 ++- ags/runner/widgets/ResultWidget.tsx | 69 ++++++++ 9 files changed, 278 insertions(+), 242 deletions(-) create mode 100644 ags/runner/plugins/clipboard.ts delete mode 100644 ags/runner/plugins/clipboard.tsx create mode 100644 ags/runner/widgets/ResultWidget.tsx diff --git a/ags/runner/Runner.tsx b/ags/runner/Runner.tsx index 6b8f8ce..62329eb 100644 --- a/ags/runner/Runner.tsx +++ b/ags/runner/Runner.tsx @@ -1,9 +1,10 @@ import { Astal, Gdk, Gtk } from "ags/gtk4"; -import { timeout } from "ags/time"; import { PopupWindow } from "../widget/PopupWindow"; import { updateApps } from "../scripts/apps"; -import { ResultWidget, ResultWidgetProps } from "../widget/runner/ResultWidget"; +import { ResultWidget, ResultWidgetProps } from "./widgets/ResultWidget"; import { Windows } from "../windows"; +import { createState, For } from "ags"; +import { timeout } from "ags/time"; import GObject from "ags/gobject"; import AstalHyprland from "gi://AstalHyprland"; @@ -22,18 +23,21 @@ export type RunnerProps = { showResultsPlaceHolderOnStartup?: boolean; }; +type Result = ResultWidgetProps; + 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 */ + /** runs when runner opens */ 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 handle: (inputText: string) => (Result|Array|null|undefined); + /** runs when runner closes */ readonly onClose?: () => void; - /** hide other plugins when using this plugin */ + /** prioritize this plugin's results over other results. + * (hides other results that aren't from this plugin on list) */ prioritize?: boolean; } @@ -88,105 +92,61 @@ export function openDefault(initialText?: string) { entryPlaceHolder: "Start typing...", initialText, resultsLimit: 24 - } as Runner.RunnerProps, - () => [ - new ResultWidget({ + } as Runner.RunnerProps, [ + { icon: "application-x-executable-symbolic", title: "Use your applications", description: "Search for any app installed in your computer", closeOnClick: false, - onClick: () => gtkEntry?.grab_focus() - } as ResultWidgetProps), - new ResultWidget({ + actionClick: () => gtkEntry?.grab_focus() + }, + { 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({ + actionClick: () => setEntryText('>') + }, + { 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({ + actionClick: () => setEntryText('#'), + }, + { icon: "utilities-terminal-symbolic", title: "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({ + actionClick: () => setEntryText('!!') + }, + { icon: "media-playback-start-symbolic", title: "Control media", description: "Type ':' to control playing media", closeOnClick: false, - onClick: () => setEntryText(':') - } as ResultWidgetProps), - new ResultWidget({ + actionClick: () => setEntryText(':') + }, + { icon: "applications-internet-symbolic", title: "Search the Web", description: "Start typing with '?' prefix to search the web", closeOnClick: false, - onClick: () => setEntryText('?') - } as ResultWidgetProps) + actionClick: () => setEntryText('?') + } ]); } -export function openRunner(props: RunnerProps, placeholder?: () => Array): Astal.Window { - let onClickTimeout: (AstalIO.Time|undefined); - const connections: Map = new Map(); - +export function openRunner(props: RunnerProps, placeholder?: Array): Astal.Window { props.width ??= 780; props.height ??= 420; - gtkEntry = { - updateResultsList(self.text); - resultsList.get_row_at_index(0) && - resultsList.select_row(resultsList.get_row_at_index(0)); + const connections: Map = new Map(); + const [results, setResults] = createState([] as Array); + let clickTimeout: AstalIO.Time|undefined; - if(self.text.trim().length < 1 && !mainBox.get_style_context().has_class("empty-input")) { - mainBox.get_style_context().add_class("empty-input"); - return; - } - - mainBox.get_style_context().has_class("empty-input") && - mainBox.get_style_context().remove_class("empty-input"); - }} onActivate={() => { - const resultWidget = resultsList.get_selected_row()?.get_child(); - if(resultWidget instanceof ResultWidget) { - resultWidget.onClick(); - resultWidget.closeOnClick && Runner.close(); - } - }} - /> as Gtk.SearchEntry; - - const mainBox = - {gtkEntry} - - - - - as Gtk.Box; - - const scrollable = mainBox.get_last_child() as Gtk.ScrolledWindow; - const resultsList = scrollable.get_first_child() as Gtk.ListBox; - - if(props?.showResultsPlaceHolderOnStartup && placeholder) { - const placeholderGtks = placeholder(); - placeholderGtks.map(widget => - resultsList.insert(widget, -1)); - } - - function getPluginResults(input: string): Array { + 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); @@ -208,81 +168,92 @@ export function openRunner(props: RunnerProps, placeholder?: () => Array = []; - - // Remove all previous results - resultsList.remove_all(); - - widgets.push(...getPluginResults(entryText)) + function updateResultsList(input: string) { + const newResults: Array = []; // Insert placeholder if there are no results - if(placeholder && widgets.length === 0) - widgets.push(...placeholder()); + if(placeholder && results.get().length === 0) + newResults.push(...placeholder); - // Insert results inside GtkListBox - widgets.map((resultGtk: ResultWidget) => { - resultsList.insert(resultGtk, -1); - - const conns: Array = []; - - conns.push( - resultsList.connect("row-activated", (_, row: Gtk.ListBoxRow) => { - const rGtk = row.get_child(); - if(rGtk instanceof ResultWidget) { - if(onClickTimeout) return; - - // Timeout, so it doesn't fire the event a hundred times :skull: - onClickTimeout = timeout(500, () => onClickTimeout = undefined); - rGtk.onClick(); - rGtk.closeOnClick && Runner.close(); - } - }), - resultsList.connect("destroy", () => - conns.forEach((id) => resultsList.disconnect(id)) - ) - ); + getPluginResults(input).forEach((result) => { + newResults.unshift(result); }); - widgets.length > 0 ? - (!scrollable.visible && scrollable.show()) - : scrollable.hide(); + setResults(newResults); } if(!instance) - instance = Windows.getDefault().createWindowForFocusedMonitor((mon: number) => { - plugins.forEach(plugin => - plugin.init?.()); + instance = Windows.getDefault().createWindowForFocusedMonitor((mon: number) => + { + plugins.forEach(plugin => + plugin.init?.()); - props.initialText && - Runner.setEntryText(props.initialText); - }} actionKeyPressed={(_, keyval) => { - if(!gtkEntry!.has_focus && keyval !== Gdk.KEY_F5 - && keyval !== Gdk.KEY_Down && keyval !== Gdk.KEY_Up - && keyval !== Gdk.KEY_Return) { - gtkEntry!.grab_focus(); - return; - } + props.initialText && + Runner.setEntryText(props.initialText); + }} actionKeyPressed={(_, keyval) => { + if(!gtkEntry!.has_focus && keyval !== Gdk.KEY_F5 + && keyval !== Gdk.KEY_Down && keyval !== Gdk.KEY_Up + && keyval !== Gdk.KEY_Return) { + gtkEntry!.grab_focus(); + return; + } - keyval === Gdk.KEY_F5 && - updateApps(); - }} onDestroy={() => { - connections.forEach((id, obj) => GObject.signal_handler_is_connected(obj, id) && - obj.disconnect(id)); + keyval === Gdk.KEY_F5 && + updateApps(); + }} onCloseRequest={() => { + connections.forEach((id, obj) => GObject.signal_handler_is_connected(obj, id) && + obj.disconnect(id)); - gtkEntry = null; + gtkEntry = null; - [...plugins.values()].forEach(plugin => - plugin && plugin.onClose && plugin.onClose()); + [...plugins.values()].forEach(plugin => plugin?.onClose?.()); - instance = null; - }}> - {mainBox} - as Astal.Window)(); + instance = null; + }}> + + + gtkEntry = self} + onSearchChanged={(self) => { + updateResultsList(self.text); + const listbox = self.parent.get_last_child()?.get_first_child()?.get_first_child() as Gtk.ListBox; + + listbox.get_row_at_index(0) && + listbox.select_row(listbox.get_row_at_index(0)); + }} onActivate={(self) => { + const listbox = self.parent.get_last_child()?.get_first_child()?.get_first_child() as Gtk.ListBox; + const resultWidget = listbox.get_selected_row()?.get_child(); + + if(resultWidget instanceof ResultWidget) { + resultWidget.actionClick(); + resultWidget.closeOnClick && Runner.close(); + } + }} + /> + + + { + if(row instanceof ResultWidget && !clickTimeout) { + clickTimeout = timeout(250, () => clickTimeout = undefined); + row.actionClick?.(); + } + }}> + + {(res: Result) => } + + + + + as Astal.Window + )(); return instance!; } diff --git a/ags/runner/plugins/apps.ts b/ags/runner/plugins/apps.ts index 15ae8bb..b92251c 100644 --- a/ags/runner/plugins/apps.ts +++ b/ags/runner/plugins/apps.ts @@ -1,5 +1,3 @@ -import { ResultWidget, ResultWidgetProps } from "../../widget/runner/ResultWidget"; -import AstalApps from "gi://AstalApps"; import { execApp, getAstalApps, lookupIcon, updateApps } from "../../scripts/apps"; import { Runner } from "../Runner"; @@ -9,13 +7,12 @@ export const PluginApps = { // asynchronously-refresh apps list on init init: async () => updateApps(), handle: (text: string) => { - return getAstalApps().fuzzy_query(text).map((app: AstalApps.Application) => - new ResultWidget({ + return getAstalApps().fuzzy_query(text).map(app => ({ title: app.get_name(), description: app.get_description(), icon: lookupIcon(app.iconName) ? app.iconName : "application-x-executable-symbolic", - onClick: () => execApp(app) - } as ResultWidgetProps) + actionClick: () => execApp(app) + }) ); } } as Runner.Plugin; diff --git a/ags/runner/plugins/clipboard.ts b/ags/runner/plugins/clipboard.ts new file mode 100644 index 0000000..d13b6e0 --- /dev/null +++ b/ags/runner/plugins/clipboard.ts @@ -0,0 +1,33 @@ +import { Gtk } from "ags/gtk4"; +import { Clipboard } from "../../scripts/clipboard"; +import { Runner } from "../Runner"; +import { jsx } from "ags/gtk4/jsx-runtime"; + + +export const PluginClipboard = { + prefix: '>', + prioritize: true, + handle: (search) => { + if(Clipboard.getDefault().history.length < 1) + return { + icon: "edit-paste-symbolic", + title: "Clipboard is empty", + description: "Copy something and it will be shown right here!" + }; + + return Clipboard.getDefault().history.filter(item => + // not the best way to search, but it works + Runner.regExMatch(search, item.id) || Runner.regExMatch(search, item.preview)).map((item) => ({ + icon: jsx(Gtk.Label, { + label: `${item.id}`, + css: "font-size: 16px; margin-right: 8px; font-weight: 600;" + }), + title: item.preview, + actionClick: () => Clipboard.getDefault().selectItem(item).catch((err: Error) => { + console.error(`Runner(Plugin/Clipboard): An error occurred while selecting clipboard item. Stderr:\n${ + err.message ? `${err.message}\n` : ""}Stack: ${err.stack}` + ); + }) + })); + } +} as Runner.Plugin; diff --git a/ags/runner/plugins/clipboard.tsx b/ags/runner/plugins/clipboard.tsx deleted file mode 100644 index 39c798a..0000000 --- a/ags/runner/plugins/clipboard.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Gtk } from "ags/gtk4"; -import { Clipboard } from "../../scripts/clipboard"; -import { ResultWidget } from "../../widget/runner/ResultWidget"; -import { Runner } from "../Runner"; - - -export const PluginClipboard = { - prefix: '>', - prioritize: true, - handle: (search) => { - if(Clipboard.getDefault().history.length < 1) - return ; - - return Clipboard.getDefault().history.filter(item => // not the best way to search, but it works - Runner.regExMatch(search, item.id) || Runner.regExMatch(search, item.preview)).map((item) => - } - title={item.preview} onClick={() => Clipboard.getDefault().selectItem(item).catch((err: Error) => { - console.error(`Runner(Plugin/Clipboard): An error occurred while selecting clipboard item. Stderr:\n${ - err.message ? `${err.message}\n` : ""}Stack: ${err.stack}` - ); - }) - } - />); - } -} as Runner.Plugin; diff --git a/ags/runner/plugins/media.ts b/ags/runner/plugins/media.ts index d87e967..0a25d3f 100644 --- a/ags/runner/plugins/media.ts +++ b/ags/runner/plugins/media.ts @@ -1,56 +1,53 @@ import { createBinding, createComputed } from "ags"; -import { ResultWidget, ResultWidgetProps } from "../../widget/runner/ResultWidget"; import { Runner } from "../Runner"; -import AstalMpris from "gi://AstalMpris"; import { player } from "../../widget/bar/Media"; +import AstalMpris from "gi://AstalMpris"; + + export const PluginMedia = { prefix: ":", - handle() { - if(!player.get().available) return new ResultWidget({ - icon: "folder-music-symbolic", - title: "Couldn't find any players", + handle: () => !player.get().available ? { + icon: "folder-music-symbolic", + title: "Couldn't find any players", + closeOnClick: false, + description: "No media / player found with mpris" + } : [ + { + icon: createBinding(player.get(), "playbackStatus").as((status) => status === AstalMpris.PlaybackStatus.PLAYING ? + "media-playback-pause-symbolic" + : "media-playback-start-symbolic"), closeOnClick: false, - description: "No media / player found with mpris" - } as ResultWidgetProps); - - return [ - new ResultWidget({ - icon: createBinding(player.get(), "playbackStatus").as((status) => status === AstalMpris.PlaybackStatus.PLAYING ? - "media-playback-pause-symbolic" - : "media-playback-start-symbolic"), - closeOnClick: false, - title: createComputed([ - createBinding(player.get(), "title"), - createBinding(player.get(), "artist"), - createBinding(player.get(), "playbackStatus") - ], (title, artist, status) => `${ status === AstalMpris.PlaybackStatus.PLAYING ? - "Pause" : "Play" - } ${title} | ${artist}`), - onClick: () => player.get().play_pause() - } as ResultWidgetProps), - new ResultWidget({ - icon: "media-skip-backward-symbolic", - closeOnClick: false, - title: createComputed([ - createBinding(player.get(), "title"), - createBinding(player.get(), "artist") - ], (title, artist) => - `Go Previous ${ title ? title : player.get().busName }${ artist ? ` | ${artist}` : "" }` - ), - onClick: () => player.get().canGoPrevious && player.get().previous() - } as ResultWidgetProps), - new ResultWidget({ - icon: "media-skip-forward-symbolic", - closeOnClick: false, - title: createComputed([ - createBinding(player.get(), "title"), - createBinding(player.get(), "artist") - ], (title, artist) => - `Go Next ${ title ? title : player.get().busName }${ artist ? ` | ${artist}` : "" }` - ), - onClick: () => player.get().canGoNext && player.get().next() - } as ResultWidgetProps) - ] - }, + title: createComputed([ + createBinding(player.get(), "title"), + createBinding(player.get(), "artist"), + createBinding(player.get(), "playbackStatus") + ], (title, artist, status) => `${ status === AstalMpris.PlaybackStatus.PLAYING ? + "Pause" : "Play" + } ${title} | ${artist}`), + actionClick: () => player.get().play_pause() + }, + { + icon: "media-skip-backward-symbolic", + closeOnClick: false, + title: createComputed([ + createBinding(player.get(), "title"), + createBinding(player.get(), "artist") + ], (title, artist) => + `Go Previous ${ title ? title : player.get().busName }${ artist ? ` | ${artist}` : "" }` + ), + actionClick: () => player.get().canGoPrevious && player.get().previous() + }, + { + icon: "media-skip-forward-symbolic", + closeOnClick: false, + title: createComputed([ + createBinding(player.get(), "title"), + createBinding(player.get(), "artist") + ], (title, artist) => + `Go Next ${ title ? title : player.get().busName }${ artist ? ` | ${artist}` : "" }` + ), + actionClick: () => player.get().canGoNext && player.get().next() + } + ] } as Runner.Plugin; diff --git a/ags/runner/plugins/shell.ts b/ags/runner/plugins/shell.ts index 364bec7..20cc771 100644 --- a/ags/runner/plugins/shell.ts +++ b/ags/runner/plugins/shell.ts @@ -1,4 +1,3 @@ -import { ResultWidget, ResultWidgetProps } from "../../widget/runner/ResultWidget"; import { Runner } from "../Runner"; import { Notifications } from "../../scripts/notifications"; @@ -17,7 +16,7 @@ export const PluginShell = (() => { return { prefix: '!', prioritize: true, - handle: (input: string): ResultWidget => { + handle: (input) => { let showOutputNotif: boolean = false; if(input.startsWith('!')) { input = input.replace('!', ""); @@ -26,8 +25,8 @@ export const PluginShell = (() => { const command = input ? GLib.shell_parse_argv(input) : undefined; - return new ResultWidget({ - onClick: () => { + return { + actionClick: () => { if(!command || !command[0]) return; const proc = procLauncher.spawnv([ shell, "-c", `${input}` ]); @@ -56,7 +55,7 @@ export const PluginShell = (() => { title: `Run ${input ? ` \`${input}\`` : `with ${shell.split('/')[shell.split('/').length-1]}`}`, description: (input || showOutputNotif) && `${input ? `${shell}\t` : ""}${ showOutputNotif ? "(showing output on notification)" : "" }`, icon: "utilities-terminal-symbolic" - } as ResultWidgetProps) + }; } } as Runner.Plugin })(); diff --git a/ags/runner/plugins/wallpapers.ts b/ags/runner/plugins/wallpapers.ts index 2169cf8..b5c6872 100644 --- a/ags/runner/plugins/wallpapers.ts +++ b/ags/runner/plugins/wallpapers.ts @@ -1,6 +1,5 @@ import { Wallpaper } from "../../scripts/wallpaper"; import { Runner } from "../Runner"; -import { ResultWidget, ResultWidgetProps } from "../../widget/runner/ResultWidget"; import Gio from "gi://Gio?version=2.0"; @@ -26,18 +25,19 @@ 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 + return this.#files!.filter(file => + // also not the best way to search, but it works Runner.regExMatch(search, file.split('/')[file.split('/').length-1]) - ).map(path => new ResultWidget({ + ).map(path => ({ title: path.split('/')[path.split('/').length-1].replace(/\..*$/, ""), - onClick: () => Wallpaper.getDefault().setWallpaper(path) - } as ResultWidgetProps)); + actionClick: () => Wallpaper.getDefault().setWallpaper(path) + })); - return new ResultWidget({ + return { title: "No wallpapers found!", description: "Define the $WALLPAPERS variable on Hyprland or create a ~/wallpapers directory", icon: "image-missing-symbolic" - } as ResultWidgetProps); + }; } } diff --git a/ags/runner/plugins/websearch.ts b/ags/runner/plugins/websearch.ts index 9c1cc3f..710b707 100644 --- a/ags/runner/plugins/websearch.ts +++ b/ags/runner/plugins/websearch.ts @@ -1,7 +1,7 @@ import AstalHyprland from "gi://AstalHyprland"; -import { ResultWidget, ResultWidgetProps } from "../../widget/runner/ResultWidget"; import { Runner } from "../Runner"; + const searchEngines = { duckduckgo: "https://duckduckgo.com/?q=", google: "https://google.com/search?q=", @@ -15,15 +15,13 @@ export const PluginWebSearch = { name: "Web Search", prioritize: true, - handle: (search: string): ResultWidget => { - return new ResultWidget({ - icon: "system-search-symbolic", - title: search || "Type your search...", - description: `Search the Web`, - onClick: () => AstalHyprland.get_default().dispatch( - "exec", - `xdg-open \"${engine + search}\"` - ) - } as ResultWidgetProps); - } + handle: (search) => ({ + icon: "system-search-symbolic", + title: search || "Type your search...", + description: `Search the Web`, + actionClick: () => AstalHyprland.get_default().dispatch( + "exec", + `xdg-open \"${engine + search}\"` + ) + }) } as Runner.Plugin; diff --git a/ags/runner/widgets/ResultWidget.tsx b/ags/runner/widgets/ResultWidget.tsx new file mode 100644 index 0000000..50b1fab --- /dev/null +++ b/ags/runner/widgets/ResultWidget.tsx @@ -0,0 +1,69 @@ +import { Accessor, With } from "ags"; +import { register } from "ags/gobject"; +import { Gtk } from "ags/gtk4"; +import Pango from "gi://Pango?version=1.0"; +import { variableToBoolean } from "../../scripts/utils"; + +export { ResultWidget, ResultWidgetProps }; + +type ResultWidgetProps = { + icon?: string | Accessor | JSX.Element | Accessor; + title: string | Accessor; + description?: string | Accessor; + closeOnClick?: boolean; + setup?: () => void; + actionClick?: () => void; + visible?: boolean; +}; + +@register({ GTypeName: "ResultWidget" }) +class ResultWidget extends Gtk.Box { + + public readonly actionClick: () => void; + public readonly setup?: () => void; + public icon?: (string | Accessor | JSX.Element | Accessor); + public closeOnClick: boolean = true; + + + constructor(props: ResultWidgetProps) { + super(); + + this.add_css_class("result"); + + this.visible = props.visible ?? true; + this.hexpand = true; + this.icon = props.icon; + this.setup = props.setup; + this.closeOnClick = props.closeOnClick ?? true; + this.actionClick = () => props.actionClick?.(); + + if(this.icon !== undefined) { + if(this.icon instanceof Accessor) { + if(typeof this.icon.get() === "string") { + this.prepend( + } /> as Gtk.Image); + } else { + this.prepend( + }> + {(widget) => widget} + + as Gtk.Box); + } + } else { + if(typeof this.icon === "string") + this.prepend( as Gtk.Image); + else + this.prepend(this.icon as Gtk.Widget); + } + } + + this.append( + + + + as Gtk.Box); + } +}