import { AstalIO, timeout, Variable } 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 function startRunnerDefault() { return Runner.openRunner({ entryPlaceHolder: "Start typing...", showResultsPlaceHolderOnStartup: false, } as Runner.RunnerProps, () => [ new ResultWidget({ icon: "application-x-executable-symbolic", title: "Run your applications", description: "Type the name of the application to search" } as ResultWidgetProps), new ResultWidget({ icon: "media-playback-start-symbolic", title: "Control media", description: "Use prefix ':' to run" } as ResultWidgetProps), new ResultWidget({ icon: "utilities-terminal-symbolic", title: "Run shell commands", description: "Start typing with '!' prefix to run shell commands" } as ResultWidgetProps), new ResultWidget({ icon: "applications-internet-symbolic", title: "Search the Web", description: "Start typing with '?' prefix to search the web" } as ResultWidgetProps) ]); } export namespace Runner { export type RunnerProps = { halign?: Gtk.Align; valign?: Gtk.Align; width?: number; height?: number; entryPlaceHolder?: string; showResultsPlaceHolderOnStartup?: boolean; }; 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; } 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(); } }, 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)); } // Init plugins plugins.forEach(plugin => plugin.init && plugin.init()); 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)); 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(); }); // Insert placeholder if somehow no results are found 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) { rWidget.onClick(); // Timeout, so it doesn't fire the event a hundred times :skull: onClickTimeout = timeout(500, () => onClickTimeout = undefined); } } }); }); } 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, 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(); } 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; } }