launcher: better math

This commit is contained in:
2 * r + 2 * t 2025-01-14 17:51:58 +11:00
parent a313624734
commit cb487aefb1
7 changed files with 338 additions and 51 deletions

View file

@ -3,8 +3,8 @@ import { Astal, Gtk, Widget } from "astal/gtk3";
import fuzzysort from "fuzzysort";
import type AstalApps from "gi://AstalApps";
import AstalHyprland from "gi://AstalHyprland";
import Mexp from "math-expression-evaluator";
import { Apps } from "../services/apps";
import Math, { type HistoryItem } from "../services/math";
import { getAppCategoryIcon } from "../utils/icons";
import { launch } from "../utils/system";
import { PopupWindow, setupCustomTooltip, TransitionType } from "../utils/widgets";
@ -104,7 +104,6 @@ const SearchEntry = ({ entry }: { entry: Widget.Entry }) => (
</stack>
);
// TODO: description field
const Result = ({
icon,
materialIcon,
@ -167,6 +166,25 @@ const AppResult = ({ app }: { app: AstalApps.Application }) => (
/>
);
const MathResult = ({ math, isHistory, entry }: { math: HistoryItem; isHistory?: boolean; entry: Widget.Entry }) => (
<Result
materialIcon={math.icon}
label={math.equation}
sublabel={math.result}
onClicked={() => {
if (isHistory) {
Math.get_default().select(math);
entry.set_text(math.equation);
entry.grab_focus();
entry.set_position(-1);
} else {
execAsync(`wl-copy -- ${math.result}`).catch(console.error);
entry.set_text("");
}
}}
/>
);
const Results = ({ entry, mode }: { entry: Widget.Entry; mode: Variable<Mode> }) => {
const empty = Variable(true);
@ -198,10 +216,10 @@ const Results = ({ entry, mode }: { entry: Widget.Entry; mode: Variable<Mode> })
description: "Search for files",
command: () => mode.set("files"),
},
calc: {
math: {
icon: "calculate",
name: "Calculator",
description: "A calculator...",
name: "Math",
description: "Do math calculations",
command: () => mode.set("math"),
},
todo: {
@ -214,7 +232,6 @@ const Results = ({ entry, mode }: { entry: Widget.Entry; mode: Variable<Mode> })
},
};
const subcommandList = Object.keys(subcommands);
const mexp = new Mexp();
const appSearch = () => {
const apps = Apps.fuzzy_query(entry.text);
@ -224,27 +241,24 @@ const Results = ({ entry, mode }: { entry: Widget.Entry; mode: Variable<Mode> })
};
const calculate = () => {
// TODO: allow defs, history
let math = null;
try {
math = mexp.eval(entry.text);
} catch (e) {
// Ignore
if (entry.text) {
self.add(<MathResult math={Math.get_default().evaluate(entry.text)} entry={entry} />);
self.add(<box className="separator" />);
}
if (math !== null)
self.add(
<Result
materialIcon="calculate"
label={entry.text}
sublabel={String(math)}
onClicked={() => execAsync(`wl-copy -- ${math}`).catch(console.error)}
/>
);
for (const item of Math.get_default().history)
self.add(<MathResult isHistory math={item} entry={entry} />);
};
self.hook(entry, "activate", () => entry.text && self.get_children()[0].activate());
self.hook(entry, "changed", () => {
self.hook(entry, "activate", () => {
if (!entry.text) return;
if (mode.get() === "math") {
if (entry.text.startsWith("clear")) Math.get_default().clear();
else Math.get_default().commit();
}
self.get_children()[0]?.activate();
});
self.hook(entry, "changed", () => {
if (!entry.text && mode.get() === "apps") return;
self.foreach(ch => ch.destroy());
if (entry.text.startsWith(">")) {
@ -268,7 +282,15 @@ const Results = ({ entry, mode }: { entry: Widget.Entry; mode: Variable<Mode> })
);
};
const LauncherContent = ({ mode, entry }: { mode: Variable<Mode>; entry: Widget.Entry }) => (
const LauncherContent = ({
mode,
showResults,
entry,
}: {
mode: Variable<Mode>;
showResults: Variable<boolean>;
entry: Widget.Entry;
}) => (
<box
vertical
className={bind(mode).as(m => `launcher ${m}`)}
@ -280,14 +302,14 @@ const LauncherContent = ({ mode, entry }: { mode: Variable<Mode>; entry: Widget.
<label className="icon" label={bind(mode).as(getIconFromMode)} />
</box>
<revealer
revealChild={bind(entry, "textLength").as(t => t === 0)}
revealChild={bind(showResults).as(s => !s)}
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
transitionDuration={150}
>
<PinnedApps />
</revealer>
<revealer
revealChild={bind(entry, "textLength").as(t => t > 0)}
revealChild={bind(showResults)}
transitionType={Gtk.RevealerTransitionType.SLIDE_UP}
transitionDuration={150}
>
@ -303,6 +325,7 @@ export default class Launcher extends PopupWindow {
constructor() {
const entry = (<entry name="entry" />) as Widget.Entry;
const mode = Variable<Mode>("apps");
const showResults = Variable.derive([bind(entry, "textLength"), mode], (t, m) => t > 0 || m !== "apps");
super({
name: "launcher",
@ -323,12 +346,13 @@ export default class Launcher extends PopupWindow {
transitionType: TransitionType.SLIDE_DOWN,
halign: Gtk.Align.CENTER,
valign: Gtk.Align.START,
child: <LauncherContent mode={mode} entry={entry} />,
child: <LauncherContent mode={mode} showResults={showResults} entry={entry} />,
});
this.mode = mode;
this.connect("hide", () => entry.set_text(""));
// Clear search on hide if not in math mode
this.connect("hide", () => mode.get() !== "math" && entry.set_text(""));
}
open(mode: Mode) {

110
package-lock.json generated
View file

@ -6,12 +6,24 @@
"": {
"dependencies": {
"fuzzysort": "^3.1.0",
"math-expression-evaluator": "^2.0.6"
"mathjs": "^14.0.1"
},
"devDependencies": {
"esbuild": "0.24.2"
}
},
"node_modules/@babel/runtime": {
"version": "7.26.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz",
"integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==",
"license": "MIT",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
@ -437,6 +449,25 @@
"node": ">=18"
}
},
"node_modules/complex.js": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/complex.js/-/complex.js-2.4.2.tgz",
"integrity": "sha512-qtx7HRhPGSCBtGiST4/WGHuW+zeaND/6Ld+db6PbrulIB1i2Ev/2UPiqcmpQNPSyfBKraC0EOvOKCB5dGZKt3g==",
"license": "MIT",
"engines": {
"node": "*"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/decimal.js": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz",
"integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==",
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.24.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
@ -478,17 +509,86 @@
"@esbuild/win32-x64": "0.24.2"
}
},
"node_modules/escape-latex": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/escape-latex/-/escape-latex-1.2.0.tgz",
"integrity": "sha512-nV5aVWW1K0wEiUIEdZ4erkGGH8mDxGyxSeqPzRNtWP7ataw+/olFObw7hujFWlVjNsaDFw5VZ5NzVSIqRgfTiw==",
"license": "MIT"
},
"node_modules/fraction.js": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.2.1.tgz",
"integrity": "sha512-Ah6t/7YCYjrPUFUFsOsRLMXAdnYM+aQwmojD2Ayb/Ezr82SwES0vuyQ8qZ3QO8n9j7W14VJuVZZet8U3bhSdQQ==",
"license": "MIT",
"engines": {
"node": ">= 12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fuzzysort": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz",
"integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==",
"license": "MIT"
},
"node_modules/math-expression-evaluator": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-2.0.6.tgz",
"integrity": "sha512-DRung1qNcKbgkhFeQ0fBPUFB6voRUMY7KyRyp1TRQ2v95Rp2egC823xLRooM1mDx1rmbkY7ym6ZWmpaE/VimOA==",
"node_modules/javascript-natural-sort": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz",
"integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==",
"license": "MIT"
},
"node_modules/mathjs": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/mathjs/-/mathjs-14.0.1.tgz",
"integrity": "sha512-yyJgLwC6UXuve724np8tHRMYaTtb5UqiOGQkjwbSXgH8y1C/LcJ0pvdNDZLI2LT7r+iExh2Y5HwfAY+oZFtGIQ==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.25.7",
"complex.js": "^2.2.5",
"decimal.js": "^10.4.3",
"escape-latex": "^1.2.0",
"fraction.js": "^5.2.1",
"javascript-natural-sort": "^0.7.1",
"seedrandom": "^3.0.5",
"tiny-emitter": "^2.1.0",
"typed-function": "^4.2.1"
},
"bin": {
"mathjs": "bin/cli.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/regenerator-runtime": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"license": "MIT"
},
"node_modules/seedrandom": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz",
"integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==",
"license": "MIT"
},
"node_modules/tiny-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==",
"license": "MIT"
},
"node_modules/typed-function": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/typed-function/-/typed-function-4.2.1.tgz",
"integrity": "sha512-EGjWssW7Tsk4DGfE+5yluuljS1OGYWiI1J6e8puZz9nTMM51Oug8CD5Zo4gWMsOhq5BI+1bF+rWTm4Vbj3ivRA==",
"license": "MIT",
"engines": {
"node": ">= 18"
}
}
}
}

View file

@ -1,7 +1,7 @@
{
"dependencies": {
"fuzzysort": "^3.1.0",
"math-expression-evaluator": "^2.0.6"
"mathjs": "^14.0.1"
},
"devDependencies": {
"esbuild": "0.24.2"

View file

@ -2,6 +2,19 @@
@use "lib";
@use "font";
@mixin launcher($mode, $colour) {
&.#{$mode} {
label.icon {
color: $colour;
}
.separator {
background-color: $colour;
margin: lib.s(5) 0;
}
}
}
.launcher-wrapper {
@include lib.ease-in-out;
@ -21,6 +34,10 @@
color: scheme.$text;
padding: lib.s(10) lib.s(14);
@include launcher(apps, scheme.$sapphire);
@include launcher(files, scheme.$peach);
@include launcher(math, scheme.$green);
.search-bar {
margin-bottom: lib.s(5);
font-size: lib.s(16);
@ -87,16 +104,4 @@
}
}
}
&.apps label.icon {
color: scheme.$sapphire;
}
&.files label.icon {
color: scheme.$peach;
}
&.math label.icon {
color: scheme.$green;
}
}

159
services/math.ts Normal file
View file

@ -0,0 +1,159 @@
import { GLib, GObject, property, readFile, register, writeFileAsync } from "astal";
import {
create,
derivativeDependencies,
evaluateDependencies,
rationalizeDependencies,
simplifyDependencies,
type MathNode,
} from "mathjs/number";
import { CACHE_DIR } from "../utils/constants";
export interface HistoryItem {
equation: string;
result: string;
icon: string;
}
@register({ GTypeName: "Math" })
export default class Math extends GObject.Object {
static instance: Math;
static get_default() {
if (!this.instance) this.instance = new Math();
return this.instance;
}
static math = create({
simplifyDependencies,
derivativeDependencies,
rationalizeDependencies,
evaluateDependencies,
});
readonly #maxHistory = 20;
readonly #path = `${CACHE_DIR}/math-history.json`;
readonly #history: HistoryItem[] = [];
#variables: Record<string, number | MathNode> = {};
#lastExpression: HistoryItem | null = null;
@property(Object)
get history() {
return this.#history;
}
#save() {
writeFileAsync(this.#path, JSON.stringify(this.#history)).catch(console.error);
}
/**
* Commits the last evaluated expression to the history
*/
commit() {
if (!this.#lastExpression) return;
// Try select first to prevent duplicates, if it fails, add it
if (!this.select(this.#lastExpression)) {
this.#history.unshift(this.#lastExpression);
if (this.#history.length > this.#maxHistory) this.#history.pop();
this.notify("history");
this.#save();
}
this.#lastExpression = null;
}
/**
* Moves an item in the history to the top
* @param item The item to select
* @returns If the item was successfully selected
*/
select(item: HistoryItem) {
const idx = this.#history.indexOf(item);
if (idx >= 0) {
this.#history.splice(idx, 1);
this.#history.unshift(item);
this.notify("history");
this.#save();
return true;
}
return false;
}
/**
* Clears the history and variables
*/
clear() {
if (this.#history.length > 0) {
this.#history.length = 0;
this.notify("history");
this.#save();
}
this.#lastExpression = null;
this.#variables = {};
}
/**
* Evaluates an equation and adds it to the history
* @param equation The equation to evaluate
* @returns A {@link HistoryItem} representing the result of the equation
*/
evaluate(equation: string): HistoryItem {
if (equation.startsWith("clear"))
return {
equation: "Clear history",
result: "Delete history and previously set variables",
icon: "delete_forever",
};
let result: string, icon: string;
try {
if (equation.includes("=")) {
const [left, right] = equation.split("=");
try {
this.#variables[left] = Math.math.simplify(right);
} catch {
this.#variables[left] = parseFloat(right);
}
result = this.#variables[left].toString();
icon = "equal";
} else if (equation.startsWith("simplify")) {
result = Math.math.simplify(equation.slice(8), this.#variables).toString();
icon = "function";
} else if (equation.startsWith("derive")) {
const respectTo = equation.slice(6).split(" ")[0];
result = Math.math.derivative(equation.slice(7 + respectTo.length), respectTo).toString();
icon = "function";
} else if (equation.startsWith("rationalize")) {
result = Math.math.rationalize(equation.slice(11), this.#variables).toString();
icon = "function";
} else {
result = Math.math.evaluate(equation, this.#variables).toString();
icon = "calculate";
}
} catch {
result = equation;
icon = "error";
equation = "Invalid equation";
}
return (this.#lastExpression = { equation, result, icon });
}
constructor() {
super();
// Load history
if (GLib.file_test(this.#path, GLib.FileTest.EXISTS)) {
try {
this.#history = JSON.parse(readFile(this.#path));
// Init eval to create variables and last expression
for (const item of this.#history) this.evaluate(item.equation);
} catch (e) {
console.error("Math - Unable to load history", e);
}
}
}
}

View file

@ -1,4 +1,4 @@
import { execAsync, GLib, GObject, property, readFile, register, writeFileAsync } from "astal";
import { GLib, GObject, property, readFile, register, writeFileAsync } from "astal";
import AstalMpris from "gi://AstalMpris";
import { CACHE_DIR } from "../utils/constants";
import { isRealPlayer } from "../utils/mpris";
@ -89,9 +89,7 @@ export default class Players extends GObject.Object {
}
#save() {
execAsync(`mkdir -p ${CACHE_DIR}`)
.then(() => writeFileAsync(this.#path, this.#players.map(p => p.busName).join("\n")).catch(console.error))
.catch(console.error);
writeFileAsync(this.#path, this.#players.map(p => p.busName).join("\n")).catch(console.error);
}
#connectPlayerSignals(player: AstalMpris.Player) {

View file

@ -1,3 +1,4 @@
import { GLib } from "astal";
import { exec, GLib } from "astal";
export const CACHE_DIR = GLib.get_user_cache_dir() + "/caelestia";
exec(`mkdir -p '${CACHE_DIR}'`);