app launcher
This commit is contained in:
parent
6a66214b8f
commit
e923d39cde
7 changed files with 412 additions and 8 deletions
16
app.tsx
16
app.tsx
|
|
@ -2,7 +2,9 @@ import { execAsync, GLib, writeFileAsync } from "astal";
|
||||||
import { App } from "astal/gtk3";
|
import { App } from "astal/gtk3";
|
||||||
import AstalHyprland from "gi://AstalHyprland";
|
import AstalHyprland from "gi://AstalHyprland";
|
||||||
import Bar from "./modules/bar";
|
import Bar from "./modules/bar";
|
||||||
|
import Launcher from "./modules/launcher";
|
||||||
import NotifPopups from "./modules/notifpopups";
|
import NotifPopups from "./modules/notifpopups";
|
||||||
|
import { PopupWindow } from "./utils/widgets";
|
||||||
|
|
||||||
const loadStyleAsync = async () => {
|
const loadStyleAsync = async () => {
|
||||||
if (!GLib.file_test(`${SRC}/scss/scheme/_index.scss`, GLib.FileTest.EXISTS))
|
if (!GLib.file_test(`${SRC}/scss/scheme/_index.scss`, GLib.FileTest.EXISTS))
|
||||||
|
|
@ -17,15 +19,25 @@ App.start({
|
||||||
main() {
|
main() {
|
||||||
loadStyleAsync().catch(console.error);
|
loadStyleAsync().catch(console.error);
|
||||||
|
|
||||||
|
<Launcher />;
|
||||||
<NotifPopups />;
|
<NotifPopups />;
|
||||||
AstalHyprland.get_default().monitors.forEach(m => <Bar monitor={m} />);
|
AstalHyprland.get_default().monitors.forEach(m => <Bar monitor={m} />);
|
||||||
|
|
||||||
console.log("Caelestia started");
|
console.log("Caelestia started");
|
||||||
},
|
},
|
||||||
requestHandler(request, res) {
|
requestHandler(request, res) {
|
||||||
|
let log = true;
|
||||||
|
|
||||||
if (request === "reload css") loadStyleAsync().catch(console.error);
|
if (request === "reload css") loadStyleAsync().catch(console.error);
|
||||||
else return res("Unknown command: " + request);
|
else if (request.startsWith("toggle")) {
|
||||||
console.log(`Request handled: ${request}`);
|
const window = App.get_window(request.slice(7));
|
||||||
|
if (window instanceof PopupWindow) window.toggle();
|
||||||
|
else App.toggle_window(request.slice(7));
|
||||||
|
|
||||||
|
log = false;
|
||||||
|
} else return res("Unknown command: " + request);
|
||||||
|
|
||||||
|
if (log) console.log(`Request handled: ${request}`);
|
||||||
res("OK");
|
res("OK");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
190
modules/launcher.tsx
Normal file
190
modules/launcher.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
import { bind, Gio, timeout, Variable } from "astal";
|
||||||
|
import { Astal, Gtk, Widget } from "astal/gtk3";
|
||||||
|
import type AstalApps from "gi://AstalApps";
|
||||||
|
import AstalHyprland from "gi://AstalHyprland";
|
||||||
|
import { Apps } from "../services/apps";
|
||||||
|
import { getAppCategoryIcon } from "../utils/icons";
|
||||||
|
import { launch } from "../utils/system";
|
||||||
|
import { PopupWindow, setupCustomTooltip, TransitionType } from "../utils/widgets";
|
||||||
|
|
||||||
|
const maxSearchResults = 15;
|
||||||
|
|
||||||
|
const browser = [
|
||||||
|
"firefox",
|
||||||
|
"waterfox",
|
||||||
|
"google-chrome",
|
||||||
|
"chromium",
|
||||||
|
"brave-browser",
|
||||||
|
"vivaldi-stable",
|
||||||
|
"vivaldi-snapshot",
|
||||||
|
];
|
||||||
|
const terminal = ["foot", "alacritty", "kitty", "wezterm"];
|
||||||
|
const files = ["thunar", "nemo", "nautilus"];
|
||||||
|
const ide = ["codium", "code", "clion", "intellij-idea-ultimate-edition"];
|
||||||
|
const music = ["spotify-adblock", "spotify", "audacious", "elisa"];
|
||||||
|
|
||||||
|
const launchAndClose = (self: JSX.Element, astalApp: AstalApps.Application) => {
|
||||||
|
const toplevel = self.get_toplevel();
|
||||||
|
if (toplevel instanceof Widget.Window) toplevel.hide();
|
||||||
|
launch(astalApp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PinnedApp = ({ names }: { names: string[] }) => {
|
||||||
|
let app: Gio.DesktopAppInfo | null = null;
|
||||||
|
let astalApp: AstalApps.Application | undefined;
|
||||||
|
for (const name of names) {
|
||||||
|
app = Gio.DesktopAppInfo.new(`${name}.desktop`);
|
||||||
|
if (app) {
|
||||||
|
astalApp = Apps.get_list().find(a => a.entry === `${name}.desktop`);
|
||||||
|
if (app.get_icon() && astalApp) break;
|
||||||
|
else app = null; // Set app to null if no icon or matching AstalApps#Application
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!app) console.error(`Launcher - Unable to find app for "${names.join(", ")}"`);
|
||||||
|
|
||||||
|
return app ? (
|
||||||
|
<button
|
||||||
|
className="app"
|
||||||
|
cursor="pointer"
|
||||||
|
onClicked={self => launchAndClose(self, astalApp!)}
|
||||||
|
setup={self => setupCustomTooltip(self, app.get_display_name())}
|
||||||
|
>
|
||||||
|
<icon gicon={app.get_icon()!} />
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PinnedApps = () => (
|
||||||
|
<box homogeneous className="pinned">
|
||||||
|
<PinnedApp names={browser} />
|
||||||
|
<PinnedApp names={terminal} />
|
||||||
|
<PinnedApp names={files} />
|
||||||
|
<PinnedApp names={ide} />
|
||||||
|
<PinnedApp names={music} />
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
|
||||||
|
const SearchEntry = ({ entry }: { entry: Widget.Entry }) => (
|
||||||
|
<stack
|
||||||
|
hexpand
|
||||||
|
transitionType={Gtk.StackTransitionType.CROSSFADE}
|
||||||
|
transitionDuration={150}
|
||||||
|
setup={self =>
|
||||||
|
self.hook(entry, "notify::text-length", () =>
|
||||||
|
// Timeout to avoid flickering when replacing entire text (cause it'll set len to 0 then back to > 0)
|
||||||
|
timeout(1, () => (self.shown = entry.textLength > 0 ? "entry" : "placeholder"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<label name="placeholder" className="placeholder" xalign={0} label='Type ">" for subcommands' />
|
||||||
|
{entry}
|
||||||
|
</stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Result = ({ app }: { app: AstalApps.Application }) => (
|
||||||
|
<button className="app" cursor="pointer" onClicked={self => launchAndClose(self, app)}>
|
||||||
|
<box>
|
||||||
|
{Astal.Icon.lookup_icon(app.iconName) ? (
|
||||||
|
<icon className="icon" icon={app.iconName} />
|
||||||
|
) : (
|
||||||
|
<label className="icon" label={getAppCategoryIcon(app)} />
|
||||||
|
)}
|
||||||
|
<label xalign={0} label={app.name} />
|
||||||
|
</box>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Results = ({ entry }: { entry: Widget.Entry }) => {
|
||||||
|
const empty = Variable(true);
|
||||||
|
return (
|
||||||
|
<stack
|
||||||
|
className="results"
|
||||||
|
transitionType={Gtk.StackTransitionType.CROSSFADE}
|
||||||
|
transitionDuration={150}
|
||||||
|
shown={bind(empty).as(t => (t ? "empty" : "list"))}
|
||||||
|
>
|
||||||
|
<box name="empty" className="empty" halign={Gtk.Align.CENTER} valign={Gtk.Align.CENTER}>
|
||||||
|
<label className="icon" label="apps_outage" />
|
||||||
|
<label label="No results" />
|
||||||
|
</box>
|
||||||
|
<box
|
||||||
|
vertical
|
||||||
|
name="list"
|
||||||
|
setup={self => {
|
||||||
|
let apps: AstalApps.Application[] = [];
|
||||||
|
self.hook(entry, "activate", () => {
|
||||||
|
if (entry.text && apps[0]) launchAndClose(self, apps[0]);
|
||||||
|
});
|
||||||
|
self.hook(entry, "changed", () => {
|
||||||
|
if (!entry.text) return;
|
||||||
|
self.foreach(ch => ch.destroy());
|
||||||
|
apps = Apps.fuzzy_query(entry.text);
|
||||||
|
empty.set(apps.length === 0);
|
||||||
|
if (apps.length > maxSearchResults) apps.length = maxSearchResults;
|
||||||
|
for (const app of apps) self.add(<Result app={app} />);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Launcher = ({ entry }: { entry: Widget.Entry }) => (
|
||||||
|
<box
|
||||||
|
vertical
|
||||||
|
className="launcher"
|
||||||
|
css={bind(AstalHyprland.get_default(), "focusedMonitor").as(m => `margin-top: ${m.height / 4}px;`)}
|
||||||
|
>
|
||||||
|
<box className="search-bar">
|
||||||
|
<label className="icon" label="search" />
|
||||||
|
<SearchEntry entry={entry} />
|
||||||
|
<label className="icon" label="apps" />
|
||||||
|
</box>
|
||||||
|
<revealer
|
||||||
|
revealChild={bind(entry, "textLength").as(t => t === 0)}
|
||||||
|
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
|
||||||
|
transitionDuration={150}
|
||||||
|
>
|
||||||
|
<PinnedApps />
|
||||||
|
</revealer>
|
||||||
|
<revealer
|
||||||
|
revealChild={bind(entry, "textLength").as(t => t > 0)}
|
||||||
|
transitionType={Gtk.RevealerTransitionType.SLIDE_UP}
|
||||||
|
transitionDuration={150}
|
||||||
|
>
|
||||||
|
<Results entry={entry} />
|
||||||
|
</revealer>
|
||||||
|
</box>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default () => {
|
||||||
|
const entry = (<entry name="entry" />) as Widget.Entry;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopupWindow
|
||||||
|
name="launcher"
|
||||||
|
keymode={Astal.Keymode.EXCLUSIVE}
|
||||||
|
exclusivity={Astal.Exclusivity.IGNORE}
|
||||||
|
onKeyPressEvent={(_, event) => {
|
||||||
|
const keyval = event.get_keyval()[1];
|
||||||
|
// Focus entry on typing
|
||||||
|
if (!entry.isFocus && keyval >= 32 && keyval <= 126) {
|
||||||
|
entry.text += String.fromCharCode(keyval);
|
||||||
|
entry.grab_focus();
|
||||||
|
entry.set_position(-1);
|
||||||
|
|
||||||
|
// Consume event, if not consumed it will duplicate character in entry
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
// Clear entry text on hide
|
||||||
|
setup={self => self.connect("hide", () => entry.set_text(""))}
|
||||||
|
transitionType={TransitionType.SLIDE_DOWN}
|
||||||
|
halign={Gtk.Align.CENTER}
|
||||||
|
valign={Gtk.Align.START}
|
||||||
|
>
|
||||||
|
<Launcher entry={entry} />
|
||||||
|
</PopupWindow>
|
||||||
|
);
|
||||||
|
};
|
||||||
80
scss/launcher.scss
Normal file
80
scss/launcher.scss
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
@use "scheme";
|
||||||
|
@use "lib";
|
||||||
|
@use "font";
|
||||||
|
|
||||||
|
.launcher {
|
||||||
|
@include lib.rounded(10);
|
||||||
|
@include lib.border(scheme.$overlay0, 0.2);
|
||||||
|
@include lib.shadow;
|
||||||
|
@include lib.element-decel;
|
||||||
|
@include font.main;
|
||||||
|
|
||||||
|
background-color: scheme.$mantle;
|
||||||
|
color: scheme.$text;
|
||||||
|
padding: lib.s(10) lib.s(14);
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
@include lib.spacing;
|
||||||
|
|
||||||
|
margin-bottom: lib.s(5);
|
||||||
|
font-size: lib.s(16);
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: lib.s(18);
|
||||||
|
color: scheme.$sapphire;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
color: scheme.$subtext0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
@include lib.element-decel;
|
||||||
|
|
||||||
|
padding: lib.s(5) lib.s(10);
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
background-color: scheme.$surface0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: scheme.$surface1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pinned {
|
||||||
|
.app {
|
||||||
|
@include lib.rounded(5);
|
||||||
|
|
||||||
|
font-size: lib.s(64);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.results {
|
||||||
|
.empty {
|
||||||
|
@include lib.spacing;
|
||||||
|
|
||||||
|
color: scheme.$subtext0;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: lib.s(32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
@include lib.rounded(10);
|
||||||
|
|
||||||
|
font-size: lib.s(18);
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
@include lib.spacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: lib.s(32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
// Modules
|
// Modules
|
||||||
@use "scss/bar";
|
@use "scss/bar";
|
||||||
@use "scss/notifpopups";
|
@use "scss/notifpopups";
|
||||||
|
@use "scss/launcher";
|
||||||
|
|
||||||
* {
|
* {
|
||||||
all: unset; // Remove GTK theme styles
|
all: unset; // Remove GTK theme styles
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { Gio } from "astal";
|
import { Gio } from "astal";
|
||||||
|
import type AstalApps from "gi://AstalApps";
|
||||||
import { Apps } from "../services/apps";
|
import { Apps } from "../services/apps";
|
||||||
|
|
||||||
// Code points from https://www.github.com/lukas-w/font-logos
|
// Code points from https://www.github.com/lukas-w/font-logos
|
||||||
|
|
@ -73,10 +74,12 @@ const categoryIcons: Record<string, string> = {
|
||||||
System: "host",
|
System: "host",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAppCategoryIcon = (name: string) => {
|
export const getAppCategoryIcon = (nameOrApp: string | AstalApps.Application) => {
|
||||||
const categories =
|
const categories =
|
||||||
Gio.DesktopAppInfo.new(`${name}.desktop`)?.get_categories()?.split(";") ??
|
typeof nameOrApp === "string"
|
||||||
Apps.fuzzy_query(name)[0]?.categories;
|
? Gio.DesktopAppInfo.new(`${nameOrApp}.desktop`)?.get_categories()?.split(";") ??
|
||||||
|
Apps.fuzzy_query(nameOrApp)[0]?.categories
|
||||||
|
: nameOrApp.categories;
|
||||||
if (categories)
|
if (categories)
|
||||||
for (const [key, value] of Object.entries(categoryIcons)) if (categories.includes(key)) return value;
|
for (const [key, value] of Object.entries(categoryIcons)) if (categories.includes(key)) return value;
|
||||||
return "terminal";
|
return "terminal";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { exec, GLib } from "astal";
|
import { exec, execAsync, GLib } from "astal";
|
||||||
|
import type AstalApps from "gi://AstalApps";
|
||||||
import { osIcons } from "./icons";
|
import { osIcons } from "./icons";
|
||||||
|
|
||||||
export const inPath = (bin: string) => {
|
export const inPath = (bin: string) => {
|
||||||
|
|
@ -10,6 +11,14 @@ export const inPath = (bin: string) => {
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const launch = (app: AstalApps.Application) => {
|
||||||
|
execAsync(["uwsm", "app", "--", app.entry]).catch(() => {
|
||||||
|
app.frequency--; // Decrement frequency cause launch also increments it
|
||||||
|
app.launch();
|
||||||
|
});
|
||||||
|
app.frequency++;
|
||||||
|
};
|
||||||
|
|
||||||
export const osId = GLib.get_os_info("ID") ?? "unknown";
|
export const osId = GLib.get_os_info("ID") ?? "unknown";
|
||||||
export const osIdLike = GLib.get_os_info("ID_LIKE");
|
export const osIdLike = GLib.get_os_info("ID_LIKE");
|
||||||
export const osIcon = String.fromCodePoint(
|
export const osIcon = String.fromCodePoint(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Binding } from "astal";
|
import { Binding, property, register, timeout } from "astal";
|
||||||
import { Astal, type Widget } from "astal/gtk3";
|
import { App, Astal, Gdk, Gtk, Widget } from "astal/gtk3";
|
||||||
import AstalHyprland from "gi://AstalHyprland";
|
import AstalHyprland from "gi://AstalHyprland";
|
||||||
|
|
||||||
export const setupCustomTooltip = (self: any, text: string | Binding<string>) => {
|
export const setupCustomTooltip = (self: any, text: string | Binding<string>) => {
|
||||||
|
|
@ -46,3 +46,112 @@ export const setupCustomTooltip = (self: any, text: string | Binding<string>) =>
|
||||||
|
|
||||||
export const setupChildClickthrough = (self: any) =>
|
export const setupChildClickthrough = (self: any) =>
|
||||||
self.connect("size-allocate", () => self.get_window()?.set_child_input_shapes());
|
self.connect("size-allocate", () => self.get_window()?.set_child_input_shapes());
|
||||||
|
|
||||||
|
export enum TransitionType {
|
||||||
|
FADE = "",
|
||||||
|
SLIDE_DOWN = "margin-top: -${height}px; margin-bottom: ${height}px;",
|
||||||
|
SLIDE_UP = "margin-top: ${height}px; margin-bottom: -${height}px;",
|
||||||
|
SLIDE_RIGHT = "margin-left: -${width}px; margin-right: ${width}px;",
|
||||||
|
SLIDE_LEFT = "margin-left: ${width}px; margin-right: -${width}px;",
|
||||||
|
}
|
||||||
|
|
||||||
|
@register()
|
||||||
|
export class PopupWindow extends Widget.Window {
|
||||||
|
readonly transitionType: TransitionType;
|
||||||
|
readonly transitionDuration: number;
|
||||||
|
readonly transitionAmount: number;
|
||||||
|
|
||||||
|
readonly #content: Widget.Box;
|
||||||
|
#visible: boolean = false;
|
||||||
|
|
||||||
|
@property(Boolean)
|
||||||
|
get realVisible() {
|
||||||
|
return this.#visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
set realVisible(v: boolean) {
|
||||||
|
if (v) this.show();
|
||||||
|
else this.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
props: Widget.WindowProps & {
|
||||||
|
transitionType?: TransitionType;
|
||||||
|
transitionDuration?: number;
|
||||||
|
transitionAmount?: number;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
onKeyPressEvent,
|
||||||
|
clickThrough,
|
||||||
|
child,
|
||||||
|
halign = Gtk.Align.START,
|
||||||
|
valign = Gtk.Align.START,
|
||||||
|
transitionType = TransitionType.FADE,
|
||||||
|
transitionDuration = 200,
|
||||||
|
transitionAmount = 0.2,
|
||||||
|
...sProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
sProps.visible = false;
|
||||||
|
sProps.application = App;
|
||||||
|
sProps.namespace = `caelestia-${props.name}`;
|
||||||
|
sProps.anchor =
|
||||||
|
Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.RIGHT;
|
||||||
|
sProps.onKeyPressEvent = (self, event) => {
|
||||||
|
// Close window on escape
|
||||||
|
if (event.get_keyval()[1] === Gdk.KEY_Escape) self.hide();
|
||||||
|
|
||||||
|
return onKeyPressEvent?.(self, event);
|
||||||
|
};
|
||||||
|
super(sProps);
|
||||||
|
|
||||||
|
this.transitionType = transitionType;
|
||||||
|
this.transitionDuration = transitionDuration;
|
||||||
|
this.transitionAmount = transitionAmount;
|
||||||
|
|
||||||
|
// Wrapper box for animations
|
||||||
|
this.#content = (
|
||||||
|
<box halign={halign} valign={valign} className={`${props.name}-wrapper`}>
|
||||||
|
{clickThrough ? <eventbox>{child}</eventbox> : child}
|
||||||
|
</box>
|
||||||
|
) as Widget.Box;
|
||||||
|
this.#content.css = this.#getTransitionCss(false);
|
||||||
|
this.add(this.#content);
|
||||||
|
|
||||||
|
if (clickThrough) setupChildClickthrough(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
#getTransitionCss(visible: boolean) {
|
||||||
|
return (
|
||||||
|
`transition-duration: ${this.transitionDuration}ms;` +
|
||||||
|
(visible
|
||||||
|
? "opacity: 1;" + this.transitionType.replaceAll("${width}", "0").replaceAll("${height}", "0")
|
||||||
|
: "opacity: 0;" +
|
||||||
|
this.transitionType
|
||||||
|
.replaceAll("${width}", String(this.#content.get_preferred_width()[1] * this.transitionAmount))
|
||||||
|
.replaceAll("${height}", String(this.#content.get_preferred_height()[1] * this.transitionAmount)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
show() {
|
||||||
|
this.#visible = true;
|
||||||
|
this.notify("real-visible");
|
||||||
|
|
||||||
|
super.show();
|
||||||
|
this.#content.css = this.#getTransitionCss(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
this.#visible = false;
|
||||||
|
this.notify("real-visible");
|
||||||
|
|
||||||
|
this.#content.css = this.#getTransitionCss(false);
|
||||||
|
timeout(this.transitionDuration, () => !this.#visible && super.hide());
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle() {
|
||||||
|
if (this.#visible) this.hide();
|
||||||
|
else this.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue