launcher: windows mode

Also files mode icons
This commit is contained in:
2 * r + 2 * t 2025-01-24 19:38:16 +11:00
parent 3cfb189e90
commit 2293da9789
4 changed files with 262 additions and 16 deletions

View file

@ -16,6 +16,13 @@ export const launcher = {
["codium", "code", "clion", "intellij-idea-ultimate-edition"],
["spotify-adblock", "spotify", "audacious", "elisa"],
],
windows: {
// Weights for fuzzysort
title: 1,
class: 1,
initialTitle: 0.5,
initialClass: 0.5,
},
};
export const notifpopups = {

View file

@ -29,6 +29,7 @@
@include launcher(apps, scheme.$sapphire);
@include launcher(files, scheme.$peach);
@include launcher(math, scheme.$green);
@include launcher(windows, scheme.$teal);
.search-bar {
margin-bottom: lib.s(5);

View file

@ -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();
});

32
src/utils/types.ts Normal file
View file

@ -0,0 +1,32 @@
import type AstalHyprland from "gi://AstalHyprland";
export type Address = `0x${string}`;
export interface Client {
address: Address;
mapped: boolean;
hidden: boolean;
at: [number, number];
size: [number, number];
workspace: {
id: number;
name: string;
};
floating: boolean;
pseudo: boolean;
monitor: number;
class: string;
title: string;
initialClass: string;
initialTitle: string;
pid: number;
xwayland: boolean;
pinned: boolean;
fullscreen: AstalHyprland.Fullscreen;
fullscreenClient: AstalHyprland.Fullscreen;
grouped: Address[];
tags: string[];
swallowing: string;
focusHistoryID: number;
inhibitingIdle: boolean;
}