From 2293da9789c0bddecd230b1b5794a6e790bd8588 Mon Sep 17 00:00:00 2001
From: 2 * r + 2 * t <61896496+soramanew@users.noreply.github.com>
Date: Fri, 24 Jan 2025 19:38:16 +1100
Subject: launcher: windows mode

Also files mode icons
---
 src/modules/launcher.tsx | 238 +++++++++++++++++++++++++++++++++++++++++++----
 1 file changed, 222 insertions(+), 16 deletions(-)

(limited to 'src/modules/launcher.tsx')

diff --git a/src/modules/launcher.tsx b/src/modules/launcher.tsx
index 0e72224..b588e3a 100644
--- a/src/modules/launcher.tsx
+++ b/src/modules/launcher.tsx
@@ -1,17 +1,18 @@
 import { bind, execAsync, Gio, GLib, register, timeout, Variable } from "astal";
-import { Astal, Gtk, Widget } from "astal/gtk3";
+import { App, Astal, Gtk, Widget } from "astal/gtk3";
 import fuzzysort from "fuzzysort";
 import type AstalApps from "gi://AstalApps";
 import AstalHyprland from "gi://AstalHyprland";
 import { launcher as config } from "../../config";
 import { Apps } from "../services/apps";
-import Math, { type HistoryItem } from "../services/math";
+import MathService, { type HistoryItem } from "../services/math";
 import { getAppCategoryIcon } from "../utils/icons";
 import { launch } from "../utils/system";
+import type { Client } from "../utils/types";
 import { MenuItem, setupCustomTooltip } from "../utils/widgets";
 import PopupWindow from "../widgets/popupwindow";
 
-type Mode = "apps" | "files" | "math";
+type Mode = "apps" | "files" | "math" | "windows";
 
 interface Subcommand {
     icon: string;
@@ -28,6 +29,8 @@ const getIconFromMode = (mode: Mode) => {
             return "folder";
         case "math":
             return "calculate";
+        case "windows":
+            return "select_window_2";
     }
 };
 
@@ -39,6 +42,8 @@ const getEmptyTextFromMode = (mode: Mode) => {
             return GLib.find_program_in_path("fd") === null ? "File search requires `fd`" : "No files found";
         case "math":
             return "Type an expression";
+        case "windows":
+            return "No windows found";
     }
 };
 
