diff --git a/src/app.ts b/src/app.ts index c8013c8..b8608ec 100644 --- a/src/app.ts +++ b/src/app.ts @@ -70,7 +70,7 @@ export class Shell extends Adw.Application { version: COLORSHELL_VERSION ?? "0.0.0-unknown", }); - setConsoleLogDomain("colorshell"); + setConsoleLogDomain("Colorshell"); GLib.set_application_name("colorshell"); } diff --git a/src/modules/utils.ts b/src/modules/utils.ts index 663c8d4..a59b328 100644 --- a/src/modules/utils.ts +++ b/src/modules/utils.ts @@ -60,6 +60,15 @@ export function escapeSpecialCharacters(str: string): string { return str.replace(/[\\^$.*?()[\]{}|]/g, "\\$&"); } +/** translate paths with environment variables in it to absolute paths */ +export function translateDirWithEnvironment(path: string): string { + path = path.replace(/^[~]/, GLib.get_home_dir()); + + return path.split('/').map(part => /^\$/.test(part) ? + GLib.getenv(part.replace(/^\$/, "")) ?? part + : part).join('/'); +} + export function getChildren(widget: Gtk.Widget): Array { const firstChild = widget.get_first_child(), children: Array = []; diff --git a/src/runner/Runner.tsx b/src/runner/Runner.tsx index 35c2602..e68b95a 100644 --- a/src/runner/Runner.tsx +++ b/src/runner/Runner.tsx @@ -1,12 +1,12 @@ import { Astal, Gdk, Gtk } from "ags/gtk4"; +import { CCProps } from "ags"; import { getPopupWindowContainer, PopupWindow } from "../widget/PopupWindow"; import { updateApps } from "../modules/apps"; import { ResultWidget, ResultWidgetProps } from "./widgets/ResultWidget"; import { Windows } from "../windows"; -import { timeout } from "ags/time"; import AstalHyprland from "gi://AstalHyprland"; -import AstalIO from "gi://AstalIO"; +import GLib from "gi://GLib?version=2.0"; export namespace Runner { @@ -21,7 +21,7 @@ export type RunnerProps = { showResultsPlaceHolderOnStartup?: boolean; }; -export type Result = ResultWidgetProps; +export type Result = CCProps; export interface Plugin { /** prefix to call the plugin. if undefined, will be triggered like applications plugin */ @@ -31,17 +31,22 @@ export interface Plugin { /** runs when runner opens */ readonly init?: () => void; /** handle the user input to return results (does not include plugin's prefix) */ - readonly handle: (inputText: string, limit?: number) => (Result|Array|null|undefined); + readonly handle: (inputText: string, limit?: number) => Promise|null|undefined>|Result|Array|null|undefined; /** runs when runner closes */ readonly onClose?: () => void; /** prioritize this plugin's results over other results. * (hides other results that aren't from this plugin on list) */ prioritize?: boolean; + /** show a specific icon when the plugin is prioritized/only + * has results from this plugin + * @todo actually implement the plugin icon feature + * @default "system-search-symbolic" */ + iconName?: string; } export let instance: (Astal.Window|null) = null; -let gtkEntry: (Gtk.SearchEntry|null) = null; +let gtkEntry: (Gtk.Entry|null) = null; const plugins = new Set(); const ignoredKeys = [ Gdk.KEY_space, @@ -137,9 +142,9 @@ export function openDefault(initialText?: string) { { 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)", + description: "Add '!' before your command to run it (tip: add another '!' to notify command output)", closeOnClick: false, - actionClick: () => setEntryText('!!') + actionClick: () => setEntryText('!') }, { icon: "media-playback-start-symbolic", @@ -158,7 +163,7 @@ export function openDefault(initialText?: string) { ]); } -function getPluginResults(input: string, limit?: number): Array { +async function getPluginResults(input: string, limit?: number): Promise> { let calledPlugins: Array = getPlugins().filter((plugin) => plugin.prefix ? (input.startsWith(plugin.prefix) ? true : false) : true ).sort((plugin) => plugin.prefix != null ? 0 : 1); @@ -166,26 +171,56 @@ function getPluginResults(input: string, limit?: number): Array { for(const plugin of calledPlugins) { if(plugin.prioritize) { calledPlugins = [ plugin ]; + plugin.iconName !== undefined && + gtkEntry break; } } - const results = calledPlugins.map(plugin => plugin.handle( - plugin.prefix ? input.replace(plugin.prefix, "") : input), - limit - ).filter(value => value !== undefined && value !== null).flat(1); + let results: Array = []; + function push(result: Result|null|undefined|void|Array) { + if(Array.isArray(result)) { + results.push(...result.filter(r => r != null)); + return; + } - return limit != null && limit > 0 ? + result && results.push(result); + } + + for(const plugin of calledPlugins) { + const res = plugin.handle(plugin.prefix ? + input.replace(plugin.prefix, "") + : input, limit); + + res instanceof Promise ? + await res.then(push) + : push(res); + } + + return limit !== undefined && limit > 0 && limit !== Infinity ? results.splice(0, limit) : results; } -function updateResultsList(listbox: Gtk.ListBox, input: string, limit?: number, placeholders?: Array) { +async function updateResultsList(listbox: Gtk.ListBox, input: string, limit?: number, placeholders?: Array) { const newResults: Array = [], scrolledWindow = listbox.parent.parent as Gtk.ScrolledWindow; + const results = await getPluginResults(input, limit).catch((e: Error) => { + console.error(`Couldn't get results because of an error: ${e.message}\n${e.stack}`); + + listbox.insert( gtkEntry?.select_region(0, gtkEntry?.text.length - 1)} + /> as ResultWidget, -1); + + return [] as Array; + }); + listbox.remove_all(); - getPluginResults(input, limit).forEach((result) => { + + results.forEach((result) => { listbox.insert( as ResultWidget, -1); newResults.push(result); }); @@ -196,6 +231,7 @@ function updateResultsList(listbox: Gtk.ListBox, input: string, limit?: number, as ResultWidget, -1 )); + newResults.length > 0 ? (!scrolledWindow.visible && scrolledWindow.show()) : scrolledWindow.hide(); @@ -237,7 +273,7 @@ export function openRunner(props: RunnerProps, placeholders?: Array): As props.width ??= 780; props.height ??= 420; - let clickTimeout: AstalIO.Time|undefined; + let clickTimeout: GLib.Source|undefined; if(!instance) instance = Windows.getDefault().createWindowForFocusedMonitor((mon, root) => @@ -289,27 +325,38 @@ export function openRunner(props: RunnerProps, placeholders?: Array): As instance = null; gtkEntry = null; }}> - gtkEntry = self} searchDelay={0} onSearchChanged={(self) => { + gtkEntry = self} onNotifyText={(self) => { const listbox = ((self.get_next_sibling()! as Gtk.ScrolledWindow) .get_child() as Gtk.Viewport).get_child() as Gtk.ListBox; - updateResultsList(listbox, self.text, props.resultsLimit, placeholders); - - listbox.get_row_at_index(0) && - listbox.select_row(listbox.get_row_at_index(0)); + updateResultsList(listbox, self.text, props.resultsLimit, placeholders).then(() => + listbox.get_row_at_index(0) && + listbox.select_row(listbox.get_row_at_index(0)) + ); + }} primaryIconName={"system-search-symbolic"} + primaryIconTooltipText={"Search"} + secondaryIconName={"edit-clear-symbolic"} + secondaryIconTooltipText={"Clear search"} + onIconRelease={(self, iconPos) => { + if(iconPos === Gtk.EntryIconPosition.PRIMARY) { + self.notify("text"); // emit notify::text, so it will force-search again + return; + } + + self.set_text(""); }} onActivate={(self) => { const listbox = ((self.get_next_sibling() as Gtk.ScrolledWindow) .get_child() as Gtk.Viewport).get_child() as Gtk.ListBox; const resultWidget = listbox.get_selected_row()?.get_child(); if(resultWidget instanceof ResultWidget && !clickTimeout) { - clickTimeout = timeout(250, () => clickTimeout = undefined); + clickTimeout = setTimeout(() => clickTimeout = undefined, 250); resultWidget.actionClick(); resultWidget.closeOnClick && Runner.close(); } - }} onStopSearch={() => Runner.close()} // close Runner on Escape + }} onUnrealize={() => Runner.close()} /> ): As const child = row.get_child()!; if(child instanceof ResultWidget && !clickTimeout) { - clickTimeout = timeout(250, () => clickTimeout = undefined); + clickTimeout = setTimeout(() => clickTimeout = undefined, 250); child.actionClick?.(); child.closeOnClick && Runner.close(); diff --git a/src/runner/plugins/clipboard.ts b/src/runner/plugins/clipboard.ts index b6990a1..dd5219a 100644 --- a/src/runner/plugins/clipboard.ts +++ b/src/runner/plugins/clipboard.ts @@ -40,7 +40,7 @@ class _PluginClipboard implements Runner.Plugin { }; } - handle(search: string, limit?: number) { + async handle(search: string, limit?: number) { if(Clipboard.getDefault().history.length < 1) return { icon: "edit-paste-symbolic", diff --git a/src/runner/widgets/ResultWidget.tsx b/src/runner/widgets/ResultWidget.tsx index 409c0c2..ed18f26 100644 --- a/src/runner/widgets/ResultWidget.tsx +++ b/src/runner/widgets/ResultWidget.tsx @@ -1,17 +1,15 @@ -import { Accessor, With } from "ags"; -import { register } from "ags/gobject"; +import { getter, gtype, property, register, setter } from "ags/gobject"; import { Gtk } from "ags/gtk4"; import { variableToBoolean } from "../../modules/utils"; import Pango from "gi://Pango?version=1.0"; +import GdkPixbuf from "gi://GdkPixbuf?version=2.0"; -export { ResultWidget, ResultWidgetProps }; - -type ResultWidgetProps = { - icon?: string | Accessor | JSX.Element | Accessor; - title: string | Accessor; - description?: string | Accessor; +export type ResultWidgetProps = { + icon?: string | GdkPixbuf.Pixbuf | Gtk.Widget | JSX.Element; + title: string; + description?: string; closeOnClick?: boolean; setup?: () => void; actionClick?: () => void; @@ -19,47 +17,35 @@ type ResultWidgetProps = { }; @register({ GTypeName: "ResultWidget" }) -class ResultWidget extends Gtk.Box { +export class ResultWidget extends Gtk.Box { + + #icon: string|Gtk.Widget|GdkPixbuf.Pixbuf|null = null; public readonly actionClick: () => void; public readonly setup?: () => void; - public icon?: (string | Accessor | JSX.Element | Accessor); - public closeOnClick: boolean = true; + @property(Boolean) + closeOnClick: boolean = true; - constructor(props: ResultWidgetProps) { + @getter(gtype(Object)) + get icon() { return this.#icon; } + + @setter(gtype(Object)) + set icon(newIcon: string|Gtk.Widget|GdkPixbuf.Pixbuf|null) { + this.set_icon(newIcon); + } + + constructor(props: ResultWidgetProps & Partial) { 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( @@ -67,5 +53,67 @@ class ResultWidget extends Gtk.Box { as Gtk.Box); + + + if(props.icon !== undefined) + this.set_icon(props.icon as Gtk.Widget|string|GdkPixbuf.Pixbuf); + } + + /** it is recommended to not change the custom widget's name. */ + set_icon(icon: string|Gtk.Widget|GdkPixbuf.Pixbuf|null): void { + const firstChild = this.get_first_child(); + + if(icon === null && firstChild?.name !== undefined && + /^(custom\-)?icon\-widget$/.test(firstChild?.name)) { + + this.remove(firstChild); + return; + } + + if(firstChild && firstChild.name === "icon-widget" && + firstChild instanceof Gtk.Image) { + + if(typeof icon === "string") { + firstChild.set_from_icon_name(icon); + this.#icon = icon; + this.notify("icon"); + return; + } + + if(icon instanceof GdkPixbuf.Pixbuf) { + firstChild.set_from_pixbuf(icon); + this.#icon = icon; + this.notify("icon"); + return; + } + + // remove if we're not going to use it + this.remove(firstChild); + } + + if(icon instanceof Gtk.Widget) { + if(firstChild?.name === "custom-icon-widget") + this.remove(firstChild); + + this.prepend(icon); + this.#icon = this.get_first_child(); + this.notify("icon"); + return; + } + + this.prepend( + { + if(typeof icon === "string") { + self.set_from_icon_name(icon); + this.#icon = icon; + this.notify("icon"); + return; + } + + self.set_from_pixbuf(icon); + this.#icon = icon; + this.notify("icon"); + }} /> as Gtk.Image + ); } }