diff options
| -rw-r--r-- | modules/launcher.tsx | 80 | ||||
| -rw-r--r-- | package-lock.json | 110 | ||||
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | scss/launcher.scss | 29 | ||||
| -rw-r--r-- | services/math.ts | 159 | ||||
| -rw-r--r-- | services/players.ts | 6 | ||||
| -rw-r--r-- | utils/constants.ts | 3 |
7 files changed, 338 insertions, 51 deletions
diff --git a/modules/launcher.tsx b/modules/launcher.tsx index 8f01596..4b42328 100644 --- a/modules/launcher.tsx +++ b/modules/launcher.tsx @@ -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) { diff --git a/package-lock.json b/package-lock.json index c5de752..0e638a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" + } } } } diff --git a/package.json b/package.json index f0cb26d..c530938 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "dependencies": { "fuzzysort": "^3.1.0", - "math-expression-evaluator": "^2.0.6" + "mathjs": "^14.0.1" }, "devDependencies": { "esbuild": "0.24.2" diff --git a/scss/launcher.scss b/scss/launcher.scss index 8e710e9..a72389a 100644 --- a/scss/launcher.scss +++ b/scss/launcher.scss @@ -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; - } } diff --git a/services/math.ts b/services/math.ts new file mode 100644 index 0000000..9920044 --- /dev/null +++ b/services/math.ts @@ -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); + } + } + } +} diff --git a/services/players.ts b/services/players.ts index 2a32960..3313595 100644 --- a/services/players.ts +++ b/services/players.ts @@ -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) { diff --git a/utils/constants.ts b/utils/constants.ts index d907014..205315e 100644 --- a/utils/constants.ts +++ b/utils/constants.ts @@ -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}'`); |