@@ -133,14 +138,16 @@ const Result = ({
     materialIcon,
     label,
     sublabel,
+    tooltip,
     onClicked,
     onSecondaryClick,
     onDestroy,
 }: {
-    icon?: string;
+    icon?: string | Gio.Icon | null;
     materialIcon?: string;
     label: string;
     sublabel?: string;
+    tooltip?: string;
     onClicked: (self: Widget.Button) => void;
     onSecondaryClick?: (self: Widget.Button) => void;
     onDestroy?: () => void;
@@ -148,16 +155,19 @@ const Result = ({
     <button
         className="result"
         cursor="pointer"
+        tooltipText={tooltip}
         onClicked={onClicked}
         onClick={(self, event) => event.button === Astal.MouseButton.SECONDARY && onSecondaryClick?.(self)}
         onDestroy={onDestroy}
     >
         <box>
-            {icon && Astal.Icon.lookup_icon(icon) ? (
-                <icon valign={Gtk.Align.START} className="icon" icon={icon} />
-            ) : (
-                <label valign={Gtk.Align.START} className="icon" label={materialIcon} />
-            )}
+            {icon &&
+                (typeof icon === "string" ? (
+                    Astal.Icon.lookup_icon(icon) && <icon valign={Gtk.Align.START} className="icon" icon={icon} />
+                ) : (
+                    <icon valign={Gtk.Align.START} className="icon" gicon={icon} />
+                ))}
+            {!icon && materialIcon && <label valign={Gtk.Align.START} className="icon" label={materialIcon} />}
             {sublabel ? (
                 <box vertical valign={Gtk.Align.CENTER} className="has-sublabel">
                     <label hexpand truncate maxWidthChars={1} xalign={0} label={label} />
@@ -229,7 +239,7 @@ const MathResult = ({ math, isHistory, entry }: { math: HistoryItem; isHistory?:
         sublabel={math.result}
         onClicked={() => {
             if (isHistory) {
-                Math.get_default().select(math);
+                MathService.get_default().select(math);
                 entry.set_text(math.equation);
                 entry.grab_focus();
                 entry.set_position(-1);
@@ -243,12 +253,159 @@ const MathResult = ({ math, isHistory, entry }: { math: HistoryItem; isHistory?:
 
 const FileResult = ({ path }: { path: string }) => (
     <Result
+        icon={Gio.File.new_for_path(path)
+            .query_info(Gio.FILE_ATTRIBUTE_STANDARD_ICON, Gio.FileQueryInfoFlags.NONE, null)
+            .get_icon()}
         label={path.split("/").pop()!}
         sublabel={path.startsWith(HOME) ? "~" + path.slice(HOME.length) : path}
         onClicked={self => openFileAndClose(self, path)}
     />
 );
 
+const WindowResult = ({ client, reload }: { client: Client; reload: () => void }) => {
+    const hyprland = AstalHyprland.get_default();
+    const app = Apps.fuzzy_query(client.class)[0];
+    const astalClient = hyprland.get_client(client.address);
+
+    const menu = new Gtk.Menu();
+    menu.append(
+        new MenuItem({
+            label: "Focus",
+            onActivate: () => {
+                close(result);
+                astalClient?.focus();
+            },
+        })
+    );
+    menu.append(new Gtk.SeparatorMenuItem({ visible: true }));
+
+    const addSubmenus = (silent: boolean) => {
+        menu.append(
+            new MenuItem({
+                label: `Move to workspace${silent ? " (silent)" : ""}`,
+                setup: self => {
+                    const submenu = new Gtk.Menu();
+                    const start = Math.floor((hyprland.focusedWorkspace.id - 1) / 10) * 10;
+                    for (let i = 1; i <= 10; i++)
+                        submenu.append(
+                            new MenuItem({
+                                label: `Workspace ${start + i}`,
+                                onActivate: () => {
+                                    if (!silent) close(result);
+                                    hyprland.dispatch(
+                                        `movetoworkspace${silent ? "silent" : ""}`,
+                                        `${start + i},address:${client.address}`
+                                    );
+                                },
+                            })
+                        );
+                    self.set_submenu(submenu);
+                },
+            })
+        );
+        menu.append(
+            new MenuItem({
+                label: `Move to special workspace${silent ? " (silent)" : ""}`,
+                setup: self => {
+                    const submenu = new Gtk.Menu();
+                    submenu.append(
+                        new MenuItem({
+                            label: "special",
+                            onActivate: () => {
+                                if (!silent) close(result);
+                                hyprland.dispatch(
+                                    `movetoworkspace${silent ? "silent" : ""}`,
+                                    `special,address:${client.address}`
+                                );
+                            },
+                        })
+                    );
+                    hyprland.message_async("j/workspaces", (_, res) => {
+                        const workspaces = JSON.parse(hyprland.message_finish(res));
+                        for (const workspace of workspaces)
+                            if (workspace.name.startsWith("special:"))
+                                submenu.append(
+                                    new MenuItem({
+                                        label: workspace.name.slice(8),
+                                        onActivate: () => {
+                                            if (!silent) close(result);
+                                            hyprland.dispatch(
+                                                `movetoworkspace${silent ? "silent" : ""}`,
+                                                `${workspace.name},address:${client.address}`
+                                            );
+                                        },
+                                    })
+                                );
+                    });
+                    self.set_submenu(submenu);
+                },
+            })
+        );
+    };
+    addSubmenus(false);
+    addSubmenus(true);
+
+    menu.append(
+        new MenuItem({
+            label: "Copy property",
+            setup: self => {
+                const addSubmenu = (self: MenuItem, obj: object) => {
+                    const submenu = new Gtk.Menu();
+
+                    for (const [key, value] of Object.entries(obj))
+                        if (typeof value === "object") submenu.append(addSubmenu(new MenuItem({ label: key }), value));
+                        else
+                            submenu.append(
+                                new MenuItem({
+                                    label: key,
+                                    onActivate: () => {
+                                        close(result);
+                                        execAsync(`wl-copy -- ${value}`).catch(console.error);
+                                    },
+                                    tooltipText: String(value),
+                                })
+                            );
+
+                    self.set_submenu(submenu);
+                    return self;
+                };
+                addSubmenu(self, client);
+            },
+        })
+    );
+
+    menu.append(new Gtk.SeparatorMenuItem({ visible: true }));
+    menu.append(
+        new MenuItem({
+            label: "Kill",
+            onActivate: () => {
+                astalClient?.kill();
+                const id = hyprland.connect("client-removed", () => {
+                    hyprland.disconnect(id);
+                    reload();
+                });
+            },
+        })
+    );
+
+    const result = (
+        <Result
+            icon={app.iconName}
+            materialIcon={getAppCategoryIcon(app)}
+            label={client.title || (client.initialTitle ? `${client.initialTitle} (initial)` : "No title")}
+            sublabel={client.class || (client.initialClass ? `${client.initialClass} (initial)` : "No class")}
+            tooltip={`Address: ${client.address}\nWorkspace: ${client.workspace.name} (${client.workspace.id})\nProcess ID: ${client.pid}\nFloating: ${client.floating}\nInhibiting idle: ${client.inhibitingIdle}`}
+            onClicked={self => {
+                close(self);
+                astalClient?.focus();
+            }}
+            onSecondaryClick={() => menu.popup_at_pointer(null)}
+            onDestroy={() => menu.destroy()}
+        />
+    );
+    return result;
+};
+
 const Results = ({ entry, mode }: { entry: Widget.Entry; mode: Variable<Mode> }) => {
     const empty = Variable(true);
 
@@ -290,6 +447,12 @@ const Results = ({ entry, mode }: { entry: Widget.Entry; mode: Variable<Mode> })
                             description: "Do math calculations",
                             command: () => mode.set("math"),
                         },
+                        windows: {
+                            icon: "select_window_2",
+                            name: "Windows",
+                            description: "Manage open windows",
+                            command: () => mode.set("windows"),
+                        },
                         todo: {
                             icon: "checklist",
                             name: "Todo",
@@ -317,10 +480,12 @@ const Results = ({ entry, mode }: { entry: Widget.Entry; mode: Variable<Mode> })
 
                     const calculate = () => {
                         if (entry.text) {
-                            self.add(<MathResult math={Math.get_default().evaluate(entry.text)} entry={entry} />);
+                            self.add(
+                                <MathResult math={MathService.get_default().evaluate(entry.text)} entry={entry} />
+                            );
                             self.add(<box className="separator" />);
                         }
-                        for (const item of Math.get_default().history)
+                        for (const item of MathService.get_default().history)
                             self.add(<MathResult isHistory math={item} entry={entry} />);
                     };
 
@@ -338,18 +503,58 @@ const Results = ({ entry, mode }: { entry: Widget.Entry; mode: Variable<Mode> })
                             })
                             .finally(updateEmpty);
 
+                    const listWindows = () => {
+                        const hyprland = AstalHyprland.get_default();
+                        // Use message cause AstalHyprland is buggy (inconsistent prop updating)
+                        hyprland.message_async("j/clients", (_, res) => {
+                            try {
+                                const unsortedClients: Client[] = JSON.parse(hyprland.message_finish(res));
+                                if (entry.text) {
+                                    const clients = fuzzysort.go(entry.text, unsortedClients, {
+                                        all: true,
+                                        limit: config.maxResults,
+                                        keys: ["title", "class", "initialTitle", "initialClass"],
+                                        scoreFn: r =>
+                                            r[0].score * config.windows.title +
+                                            r[1].score * config.windows.class +
+                                            r[2].score * config.windows.initialTitle +
+                                            r[3].score * config.windows.initialClass,
+                                    });
+                                    self.foreach(ch => ch.destroy());
+                                    for (const { obj } of clients)
+                                        self.add(<WindowResult reload={listWindows} client={obj} />);
+                                } else {
+                                    const clients = unsortedClients.sort((a, b) => a.focusHistoryID - b.focusHistoryID);
+                                    self.foreach(ch => ch.destroy());
+                                    for (const client of clients)
+                                        self.add(<WindowResult reload={listWindows} client={client} />);
+                                }
+                            } catch (e) {
+                                console.error(e);
+                            } finally {
+                                updateEmpty();
+                            }
+                        });
+                    };
+
+                    // Update windows on open
+                    self.hook(App, "window-toggled", (_, window) => {
+                        if (window.name === "launcher" && window.visible && mode.get() === "windows") listWindows();
+                    });
+
                     self.hook(entry, "activate", () => {
                         if (mode.get() === "math") {
-                            if (entry.text.startsWith("clear")) Math.get_default().clear();
-                            else Math.get_default().commit();
+                            if (entry.text.startsWith("clear")) MathService.get_default().clear();
+                            else MathService.get_default().commit();
                         }
                         self.get_children()[0]?.activate();
                     });
                     self.hook(entry, "changed", () => {
                         if (!entry.text && mode.get() === "apps") return;
 
-                        // Files has delay cause async so it does some stuff by itself
-                        const ignoreFileAsync = entry.text.startsWith(">") || mode.get() !== "files";
+                        // Files and windows have delay cause async so they do some stuff by themselves
+                        const ignoreFileAsync =
+                            entry.text.startsWith(">") || (mode.get() !== "files" && mode.get() !== "windows");
                         if (ignoreFileAsync) self.foreach(ch => ch.destroy());
 
                         if (entry.text.startsWith(">")) {
@@ -365,6 +570,7 @@ const Results = ({ entry, mode }: { entry: Widget.Entry; mode: Variable<Mode> })
                         } else if (mode.get() === "apps") appSearch();
                         else if (mode.get() === "math") calculate();
                         else if (mode.get() === "files") fileSearch();
+                        else if (mode.get() === "windows") listWindows();
 
                         if (ignoreFileAsync) updateEmpty();
                     });
-- 
cgit v1.2.3-freya

