diff --git a/.gitignore b/.gitignore index 397b4a76..91fcc751 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,6 @@ +node_modules/ +@types/ +build/ + +pnpm-lock.yaml *.log diff --git a/LICENSE b/LICENSE index 2b5304a0..d4fc8481 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,28 @@ -MIT License +BSD 3-Clause License -Copyright (c) 2024 João Dias +Copyright (c) 2025, João Dias (retrozinndev) -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index 2cfbcd89..08e45228 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,65 @@ # colorshell -

(previously retrozinndev/Hyprland-Dots)

- -> [!note] -> The [AGS v3](https://github.com/aylur/ags/blob/main) version is already done! You can try it out in the branch [`gtk4-ags3`](https://github.com/retrozinndev/colorshell/blob/gtk4-ags3).
-> The updated version will be merged into the main branch as soon as AGS v3 reaches a stable release(tagged release). > [!note] > My personal dotfiles are now on [retrozinndev/Hyprland-Dots](https://github.com/retrozinndev/Hyprland-Dots) -My Hyprland Desktop Shell that I love to keep improving every day! 🤩
+This is the repository for the colorshell desktop shell, made for Hyprland with [TypeScript], [GTK4], [AGS], [Gnim], and some of the [Astal] libraries. -This is the repository for my desktop shell, made with [GTK], [Astal] and [AGS] + [TypeScript].
It really took me a lot of time to make this, so please star the repo if you like it! :star: ## 🌄 Screenshots -![Kitty](repo/shots/kitty.png) -![Widgets](repo/shots/control-center-runner.png) -![Runner](repo/shots/clock-window.png) -![Browser](repo/shots/browser.png) +
+ desktop + Runner + Control Center & Center Window + Kitty + +

+ more screenshots on repo/shots +

+
## 🎨 Colors All the shell colors are dynamically generated from your wallpaper! -This is possible by using [pywal16](fork of the archived [pywal](https://github.com/dylanaraps/pywal) project), a cli tool to generate color schemes on the fly. + +This is possible by using [pywal16], a fork of the archived [pywal](https://github.com/dylanaraps/pywal) project. +It's a cli tool to generate color schemes from an image. ## 🖼️ Wallpapers -When you're at the [Installation](#%EF%B8%8F-installation) process, you can choose whether you want to install the wallpapers or not.
-If you chose to not, either define the `$WALLPAPERS` variable in your hyprland user configuration to your wallpapers folder, or create the `~/wallpapers` directory. +These are not included in the shell anymore, because the repository was getting too big in size. +So you'll have to add it in your own. -You can select any of the images inside `~/wallpapers` by pressing SUPER + W or by accessing the -Control Center and clicking in the image icon on top. +You can add more wallpapers either by adding your custom images to `~/wallpapers` or by defining `WALLPAPERS` +in your `hypr/user/environment`, pointing to the custom location. -### ℹ️ Source -None of the wallpapers available in this repo are made by me! You can find sources inside the [`WALLPAPERS.md`](/WALLPAPERS.md) file. (it took me a lot of time to make this sources list 😭) +Also, you can select any of the images inside `~/wallpapers` by pressing +SUPER + W or by accessing the Control Center and clicking in the image +icon on top. -### ✔️ What's included in this shell +## ✨ Features +
+ - Pretty Top-Bar - - Apps button(basically the "start menu", opens the full-screen app launcher) - - Workspace indicator(contains icon of last used application on each) - - Focused Client(Window) information(title, class and icon) + - Apps(basically the "start menu", opens the full-screen app launcher) + - Workspaces indicator + - Focused Client(window) information(title, class and icon) - Clock(with date) - - Media(shows only when media is being played) + - Media + - Change current player by scrolling on top of the widget or by opening the +Center Window and scrolling the player + - Only available when there's media playing - Tray(Applications running in the background) - Status (volume information, bluetooth, network and notification status) - Control Center - - Volume Controls (Microphone and Speaker) - - Volume Mixer(per-app volume) + - Sliders + - Speaker volume + - Microphone sensitivity + - Brightness amount - Pages(the thing that shows up when you click the arrow on a tile) - Bluetooth devices - Network devices - Night Light controls + - Brightness options - Tiles - Screen Recording - Bluetooth @@ -56,6 +67,9 @@ None of the wallpapers available in this repo are made by me! You can find sourc - Network(wifi needs work, i don't have wifi in my machine) - Don't Disturb(disables notification popups) - Center Window(clock, calendar + media management) +- OSD (On-screen Display) + - Brightness(when changed) + - Volume(when changed) - Notifications with support for application actions + Notification History - Localization(see [🌐 Internationalization](#-internationalization) for available languages) - Application Runner with support for plugins ([anyrun](https://github.com/anyrun-org/anyrun)-like) @@ -64,9 +78,9 @@ None of the wallpapers available in this repo are made by me! You can find sourc - Wallpapers(`#`): Search and select to change wallpaper - Media(`:`): Control playing media - Search(`?`): Search something on the internet with your default browser -- Gnome-like application list - Support for your multiple monitors -- Dynamic support for [UWSM](https://github.com/Vladimir-csp/uwsm)(dinamically enabled if current session is using UWSM) +- Dynamic support for [UWSM](https://github.com/Vladimir-csp/uwsm)(apps will use uwsm if current session is using it) +
## ⌨️ Binds You can see default bindings and usage information on the [Wiki/Usage] page! @@ -85,18 +99,59 @@ You can do so by forking this repository, translating the shell in your fork and ## ⚙️ Installation See the Installation Guide on [Wiki/Installation]. +## 🛠️ Development +This project uses `pnpm` to manage packages and running scripts. +To build the shell, run a development build or make a release build, you can use the project's integrated scripts.
+The most complicated ones have a help flag, so you can learn from there. + +### Dependencies +These are development-only dependencies(by package name on AUR): +- `aylurs-gtk-shell-git` + +Plus, you also need the packages listed in [Wiki/Dependencies]! + +### Building +In a common build, the shell's gresource(icons and sass) will be targeted to the build output directory by default. +If you want to ship it, you likely want to use the `pnpm build:release` command. +```zsh +pnpm build -d # remove the -d flag if you don't want a development build +``` +If you want to ship the build(or install it on your local machine), you'll likely prefer a release build: +(the `build:release` command targets the gresource to `$XDG_DATA_HOME/colorshell/resources.gresource` by default) +```zsh +pnpm build:release +``` +Don't forget to install the gresource to the actual target directory! Or else it'll not find the resource file and will fail +to load custom assets.
+Also, the environment variables are only actually used at runtime! It's passed as a literal string in the bash +variable format, then when the shell runs, it understands that it's an environment variable and replaces it with it's value. + +### Testing/Running the project +```zsh +pnpm dev +``` +or if you actually only want to run the current build instead of building again: +``` +pnpm start +``` + ## ❗ Issues Having issues? Please create a [new Issue] here, I'll be happy to help you out! ## 📜 License -This repo is licensed under the [MIT License], project is made and maintained by [retrozinndev](https://github.com/retrozinndev). +This repo is licensed under the [BSD 3-clause] license, project is made and maintained by [retrozinndev](https://github.com/retrozinndev). ## 🌠 Stargazers
- -[![Star History Chart](https://api.star-history.com/svg?repos=retrozinndev/colorshell&type=Date)](https://www.star-history.com/#retrozinndev/colorshell&Date) -
-

Thanks to everyone who starred my project! 💖

+ + + + + Star History Chart + + +
+

Thanks to everyone who starred my project! 💖

@@ -106,12 +161,14 @@ This repo is licensed under the [MIT License], project is made and maintained by [nushell]: https://nushell.sh [kitty]: https://sw.kovidgoyal.net/kitty [ags]: https://aylur.github.io/ags +[gnim]: https://aylur.github.io/gnim [astal]: https://aylur.github.io/astal [typescript]: https://typescriptlang.org +[gtk4]: https://www.gtk.org [gtk]: https://www.gtk.org -[mit license]: https://en.wikipedia.org/wiki/MIT_License +[bsd 3-clause]: https://en.wikipedia.org/wiki/BSD_licenses#4-clause_license_(original_%22BSD_License%22) [wiki]: https://github.com/retrozinndev/colorshell/wiki diff --git a/WALLPAPERS.md b/WALLPAPERS.md deleted file mode 100644 index d3b40169..00000000 --- a/WALLPAPERS.md +++ /dev/null @@ -1,559 +0,0 @@ -# About - -None of them are made by me. You can view and find their source -by expanding them below. - -## Bocchi The Rock! - -
- - Kessoku Band Rooftop (cropped borders) - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=1319345) - -
- -
- - Bocchi The Rock! (the wallpaper) - - - - -- Source: [Twitter/X (artist only, post was deleted)](https://x.com/mofujiro_mofum2) -
- -
- - Ryo Yamada Maid Dress - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=1363565) -
- -
- - Ryo Yamada - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=1323120) -
- -
- - Ryo Vending Machine - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=1293921) -
- -
- - Nijika Train - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=1304192) -
- -
- - Nijika Ijichi - - - - -- Source: [Wallpaper Flare](https://www.wallpaperflare.com/blonde-nijika-ijichi-bocchi-the-rock-anime-girls-sunset-glow-wallpaper-yjrwx) -
- -
- - Kita Street - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=1304193) -
- -
- - Kita-chan!! - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=1296783) -
- -
- - Kikuri Hiroi - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=1295717) -
- -
- - Kessoku Band Reunited - - - - -- Source: [Wallpaper Cave](https://wallpapercave.com/w/wp11695992) -
- -
- - Kessoku Albums - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=1316133) -
- -
- - Hitori Gotoh College Corridor - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=1302067) -
- -
- - Garden Kita - - - - -- Source: [Gruvbox Wallpapers](https://gruvbox-wallpapers.pages.dev) -
- - -## Vocaloid Wallpapers - -
- - Arch Linux Miku - - - - -- Source: [DeviantArt](https://www.deviantart.com/nesyah/art/Arch-linux-feat-Hatsune-Miku-858316759) -
- -
- - Gumi Bridge - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=593482) -
- -
- - Gumi Forest Sunlight - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=930443) -
- -
- - Miku Balloons - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=768576) -
- -
- - Miku Green Hair Glasses - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=858278) -
- -
- - Kagamine Rin Yellow Tapes - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=1292852) -
- -
- - Gumi VOCALOID - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=768096) -
- -
- - Miku Stylish with Glasses - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=1305668) -
- -
- - Miku Winter - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=1305841) -
- -
- - Vocaloid Karaoke - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=770194) -
- -
- - Miku, Rin and Luka Chibi - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=770164) -
- -
- - Miku Guitar - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=867976) -
- -
- - Miku Garden - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=1315430) -
- -
- - Miku Setup - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=672757) -
- -
- - Miku Flower Field - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=688123) -
- -
- - Miku Door - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=845583) -
- -
- - Miku Crying with Mask - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=524092) -
- -
- - Miku City Sky - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=698444) -
- -
- - Miku Bush - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=631739) -
- -
- - Hatsune Miku Birthday! - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=731810) -
- -
- - Hatsune Miku and Megurine Luka - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=1313438) -
- -
- - Gumi Ocean Sunset - - - - -- Source: [WallHaven](https://wallhaven.cc/w/we8pgx) -
- -
- - Gumi Street Bike - - - - -- Source: [WallHaven](https://wallhaven.cc/w/4x7e7o) -
- -
- - Inabakumori Kaai Yuki - - - - -- Source: [WallHaven](https://wallhaven.cc/w/wed3m7) -
- -
- - Inabakumori Osage - - - - -- Source: [WallHaven](https://wallhaven.cc/w/o3r8z9) -
- - -## Frieren: Beyond Journey's End -
- - Frieren Underwater - - - - -- Source: [Pixiv](https://www.pixiv.net/en/artworks/114234634) -
- -
- - Frieren Rain - - - - -- Source: [Pixiv](https://www.pixiv.net/en/artworks/114234634) -
- -
- - Frieren At The Funeral - - - - -- Source: [Pixiv](https://www.pixiv.net/en/artworks/114234634) -
- -
- - Frieren Sunset - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=1354394) -
- -
- - Frieren Sending Kiss - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=1344010) -
- -
- - Frieren Ring - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=1351964) -
- -
- - Frieren Night Film - - - - -- Source: [Wallpaper Flare](https://www.wallpaperflare.com/anime-anime-girls-sousou-no-frieren-wallpaper-yvcxe) -
- -
- - Frieren Blue - - - - -- Source: [Alpha Coders](https://wall.alphacoders.com/big.php?i=1357998) -
- - -## Oshi no Ko -
- - Oshi no Ko Kana Arima - - - - -- Source: [WallHaven](https://wallhaven.cc/w/x6pp5z) -
- - -## Gruvbox-styled -
- - Balcony Girl - - - - -- Source: [Gruvbox Wallpapers](https://gruvbox-wallpapers.pages.dev) -
- -
- - Gruvbox Girl - - - - -- Source: [Gruvbox Wallpapers](https://gruvbox-wallpapers.pages.dev) -
- -- [Gruvbox Wallpapers](https://gruvbox-wallpapers.pages.dev) - - -## Genshin Impact Wallpaper(s) -Those can be get on web events in Genshin Impact, and also on [HoYoLAB](https://hoyolab.com). -
- - Mualani!! - - - - -- Source: Genshin Impact Web Event (not available anymore) -
- - -## Others -
- - Hypr-chan - - - - -- Source: [GitHub (hyprwm/Hyprland)](https://github.com/hyprwm/Hyprland) -
- -
- - Linux Anime Girl - - - - -- Source: [WallHere](https://wallhere.com/en/wallpaper/2284648) -
- -### More sources -- [Pinterest](https://pinterest.com) -- [AlphaCoders](https://alphacoders.com/bocchi-the-rock!-wallpapers) -- [WallpaperCave](https://wallpapercave.com/bocchi-the-rock-wallpapers) -- [WallpaperFlare](https://www.wallpaperflare.com/search?wallpaper=BOCCHI+THE+ROCK%21) diff --git a/ags/.gitignore b/ags/.gitignore deleted file mode 100644 index 298eb4de..00000000 --- a/ags/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -@girs/ diff --git a/ags/app.ts b/ags/app.ts deleted file mode 100644 index 7df09e16..00000000 --- a/ags/app.ts +++ /dev/null @@ -1,113 +0,0 @@ -import AstalNotifd from "gi://AstalNotifd"; - -import { App } from "astal/gtk3" -import { Wireplumber } from "./scripts/volume"; - -import { handleArguments } from "./scripts/arg-handler"; -import { Time, timeout } from "astal/time"; - -import { OSDModes, setOSDMode } from "./window/OSD"; - -import { Runner } from "./runner/Runner"; -import { PluginApps } from "./runner/plugins/apps"; -import { PluginShell } from "./runner/plugins/shell"; -import { PluginWebSearch } from "./runner/plugins/websearch"; -import { PluginMedia } from "./runner/plugins/media"; -import { Windows } from "./windows"; -import { Notifications } from "./scripts/notifications"; -import { GObject } from "astal"; -import { PluginWallpapers } from "./runner/plugins/wallpapers"; -import { Wallpaper } from "./scripts/wallpaper"; -import { Stylesheet } from "./scripts/stylesheet"; -import { Clipboard } from "./scripts/clipboard"; -import { PluginClipboard } from "./runner/plugins/clipboard"; -import { Config } from "./scripts/config"; - - -let osdTimer: (Time|undefined); -let connections = new Map | number)>(); - -const defaultWindows: Array = [ "bar" ]; -const runnerPlugins: Array = [ - PluginApps, - PluginShell, - PluginWebSearch, - PluginMedia, - new PluginWallpapers(), - PluginClipboard -]; - -App.start({ - instanceName: "astal", - icons: "icons/", - requestHandler: (request: string, response: (result: any) => void): void => { - response(handleArguments(request)); - }, - main: (..._args: Array) => { - console.log(`Initialized astal instance as: ${ App.instanceName || "astal" }`); - - console.log("Config: initializing configuration file"); - Config.getDefault(); - - Stylesheet.getDefault().compileApply(); - - App.vfunc_dispose = () => { - console.log("Disconnecting stuff"); - connections.forEach((v, k) => Array.isArray(v) ? - v.map(id => k.disconnect(id)) - : k.disconnect(v)); - }; - - // Init clipboard module - Clipboard.getDefault(); - - connections.set(Wireplumber.getDefault(), [ - Wireplumber.getDefault().getDefaultSink().connect("notify::volume", () => - triggerOSD(OSDModes.SINK)) - ]); - - connections.set(Notifications.getDefault(), [ - Notifications.getDefault().connect("notification-added", (_, _notif: AstalNotifd.Notification) => { - Windows.open("floating-notifications"); - }), - Notifications.getDefault().connect("notification-removed", (_: Notifications, _id: number) => { - _.notifications.length === 0 && Windows.close("floating-notifications"); - }) - ]); - - console.log("Initializing wallpaper handler"); - Wallpaper.getDefault(); - - console.log("Adding runner plugins"); - runnerPlugins.map(plugin => Runner.addPlugin(plugin)); - - console.log("Opening default windows"); - // Open openOnStart windows - defaultWindows.map(name => { - if(Windows.isVisible(name)) return; - Windows.open(name); - }); - } -}); - -function triggerOSD(osdModeParam: OSDModes) { - if(Windows.isVisible("control-center")) return; - - Windows.open("osd"); - - if(!osdTimer) { - setOSDMode(osdModeParam); - osdTimer = timeout(3000, () => { - osdTimer = undefined; - Windows.close("osd"); - }); - - return; - } - - osdTimer.cancel(); - osdTimer = timeout(3000, () => { - Windows.close("osd"); - osdTimer = undefined; - }); -} diff --git a/ags/package-lock.json b/ags/package-lock.json deleted file mode 100644 index a31381cb..00000000 --- a/ags/package-lock.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "colorshell", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "colorshell", - "dependencies": { - "astal": "/usr/share/astal/gjs" - } - }, - "../../../../usr/share/astal/gjs": { - "name": "astal", - "license": "LGPL-2.1" - }, - "node_modules/astal": { - "resolved": "../../../../usr/share/astal/gjs", - "link": true - } - } -} diff --git a/ags/package.json b/ags/package.json deleted file mode 100644 index 02db1760..00000000 --- a/ags/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "colorshell", - "dependencies": { - "astal": "/usr/share/astal/gjs" - } -} diff --git a/ags/runner/Runner.ts b/ags/runner/Runner.ts deleted file mode 100644 index 92cad324..00000000 --- a/ags/runner/Runner.ts +++ /dev/null @@ -1,315 +0,0 @@ -import { AstalIO, GObject, timeout } from "astal"; -import { Astal, Gdk, Gtk, Widget } from "astal/gtk3"; -import { PopupWindow, PopupWindowProps } from "../widget/PopupWindow"; -import { updateApps } from "../scripts/apps"; -import { ResultWidget, ResultWidgetProps } from "../widget/runner/ResultWidget"; -import { Windows } from "../windows"; -import AstalHyprland from "gi://AstalHyprland"; - -export namespace Runner { -export type RunnerProps = { - halign?: Gtk.Align; - valign?: Gtk.Align; - width?: number; - height?: number; - entryPlaceHolder?: string; - initialText?: string; - resultsLimit?: number; - showResultsPlaceHolderOnStartup?: boolean; -}; - -export interface Plugin { - /** prefix to call the plugin. if undefined, will be triggered like applications plugin */ - readonly prefix?: string; - /** name of the plugin. e.g.: websearch, shell */ - readonly name?: string; - /** ran on runner open */ - readonly init?: () => void; - /** handle the user input to return results (does not include plugin's prefix) */ - readonly handle: (inputText: string) => (ResultWidget|Array|null|undefined); - /** ran on runner close */ - readonly onClose?: () => void; - /** hide other plugins when using this plugin */ - prioritize?: boolean; -} - -export let instance: (Widget.Window|null) = null; -let gtkEntry: (Widget.Entry|null) = null; -const plugins = new Set(); - -export function close() { instance?.close(); } - -export function regExMatch(search: string, item: (string|number)): boolean { - search = search.replace(/[\\^$.*?()[\]{}|]/g, "\\$&"); - - if(typeof item === "number") - return new RegExp(`${search.split('').map(c => - `${c}`).join('')}`, - "g").test(item.toString()); - - return new RegExp(`${search.split('').map(c => - `${c}`).join('')}`, - "gi").test(item); -} - - -export function addPlugin(plugin: Runner.Plugin, force?: boolean) { - if(!force && plugin.prefix && plugins.has(plugin)) - throw new Error(`Runner plugin with prefix ${plugin.prefix} already exists`); - - plugins.delete(plugin); - plugins.add(plugin); -} - -export function getPlugins(): Array { - return [...plugins.values()]; -} - -/** Removes a plugin from the runner plugins list - * @returns true if plugin was removed or false if plugin wasn't found - */ -export function removePlugin(plugin: Plugin): boolean { - return plugins.delete(plugin); -} - -export function setEntryText(text: string): void { - gtkEntry?.set_text(text); - gtkEntry?.set_position(gtkEntry.textLength); - - gtkEntry?.grab_focus_without_selecting(); -} - -export function openDefault(initialText?: string) { - return Runner.openRunner({ - entryPlaceHolder: "Start typing...", - initialText, - resultsLimit: 24 - } as Runner.RunnerProps, - () => [ - new ResultWidget({ - icon: "application-x-executable-symbolic", - title: "Use your applications", - description: "Search for any app installed in your computer", - closeOnClick: false, - onClick: () => gtkEntry?.grab_focus() - } as ResultWidgetProps), - new ResultWidget({ - icon: "edit-paste-symbolic", - title: "See your clipboard history", - description: "Start your search with '>' to go through your clipboard history", - closeOnClick: false, - onClick: () => setEntryText('>') - } as ResultWidgetProps), - new ResultWidget({ - icon: "image-x-generic-symbolic", - title: "Change your wallpaper", - description: "Add '#' at the start to search through the wallpapers folder!", - closeOnClick: false, - onClick: () => setEntryText('#'), - } as ResultWidgetProps), - new ResultWidget({ - icon: "utilities-terminal-symbolic", - title: "Run shell commands", - description: "Add '!' before your command to run it (pro tip: add a second '!' to show command output)", - closeOnClick: false, - onClick: () => setEntryText('!!') - } as ResultWidgetProps), - new ResultWidget({ - icon: "media-playback-start-symbolic", - title: "Control media", - description: "Type ':' to control playing media", - closeOnClick: false, - onClick: () => setEntryText(':') - } as ResultWidgetProps), - new ResultWidget({ - icon: "applications-internet-symbolic", - title: "Search the Web", - description: "Start typing with '?' prefix to search the web", - closeOnClick: false, - onClick: () => setEntryText('?') - } as ResultWidgetProps) - ]); -} - -export function openRunner(props: RunnerProps, placeholder?: () => Array): Widget.Window { - let onClickTimeout: (AstalIO.Time|undefined); - const connections: Map = new Map(); - - props.width ??= 780; - props.height ??= 420; - - gtkEntry = new Widget.Entry({ - className: "search", - placeholderText: props?.entryPlaceHolder || "", - onChanged: async (self) => { - updateResultsList(self.text); - resultsList.get_row_at_index(0) && - resultsList.select_row(resultsList.get_row_at_index(0)); - - if(self.text.trim().length < 1 && !mainBox.get_style_context().has_class("empty-input")) { - mainBox.get_style_context().add_class("empty-input"); - return; - } - - mainBox.get_style_context().has_class("empty-input") && - mainBox.get_style_context().remove_class("empty-input"); - }, - onActivate: (entry) => { - const resultWidget = resultsList.get_selected_row()?.get_child(); - if(resultWidget instanceof ResultWidget) { - entry.isFocus = false; - resultWidget.onClick(); - resultWidget.closeOnClick && Runner.close(); - } - }, - primary_icon_name: "system-search" - } as Widget.EntryProps); - - const mainBox = new Widget.Box({ - className: `runner main ${props.showResultsPlaceHolderOnStartup ? "empty" : ""}`, - orientation: Gtk.Orientation.VERTICAL, - hexpand: true, - valign: Gtk.Align.START, - children: [ - gtkEntry, - new Widget.Scrollable({ - className: "results-scrollable", - vscroll: Gtk.PolicyType.AUTOMATIC, - hscroll: Gtk.PolicyType.NEVER, - expand: true, - visible: props.showResultsPlaceHolderOnStartup ?? false, - propagateNaturalHeight: true, - maxContentHeight: props.height, - child: new Gtk.ListBox({ - visible: true, - expand: true, - } as Gtk.ListBox.ConstructorProps) - }) - ] - } as Widget.BoxProps); - - const scrollable = mainBox.get_children()[1] as Widget.Scrollable; - const resultsList = scrollable.get_child() as Gtk.ListBox; - - if(props?.showResultsPlaceHolderOnStartup && placeholder) { - const placeholderWidgets = placeholder(); - placeholderWidgets.map(widget => - resultsList.insert(widget, -1)); - } - - function cleanResults() { - resultsList.get_children().map((listItem) => { - resultsList.remove(listItem); - }); - } - - function getPluginResults(input: string): Array { - let calledPlugins: Array = getPlugins().filter((plugin) => - plugin.prefix ? (input.startsWith(plugin.prefix) ? true : false) : true - ).sort((plugin) => plugin.prefix != null ? 0 : 1); - - for(const plugin of calledPlugins) { - if(plugin.prioritize) { - calledPlugins = [ plugin ]; - break; - } - } - - const results = calledPlugins.map(plugin => plugin.handle( - plugin.prefix ? input.replace(plugin.prefix, "") : input) - ).filter(value => value !== undefined && value !== null).flat(1); - - return props?.resultsLimit != null && - props.resultsLimit !== Infinity ? - results.splice(0, props.resultsLimit) - : results; - } - - function updateResultsList(entryText: string) { - const widgets: Array = []; - - // Remove all previous results - cleanResults(); - - widgets.push(...getPluginResults(entryText)) - - // Insert placeholder if there are no results - if(placeholder && widgets.length === 0) - widgets.push(...placeholder()); - - // Insert results inside GtkListBox - widgets.map((resultWidget: ResultWidget) => { - resultsList.insert(resultWidget, -1); - - const conns: Array = []; - - conns.push( - resultsList.connect("row-activated", (_, row: Gtk.ListBoxRow) => { - const rWidget = row.get_child(); - if(rWidget instanceof ResultWidget) { - if(onClickTimeout) return; - - // Timeout, so it doesn't fire the event a hundred times :skull: - onClickTimeout = timeout(500, () => onClickTimeout = undefined); - rWidget.onClick(); - rWidget.closeOnClick && Runner.close(); - } - }), - resultsList.connect("destroy", () => - conns.forEach((id) => resultsList.disconnect(id)) - ) - ); - }); - - widgets.length > 0 ? - (!scrollable.visible && scrollable.show()) - : scrollable.hide(); - } - - if(!instance) - instance = Windows.createWindowForFocusedMonitor((mon: number): (Widget.Window) => PopupWindow({ - namespace: "runner", - monitor: mon, - widthRequest: props.width, - heightRequest: props.height, - marginTop: (AstalHyprland.get_default().get_monitor(mon)?.height / 2) - (props.height! / 2), - exclusivity: Astal.Exclusivity.IGNORE, - halign: Gtk.Align.CENTER, - valign: Gtk.Align.START, - setup: () => { - // Init plugins - plugins.forEach(plugin => plugin.init && plugin.init()); - - if(props?.initialText) - Runner.setEntryText(props.initialText); - }, - onKeyPressEvent: (_, event: Gdk.Event) => { - const keyVal = event.get_keyval()[1]; - - if(!gtkEntry!.has_focus && keyVal !== Gdk.KEY_F5 - && keyVal !== Gdk.KEY_Down && keyVal !== Gdk.KEY_Up - && keyVal !== Gdk.KEY_Return) { - gtkEntry!.grab_focus_without_selecting(); - return; - } - - event.get_keyval()[1] === Gdk.KEY_F5 && - updateApps(); - }, - onDestroy: () => { - connections.forEach((id, obj) => GObject.signal_handler_is_connected(obj, id) && - obj.disconnect(id)); - - gtkEntry = null; - - [...plugins.values()].forEach(plugin => - plugin && plugin.onClose && plugin.onClose()); - - instance = null; - }, - child: mainBox - } as PopupWindowProps))(); - - return instance!; -} -} diff --git a/ags/runner/plugins/apps.ts b/ags/runner/plugins/apps.ts deleted file mode 100644 index ab423e0e..00000000 --- a/ags/runner/plugins/apps.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { ResultWidget, ResultWidgetProps } from "../../widget/runner/ResultWidget"; -import AstalApps from "gi://AstalApps"; -import { execApp, getAstalApps, updateApps } from "../../scripts/apps"; -import { Runner } from "../Runner"; -import { Astal } from "astal/gtk3"; - -export const PluginApps = { - // Do not provide prefix, so it always runs. - name: "Apps", - // asynchronously-refresh apps list on init - init: async () => updateApps(), - handle: (text: string) => { - return getAstalApps().fuzzy_query(text).map((app: AstalApps.Application) => - new ResultWidget({ - title: app.get_name(), - description: app.get_description(), - icon: Astal.Icon.lookup_icon(app.iconName) ? app.iconName : "application-x-executable-symbolic", - onClick: () => execApp(app) - } as ResultWidgetProps) - ); - } -} as Runner.Plugin; diff --git a/ags/runner/plugins/clipboard.ts b/ags/runner/plugins/clipboard.ts deleted file mode 100644 index 0e105821..00000000 --- a/ags/runner/plugins/clipboard.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Widget } from "astal/gtk3"; -import { Clipboard } from "../../scripts/clipboard"; -import { ResultWidget, ResultWidgetProps } from "../../widget/runner/ResultWidget"; -import { Runner } from "../Runner"; -import { Gio } from "astal"; - - -export const PluginClipboard = { - prefix: '>', - prioritize: true, - handle: (search) => { - if(Clipboard.getDefault().history.length < 1) - return new ResultWidget({ - icon: "edit-paste-symbolic", - title: "Clipboard is empty", - description: "Copy something and it will be shown right here!" - } as ResultWidgetProps); - - return Clipboard.getDefault().history.filter(item => // not the best way to search, but it works - Runner.regExMatch(search, item.id) || Runner.regExMatch(search, item.preview)).map((item) => - new ResultWidget({ - icon: new Widget.Label({ - label: item.id.toString(), - css: "font-size: 16px; margin-right: 8px; font-weight: 600;" - } as Widget.LabelProps), - title: item.preview, - onClick: () => Clipboard.getDefault().selectItem(item).catch((err: Gio.IOErrorEnum) => { - console.error(`Runner(Plugin/Clipboard): An error occurred while selecting clipboard item. Stderr:\n${ - err.message ? `${err.message}\n` : ""}Stack: ${err.stack}`); - }) - } as ResultWidgetProps)); - } -} as Runner.Plugin; diff --git a/ags/runner/plugins/media.ts b/ags/runner/plugins/media.ts deleted file mode 100644 index 54ef694b..00000000 --- a/ags/runner/plugins/media.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { bind, Variable } from "astal"; -import { ResultWidget, ResultWidgetProps } from "../../widget/runner/ResultWidget"; -import { Runner } from "../Runner"; -import AstalMpris from "gi://AstalMpris"; - -export const PluginMedia = (() => { - let playTitle: Variable|null; - let previousTitle: Variable|null; - let nextTitle: Variable|null; - - return { - prefix: ":", - - onClose: () => { - playTitle?.drop(); - previousTitle?.drop(); - nextTitle?.drop(); - - previousTitle = null; - playTitle = null; - nextTitle = null; - }, - - handle() { - const player = AstalMpris.get_default().players[0]; - - playTitle = Variable.derive([ - bind(player, "title"), - bind(player, "artist"), - bind(player, "playbackStatus") - ], (title, artist, status) => `${ status === AstalMpris.PlaybackStatus.PLAYING ? - "Pause" : "Play" - } ${title} | ${artist}`); - - previousTitle = Variable.derive([ - bind(player, "title"), - bind(player, "artist") - ], (title, artist) => - `Go Previous ${ title ? title : player.busName }${ artist ? ` | ${artist}` : "" }` - ); - - nextTitle = Variable.derive([ - bind(player, "title"), - bind(player, "artist") - ], (title, artist) => - `Go Next ${ title ? title : player.busName }${ artist ? ` | ${artist}` : "" }` - ); - - if(!player) return new ResultWidget({ - icon: "folder-music-symbolic", - title: "Couldn't find any players", - closeOnClick: false, - description: "No media / player found with mpris" - } as ResultWidgetProps); - return [ - new ResultWidget({ - icon: bind(player, "playbackStatus").as((status) => status === AstalMpris.PlaybackStatus.PLAYING ? - "media-playback-pause-symbolic" - : "media-playback-start-symbolic"), - closeOnClick: false, - title: playTitle(), - onClick: () => player && player.play_pause() - } as ResultWidgetProps), - new ResultWidget({ - icon: "media-skip-backward-symbolic", - closeOnClick: false, - title: previousTitle(), - onClick: () => player && player.canGoPrevious && player.previous() - } as ResultWidgetProps), - new ResultWidget({ - icon: "media-skip-forward-symbolic", - closeOnClick: false, - title: nextTitle(), - onClick: () => player && player.canGoNext && player.next() - } as ResultWidgetProps) - ] - }, - } as Runner.Plugin -})(); diff --git a/ags/runner/plugins/websearch.ts b/ags/runner/plugins/websearch.ts deleted file mode 100644 index 9c1cc3f8..00000000 --- a/ags/runner/plugins/websearch.ts +++ /dev/null @@ -1,29 +0,0 @@ -import AstalHyprland from "gi://AstalHyprland"; -import { ResultWidget, ResultWidgetProps } from "../../widget/runner/ResultWidget"; -import { Runner } from "../Runner"; - -const searchEngines = { - duckduckgo: "https://duckduckgo.com/?q=", - google: "https://google.com/search?q=", - yahoo: "https://search.yahoo.com/search?p=" -}; - -let engine: string = searchEngines.google; - -export const PluginWebSearch = { - prefix: '?', - name: "Web Search", - prioritize: true, - - handle: (search: string): ResultWidget => { - return new ResultWidget({ - icon: "system-search-symbolic", - title: search || "Type your search...", - description: `Search the Web`, - onClick: () => AstalHyprland.get_default().dispatch( - "exec", - `xdg-open \"${engine + search}\"` - ) - } as ResultWidgetProps); - } -} as Runner.Plugin; diff --git a/ags/scripts/arg-handler.ts b/ags/scripts/arg-handler.ts deleted file mode 100644 index e1a0b0e8..00000000 --- a/ags/scripts/arg-handler.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { Wireplumber } from "./volume"; -import { Windows } from "../windows"; - -import { restartInstance } from "./reload-handler"; -import { AstalIO, timeout } from "astal"; -import { Runner } from "../runner/Runner"; -import { showWorkspaceNumber } from "../widget/bar/Workspaces"; - -let wsTimeout: (AstalIO.Time|undefined); - -export function handleArguments(request: string): any { - const args: Array = request.split(" "); - switch(args[0]) { - case "open": - case "close": - case "toggle": - return handleWindowArgs(args); - - case "help": - case "h": - return getHelp(); - - case "volume": - return handleVolumeArgs(args); - - case "reload": - restartInstance(); - return "Restarting instance..." - - case "windows": - return Object.keys(Windows.windows).map(name => - `${name}: ${Windows.isVisible(name) ? "open" : "closed" }`).join('\n'); - - case "runner": - !Runner.instance ? - Runner.openDefault(args[1] || undefined) - : Runner.close(); - return "Opening runner..." - - case "peek-workspace-num": - if(wsTimeout) return "Workspace numbers are already showing"; - - showWorkspaceNumber(true); - wsTimeout = timeout(2200, () => { - showWorkspaceNumber(false); - wsTimeout = undefined; - }); - return "Toggled workspace numbers"; - - default: - return "command not found! try checking help"; - } -} - -// Didn't want to bloat the switch statement, so I just separated it into functions -function handleWindowArgs(args: Array): string { - if(!args[1]) - return "Window argument not specified!"; - - const specifiedWindow: string = args[1]; - - if(!Windows.hasWindow(specifiedWindow)) - return `Name "${specifiedWindow}" not found windows map! Make sure to add new Windows on the Map!` - - switch(args[0]) { - case "open": - if(!Windows.isVisible(specifiedWindow)) { - Windows.open(specifiedWindow); - return `Setting visibility of window "${args[1]}" to true`; - } - - return `Window is already open, ignored`; - - case "close": - if(Windows.isVisible(specifiedWindow)) { - Windows.close(specifiedWindow); - return `Setting visibility of window "${args[1]}" to false` - } - - return `Window is already closed, ignored` - - case "toggle": - if(!Windows.isVisible(specifiedWindow)) { - Windows.open(specifiedWindow); - return `Toggle opening window "${args[1]}"`; - } - - Windows.close(specifiedWindow); - return `Toggle closing window "${args[1]}"` - } - - return "Couldn't handle window management arguments" -} - -function handleVolumeArgs(args: Array) { - if(!args[1]) - return `Please specify what you want to do!\n\n${volumeHelp()}` - - if(/^(sink|source)(\-increase|\-decrease|\-set)$/.test(args[1]) && !args[2]) - return `You forgot to add a value to be set!`; - - if(Number.isNaN(Number.parseFloat(args[2])) && Number.isSafeInteger(Number.parseFloat(args[2]))) - return `Argument "${args[2]} is not a valid number! Please use integers"`; - - const command: Array = args[1].split('-'); - - if(/help/.test(args[1])) - return volumeHelp(); - - switch(command[1]) { - case "set": - command[0] === "sink" ? - Wireplumber.getDefault().setSinkVolume(Number.parseInt(args[2])) - : - Wireplumber.getDefault().setSourceVolume(Number.parseInt(args[2])) - return `Done! Set ${command[0]} volume to ${args[2]}`; - - case "mute": - command[0] === "sink" ? - Wireplumber.getDefault().toggleMuteSink() - : - Wireplumber.getDefault().toggleMuteSource() - return `Done toggling mute!`; - - case "increase": - command[0] === "sink" ? - Wireplumber.getDefault().increaseSinkVolume(Number.parseInt(args[2])) - : - Wireplumber.getDefault().increaseSourceVolume(Number.parseInt(args[2])) - - return `Done increasing volume by ${args[2]}`; - - case "decrease": - command[0] === "sink" ? - Wireplumber.getDefault().decreaseSinkVolume(Number.parseInt(args[2])) - : - Wireplumber.getDefault().decreaseSourceVolume(Number.parseInt(args[2])) - - return `Done decreasing volume to ${args[2]}`; - } - - return `Couldn't resolve arguments! "${args.join(' ').replace(new RegExp(`^${args[0]}`), "")}"`; - - function volumeHelp(): string { - return ` -Control speaker and microphone volumes easily! -Options: - (sink|source)-set [number]: set speaker/microphone volume. - (sink|source)-mute: toggle mute for the speaker/microphone device. - (sink|source)-increase [number]: increases speaker/microphone volume. - (sink|source)-decrease [number]: decreases speaker/microphone volume. -`.trim(); - } -} - -function getHelp(): string { - return `Manage Astal Windows and do more stuff. From - retrozinndev's Hyprland Dots, using Astal and AGS by Aylur. - - Window and Audio options: - open [window]: opens the specified window. - close [window]: closes all instances of specified window. - toggle [window]: toggle-open/close the specified window. - windows: list shell windows. - reload: quit this instance and start a new one. - volume: speaker and microphone volume controller, see "volume help". - h, help: shows this help message. - - Other options: - runner [initial_text]: open the application runner, optionally add an initial search. - peek-workspace-num: peek the workspace numbers on bar window. - - 2025 (c) retrozinndev's Hyprland-Dots, licensed under the MIT License. - https://github.com/retrozinndev/Hyprland-Dots - `.split('\n').map(l => l.replace(/^ {8}/, "")).join('\n'); -} diff --git a/ags/scripts/auth.ts b/ags/scripts/auth.ts deleted file mode 100644 index 550a2469..00000000 --- a/ags/scripts/auth.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { execAsync, Gio, GLib, register } from "astal"; -import Polkit from "gi://Polkit"; -import PolkitAgent from "gi://PolkitAgent"; -import { EntryPopup, EntryPopupProps } from "../widget/EntryPopup"; -import AstalAuth from "gi://AstalAuth"; -import { AskPopup, AskPopupProps } from "../widget/AskPopup"; - -export { Auth }; - -@register({ GTypeName: "AuthAgent" }) -class Auth extends PolkitAgent.Listener { - private static instance: Auth; - #subject: Polkit.Subject; - - constructor() { - super(); - this.#subject = Polkit.UnixSession.new(GLib.get_user_name()); - - this.register(PolkitAgent.RegisterFlags.NONE, - this.#subject, - "/io/github/retrozinndev/Colorshell/PolicyKit/AuthAgent", - null - ); - } - - vfunc_dispose() { - PolkitAgent.Listener.unregister(); - } - - static initiate_authentication(action_id: string, message: string, icon_name: string, details: Polkit.Details, cookie: string, identities: Array, cancellable?: Gio.Cancellable, callback?: Gio.AsyncReadyCallback): void | Promise { - const authPopup = EntryPopup({ - title: "Authentication", - text: message, - isPassword: true, - onFinish: callback, - onCancel: () => cancellable?.cancel(), - closeOnAccept: false, - onAccept: (input: string) => { - if(this.validatePasswd(input)) { - authPopup.close(); - } - AskPopup({ - - } as AskPopupProps) - } - } as EntryPopupProps); - } - - - public static initAgent(): Auth { - if(!this.instance) - this.instance = new Auth(); - - return this.instance; - } - - private static validatePasswd(passwd: string): boolean { - return AstalAuth.Pam.authenticate(passwd, null); - } - - /** @returns if successful, true, or else, false */ - public async polkitExecute(cmd: string | Array): Promise { - let success: boolean = true; - await execAsync([ "pkexec", "--", ...(Array.isArray(cmd) ? - cmd as Array : [ cmd as string ]) ] - ).catch((r) => { - success = false; - console.error(`Polkit: Couldn't authenticate. Stderr: ${r}`); - }); - - return success; - } -} diff --git a/ags/scripts/brightness.ts b/ags/scripts/brightness.ts deleted file mode 100644 index b98067df..00000000 --- a/ags/scripts/brightness.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { exec, execAsync, GObject, monitorFile, readFileAsync, register, signal } from "astal"; -import { Connectable } from "astal/binding"; - - -/** !!TODO!! Needs more work and testing - * I(retrozinndev) don't have a monitor that has software-controlled brightness :( - */ -@register({ GTypeName: "Brightness" }) -class Brightness extends GObject.Object implements Connectable { - private readonly backlight: string|undefined; - private max: number; - private brightness: number; - - @signal(Number) - declare brightnessChanged: (value: number) => void; - - constructor(backlightDevice?: string) { - super(); - this.backlight = backlightDevice || "intel_backlight"; - this.max = Number.parseInt(exec(`brightnessctl -d ${backlightDevice} max`)) - this.brightness = Number.parseInt(exec(`brightnessctl -d ${backlightDevice} get`)) - - readFileAsync(`/sys/class/backlight/${backlightDevice}/brightness`).catch(() => { - throw new Error(`Couldn't find backlight ${backlightDevice}`); - }); - - monitorFile(`/sys/class/backlight/${backlightDevice}/brightness`, async () => { - this.brightness = Number.parseInt(await execAsync(`brightnessctl -d ${backlightDevice} get`)); - this.max = Number.parseInt(await execAsync(`brightnessctl -d ${backlightDevice} max`)); - - this.emit("brightness-changed", this.brightness); - }); - } - - public setBrightness(newBrightness: number): void { - execAsync(`brightnessctl -d ${this.backlight} set ${newBrightness || this.brightness}`).catch(() => { - throw new Error(`Couldn't set brightness of backlight ${this.backlight}`); - }); - - this.emit("brightness-changed", newBrightness); - } -} diff --git a/ags/scripts/config.ts b/ags/scripts/config.ts deleted file mode 100644 index 1d897ac5..00000000 --- a/ags/scripts/config.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { AstalIO, Gio, GLib, GObject, monitorFile, readFileAsync, register, timeout } from "astal"; -import Binding, { bind, Subscribable } from "astal/binding"; -import { Notifications } from "./notifications"; -import AstalNotifd from "gi://AstalNotifd"; -import { encoder } from "./utils"; - - -export { Config }; - -export type ConfigEntries = Partial<{ - workspaces: Partial<{ - /** this is the function that shows the Workspace's IDs - * around the current workspace if one breaks the crescent order. - * It basically helps keyboard navigation between workspaces. - * --- - * Example: 1(empty, current, shows ID), 2(empty, does not appear(makes - * the previous not to be in a crescent order)), 3(not empty, shows ID) */ - enable_helper: boolean; - /** breaks `enable_helper`, makes all workspaces show their respective ID - * by default */ - always_show_id: boolean; - }>; - - clock: Partial<{ - /** use the same formats as gnu's `date` command */ - date_format: string; - }>; - - notifications: Partial<{ - timeout_low: number; - timeout_normal: number; - timeout_critical: number; - }>; - - night_light: Partial<{ - /** whether to save night light values to disk */ - save_on_shutdown: boolean; - }>; -}>; - -type ValueTypes = "string" | "boolean" | "object" | "number" | "undefined" | "any"; - - -@register({ GTypeName: "Config" }) -class Config extends GObject.Object implements Subscribable { - private static instance: Config; - - private readonly defaultFile = Gio.File.new_for_path( - `${GLib.get_user_config_dir()}/colorshell/config.json`); - - /** unmodified object with default entries. User-values are stored - * in the `entries` field */ - public readonly defaults: ConfigEntries = { - notifications: { - timeout_low: 4000, - timeout_normal: 6000, - timeout_critical: 0 - }, - - night_light: { - save_on_shutdown: true - }, - - workspaces: { - always_show_id: false, - enable_helper: true - }, - - clock: { - date_format: "%A %d, %H:%M" - } - }; - - private readonly entries: ConfigEntries = this.defaults; - - - #subs: Set<(entries: ConfigEntries) => void> = new Set(); - #file: Gio.File; - private timeout: (AstalIO.Time|boolean|undefined); - public get file() { return this.#file; }; - - constructor(filePath?: (Gio.File|string)) { - super(); - - this.#file = (typeof filePath === "string") ? - Gio.File.new_for_path(filePath) - : (filePath ?? this.defaultFile); - - if(!this.#file.query_exists(null)) { - this.#file.make_directory_with_parents(null); - this.#file.delete(null); - - this.#file.create_readwrite_async( - Gio.FileCreateFlags.NONE, GLib.PRIORITY_DEFAULT, - null, (_, asyncRes) => { - const ioStream = this.#file.create_readwrite_finish(asyncRes); - - ioStream.outputStream.write_bytes_async( - GLib.Bytes.new(encoder.encode(JSON.stringify(this.entries, undefined, 4))), - GLib.PRIORITY_DEFAULT, null, - (_, asyncRes) => { - const writtenBytes = ioStream.outputStream.write_bytes_finish(asyncRes); - - if(!writtenBytes) - Notifications.getDefault().sendNotification({ - appName: "colorshell", - summary: "Write error", - body: `Couldn't write default configuration file to "${this.#file.get_path()!}"` - }); - } - ); - }); - } - - monitorFile(this.#file.get_path()!, - () => { - if(this.timeout) return; - this.timeout = timeout(500, () => this.timeout = undefined); - - if(this.#file.query_exists(null)) { - this.timeout?.cancel(); - this.timeout = true; - - this.readFile().finally(() => - this.timeout = undefined); - - return; - } - - Notifications.getDefault().sendNotification({ - appName: "colorshell", - summary: "Config error", - body: `Could not hot-reload configuration: config file not found in \`${this.#file.get_path()!}\`, last valid configuration is being used. Maybe it got deleted?` - }); - } - ); - } - - public static getDefault(): Config { - if(!this.instance) - this.instance = new Config(); - - return this.instance; - } - - private async readFile(): Promise { - await readFileAsync(this.#file.get_path()!).then((content) => { - let config: (ConfigEntries|undefined); - - try { - config = JSON.parse(content) as ConfigEntries; - } catch(e) { - Notifications.getDefault().sendNotification({ - urgency: AstalNotifd.Urgency.NORMAL, - appName: "colorshell", - summary: "Config parsing error", - body: `An error occurred while parsing colorshell's config file: \nFile: ${ - this.#file.get_path()!}\n${ - (e as SyntaxError).message}\n${(e as SyntaxError).stack}` - }); - } - - if(!config) return; - - - // only change valid entries that are available in the defaults (with 1 of depth) - for(const k of Object.keys(this.entries)) { - if(config[k as keyof typeof config] === undefined) - return; - - // TODO needs more work, like object-recursive(infinite depth) entry attributions - this.entries[k as keyof typeof this.entries] = config[k as keyof typeof config]; - } - - this.notifySubs(); - }).catch((e: Gio.IOErrorEnum) => { - Notifications.getDefault().sendNotification({ - urgency: AstalNotifd.Urgency.NORMAL, - appName: "colorshell", - summary: "Config read error", - body: `An error occurred while reading colorshell's config file: \nFile: ${`${ - this.#file.get_path()!}\n${e.message ? `${e.message}\n` : ""}${e.stack}`.replace(/[<>]/g, "\\&")}` - }); - }); - } - - private notifySubs(): void { - for(const sub of this.#subs) { - sub(this.entries); - } - } - - public bindProperty(propertyPath: (keyof ConfigEntries|string), expectType?: ValueTypes): Binding { - return bind(this).as(() => - this.getProperty(propertyPath, expectType)); - } - - public getProperty(path: string, expectType?: ValueTypes): (any|undefined) { - return this._getProperty(path, this.entries, expectType); - } - - public getPropertyDefault(path: string, expectType?: ValueTypes): (any|undefined) { - return this._getProperty(path, this.defaults, expectType); - } - - private _getProperty(path: string, entries: ConfigEntries, expectType?: ValueTypes): (any|undefined) { - let property: any = entries; - const pathArray = path.split('.').filter(str => str); - - for(let i = 0; i < pathArray.length; i++) { - const currentPath = pathArray[i]; - - property = property[currentPath as keyof typeof property]; - } - - if(expectType !== "any" && typeof property !== expectType) { - console.error(`Config: property with path \`${path - }\` is either \`undefined\` or not in the expected value type \`${expectType - }\`, returning default value`); - - property = this.defaults; - - for(let i = 0; i < pathArray.length; i++) { - const currentPath = pathArray[i]; - - property = property[currentPath as keyof typeof property]; - } - } - - if(expectType !== "any" && typeof property !== expectType) { - console.error(`Config: property with path \`${path}\` not found in defaults/user-entries, returning \`undefined\``); - property = undefined; - } - - return property; - } - - public get(): ConfigEntries { - return this.entries; - } - - public subscribe(callback: (entries: ConfigEntries) => void): () => void { - this.#subs.add(callback); - - return () => { - this.#subs.delete(callback); - }; - } -} diff --git a/ags/scripts/nightlight.ts b/ags/scripts/nightlight.ts deleted file mode 100644 index 63e7e678..00000000 --- a/ags/scripts/nightlight.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { AstalIO, exec, execAsync, GLib, GObject, interval, property, register } from "astal"; - -export { NightLight }; - -@register({ GTypeName: "NightLight" }) -class NightLight extends GObject.Object { - private static instance: NightLight; - - #watchInterval: (AstalIO.Time|null) = null; - #temperature: number = 4500; - #gamma: number = 100; - #identity: boolean = false; - - #prevTemperature: (number|null) = null; - #prevGamma: (number|null) = null; - - @property(Number) - public get temperature() { return this.#temperature; } - public set temperature(newValue: number) { this.setTemperature(newValue); } - - @property(Number) - public get gamma() { return this.#gamma; } - public set gamma(newValue: number) { this.setGamma(newValue); } - - public readonly maxTemperature = 20000; - public readonly minTemperature = 1000; - public readonly identityTemperature = 6000; - public readonly maxGamma = 100; - - @property(Boolean) - public get identity() { return this.#identity; } - public set identity(newValue: boolean) { - newValue ? this.applyIdentity() : this.filter(); - } - - constructor() { - super(); - - this.#watchInterval = interval(1000, () => { - execAsync("hyprctl hyprsunset temperature").then(t => { - if(t.trim() !== "" && t.trim().length <= 5) { - const val = Number.parseInt(t.trim()); - - if(this.#temperature !== val) { - this.#temperature = val; - this.notify("temperature"); - } - } - }).catch((r) => console.error(r)); - - execAsync("hyprctl hyprsunset gamma").then(g => { - if(g.trim() !== "" && g.trim().length <= 5) { - const val = Number.parseInt(g.trim()); - - if(this.#gamma !== val) { - this.#gamma = val; - this.notify("gamma"); - } - } - }).catch((r) => console.error(r)); - }); - - this.vfunc_dispose = () => this.#watchInterval && - this.#watchInterval.cancel(); - } - - public static getDefault(): NightLight { - if(!this.instance) - this.instance = new NightLight(); - - return this.instance; - } - - private setTemperature(value: number): void { - if(value === this.temperature) return; - - if(value > this.maxTemperature || value < 1000) { - console.error(`Night Light(hyprsunset): provided temperatue ${value - } is out of bounds (min: 1000; max: ${this.maxTemperature})`); - return; - } - - execAsync(`hyprctl hyprsunset temperature ${value}`).then(() => { - this.#temperature = value; - this.notify("temperature"); - - this.#identity = false; - this.#prevTemperature = null; - this.#prevGamma = null; - }).catch((r) => console.error( - `Night Light(hyprsunset): Couldn't set temperature. Stderr: ${r}` - )); - } - - private setGamma(value: number): void { - if(value === this.gamma) return; - - if(value > this.maxGamma || value < 0) { - console.error(`Night Light(hyprsunset): provided gamma ${value - } is out of bounds (min: 0; max: ${this.maxTemperature})`); - return; - } - - execAsync(`hyprctl hyprsunset gamma ${value}`).then(() => { - this.#gamma = value; - this.notify("gamma"); - - this.#identity = false; - this.#prevTemperature = null; - this.#prevGamma = null; - }).catch((r) => console.error( - `Night Light(hyprsunset): Couldn't set gamma. Stderr: ${r}` - )); - } - - public applyIdentity(): void { - if(this.#identity) return; - - this.#prevGamma = this.#gamma; - this.#prevTemperature = this.#temperature; - - this.#identity = true; - this.temperature = this.identityTemperature; - this.gamma = this.maxGamma; - } - - public filter(): void { - if(!this.#identity) return; - - this.#identity = false; - this.setTemperature(this.#prevTemperature ?? this.identityTemperature); - this.setGamma(this.#prevGamma ?? this.maxGamma); - - this.#prevTemperature = null; - this.#prevGamma = null; - } - - public saveData(): void { - exec(`sh ${GLib.get_user_config_dir()}/hypr/scripts/save-hyprsunset.sh`); - } - - public loadData(): void { - exec(`sh ${GLib.get_user_config_dir()}/hypr/scripts/load-hyprsunset.sh`); - } -} diff --git a/ags/scripts/notifications.ts b/ags/scripts/notifications.ts deleted file mode 100644 index 2996f38a..00000000 --- a/ags/scripts/notifications.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { AstalIO, execAsync, Gio, GObject, property, register, signal, timeout } from "astal"; -import AstalNotifd from "gi://AstalNotifd"; -import { Config } from "./config"; - - -export interface HistoryNotification { - id: number; - appName: string; - body: string; - summary: string; - urgency: AstalNotifd.Urgency; - appIcon?: string; - time: number; - image?: string; -} - -@register({ GTypeName: "Notifications" }) -class Notifications extends GObject.Object { - private static instance: (Notifications|null) = null; - - #notifications: Array = []; - #history: Array = []; - #notificationsOnHold: Set = new Set(); - #connections: Array = []; - #historyLimit: number = 10; - - @property() - public get notifications() { return this.#notifications }; - - @property() - public get history() { return this.#history }; - - @property() - public get historyLimit() { return this.#historyLimit }; - - public set historyLimit(newValue: number) { - this.#historyLimit = newValue; - this.notify("historyLimit"); - } - - - @signal(AstalNotifd.Notification) - declare notificationAdded: (notification: AstalNotifd.Notification) => void; - - @signal(Number) - declare notificationRemoved: (id: number) => void; - - @signal(Object) // It's an Object, beacuase HistoryNotification is just an interface - declare historyAdded: (notification: AstalNotifd.Notification) => void; - - @signal(Number) - declare historyRemoved: (id: number) => void; - - @signal(Number) - declare notificationReplaced: (id: number) => void; - - - constructor() { - super(); - - this.#connections.push( - AstalNotifd.get_default().connect("notified", (notifd, id) => { - const notification = notifd.get_notification(id); - const notifTimeout = Config.getDefault().getProperty( - `notifications.timeout_${this.getUrgencyString(notification.urgency).toLowerCase()}`, - "number") as number; - - if(this.getNotifd().dontDisturb) { - this.addHistory(notification, () => notification.dismiss()); - return; - } - - this.addNotification(notification, () => { - if(notification.urgency !== AstalNotifd.Urgency.CRITICAL || - (notification.urgency === AstalNotifd.Urgency.CRITICAL && - notifTimeout > 0)) { - - let notifTimer: (AstalIO.Time|undefined) = undefined; - let replacedConnectionId: number; - - const removeFun = () => { // Funny name haha lmao remove fun :skull: - notifTimer = undefined; - if(this.#notificationsOnHold.has(notification.id)) return; - - this.addHistory(notification, () => { - replacedConnectionId && this.disconnect(replacedConnectionId); - this.removeNotification(id); - }); - } - - notifTimer = timeout(notifTimeout, removeFun); - - replacedConnectionId = this.connect("notification-replaced", (_, id: number) => { - if(notification.id !== id) return; - - notifTimer?.cancel(); - notifTimer = timeout(notifTimeout, removeFun); - }); - } - }); - }), - - AstalNotifd.get_default().connect("resolved", (notifd, id, _reason) => { - this.removeNotification(id); - this.addHistory(notifd.get_notification(id)); - }) - ); - - this.run_dispose = () => { - super.run_dispose(); - this.#connections.map((id: number) => - AstalNotifd.get_default().disconnect(id)); - }; - } - - public static getDefault(): Notifications { - if(!this.instance) - this.instance = new Notifications(); - - return this.instance; - } - - public async sendNotification(props: { - urgency?: AstalNotifd.Urgency; - appName?: string; - image?: string; - summary: string; - body?: string; - replaceId?: number; - actions?: Array<{ - id?: (string|number); - text: string; - onAction?: () => void - }> - }): Promise<{ - id?: (string|number); - text: string; - onAction?: () => void - }|null|void> { - - return await execAsync([ - "notify-send", - ...(props.urgency ? [ - "-u", this.getUrgencyString(props.urgency) - ] : []), ...(props.appName ? [ - "-a", props.appName - ] : []), ...(props.image ? [ - "-i", props.image - ] : []), ...(props.actions ? props.actions.map((action) => - [ "-A", action.text ] - ).flat(2) : []), ...(props.replaceId ? [ - "-r", props.replaceId.toString() - ] : []), props.summary, props.body ? props.body : "" - ]).then((stdout) => { - stdout = stdout.trim(); - if(!stdout) { - if(props.actions && props.actions.length > 0) - return null; - - return; - } - - if(props.actions && props.actions.length > 0) { - const action = props.actions[Number.parseInt(stdout)]; - action?.onAction?.(); - - return action ?? undefined; - } - }).catch((err: Gio.IOErrorEnum) => { - console.error(`Notifications: Couldn't send notification! Is the daemon running? Stderr:\n${ - err.message ? `${err.message}\n` : ""}Stack: ${err.stack}`); - }); - } - - public getUrgencyString(urgency: AstalNotifd.Notification|AstalNotifd.Urgency) { - switch((urgency instanceof AstalNotifd.Notification) ? - urgency.urgency : urgency) { - - case AstalNotifd.Urgency.LOW: - return "low"; - case AstalNotifd.Urgency.CRITICAL: - return "critical"; - } - - return "normal"; - } - - private addHistory(notif: AstalNotifd.Notification, onAdded?: (notif: AstalNotifd.Notification) => void): void { - if(!notif) return; - - this.#history.length === this.#historyLimit && - this.removeHistory(this.#history[this.#history.length - 1]); - - this.#history.map((notifb, i) => - notifb.id === notif.id && this.#history.splice(i, 1)); - - this.#history.unshift({ - id: notif.id, - appName: notif.appName, - body: notif.body, - summary: notif.summary, - urgency: notif.urgency, - appIcon: notif.appIcon, - time: notif.time, - image: notif.image ? notif.image : undefined - } as HistoryNotification); - - this.notify("history"); - this.emit("history-added", this.#history[0]); - onAdded && onAdded(notif); - } - - public async clearHistory(): Promise { - this.#history.reverse().map((notif) => { - this.#history = this.history.filter((n) => n.id !== notif.id); - this.emit("history-removed", notif.id); - }); - - this.notify("history"); - } - - public removeHistory(notif: (HistoryNotification|number)): void { - const notifId = (typeof notif === "number") ? notif : notif.id; - this.#history = this.#history.filter((item: HistoryNotification) => - item.id !== notifId); - - this.notify("history"); - this.emit("history-removed", notifId); - } - - private addNotification(notif: AstalNotifd.Notification, onAdded?: (notif: AstalNotifd.Notification) => void): void { - for(let i = 0; i < this.#notifications.length; i++) { - const item = this.#notifications[i]; - - if(item.id !== notif.id) continue; - - this.#notifications.splice(i, 1); - this.emit("notification-replaced", item.id); - break; - } - - this.#notifications.unshift(notif); - this.notify("notifications"); - this.emit("notification-added", notif); - onAdded?.(notif); - } - - public removeNotification(notif: (AstalNotifd.Notification|number)): void { - const notificationId = (notif instanceof AstalNotifd.Notification) ? notif.id : notif; - this.#notificationsOnHold.has(notificationId) && - this.#notificationsOnHold.delete(notificationId); - - this.#notifications = this.#notifications.filter((item: AstalNotifd.Notification) => - item.id !== notificationId); - - AstalNotifd.get_default().get_notification(notificationId)?.dismiss(); - this.notify("notifications"); - this.emit("notification-removed", notificationId); - } - - private getNotificationById(id: number): AstalNotifd.Notification|undefined { - return this.#notifications.filter(notif => notif.id === id)?.[0]; - } - - public holdNotification(notif: (AstalNotifd.Notification|number)): void { - notif = (typeof notif === "number") ? - this.getNotificationById(notif)! - : notif; - - if(!notif) return; - - this.#notificationsOnHold.add(notif.id); - } - - public toggleDoNotDisturb(value?: boolean): boolean { - value = value ?? !AstalNotifd.get_default().dontDisturb; - AstalNotifd.get_default().dontDisturb = value; - - return value; - } - - public getNotifd(): AstalNotifd.Notifd { return AstalNotifd.get_default(); } -} - -export { Notifications }; diff --git a/ags/scripts/reload-handler.ts b/ags/scripts/reload-handler.ts deleted file mode 100644 index e4a2de0d..00000000 --- a/ags/scripts/reload-handler.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { execAsync, Gio, monitorFile } from "astal"; -import { App } from "astal/gtk3"; -import { uwsmIsActive } from "./apps"; - -const monitoringPaths = [ "./scripts", "./window", "./app.ts", "env.d.ts" ]; - -export function restartInstance(instanceName?: string): void { - execAsync(`astal -q ${ instanceName ?? App.instanceName ?? "astal" }`); - Gio.Subprocess.new( - ( uwsmIsActive ? - [ "uwsm", "app", "--", "ags", "run" ] - : [ "ags", "run" ]), - Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE - ); -} - -export function monitorPaths(): void { - monitoringPaths.map((path: string) => { - monitorFile( - path, - () => restartInstance() - ) - }); -} diff --git a/ags/scripts/stylesheet.ts b/ags/scripts/stylesheet.ts deleted file mode 100644 index ee235b3f..00000000 --- a/ags/scripts/stylesheet.ts +++ /dev/null @@ -1,83 +0,0 @@ -// handles stylesheet compiling and reloading - -import { monitorFile, AstalIO, timeout, GLib, Gio, execAsync, exec, readFile } from "astal"; -import { App } from "astal/gtk3"; - -export class Stylesheet { - private static instance: Stylesheet; - #watchDelay: (AstalIO.Time|undefined); - #outputPath = Gio.File.new_for_path(`${GLib.get_user_state_dir()}/ags/style`); - #styles = [ - "./style", - "./style.scss" - ]; - - public async compileSass(): Promise { - console.log("Stylesheet: Compiling Sass"); - - exec(`bash -c "sass ${this.#styles.map(style => `-I ${style}`).join('\s')} ${ - this.#outputPath.get_path()!}/style.css"`); - } - - public async reapply(cssFilePath: string): Promise { - console.log("Stylesheet: Applying stylesheet"); - - const content = readFile(cssFilePath); - - if(content) { - App.reset_css(); - App.apply_css(content); - - console.log("Stylesheet: done applying stylesheet to shell"); - return; - } - - console.error(`Stylesheet: An error occurred while trying to read the css file: ${ - cssFilePath}`); - } - - public async compileApply(): Promise { - await this.compileSass().catch((err: Gio.IOErrorEnum) => - console.error(`Stylesheet: An Error occurred and Sass couldn't be compiled. Stderr:\n${err.message ? - `\t${err.message}\n` : ""}${err.stack}\n`) - ).then(() => this.reapply(this.#outputPath.get_path()! + "/style.css")); - } - - public static getDefault(): Stylesheet { - if(!this.instance) - this.instance = new Stylesheet(); - - return this.instance; - } - - constructor() { - (async () => !this.#outputPath.query_exists(null) && - this.#outputPath.make_directory_with_parents(null))(); - - this.#styles.map((path: string) => - monitorFile( - `${path}`, - (file: string) => { - if(this.#watchDelay || file.endsWith('~') || Number.isNaN(file)) - return; - - this.#watchDelay = timeout(250, () => this.#watchDelay = undefined); - console.log(`Stylesheet: \`${file.startsWith(GLib.get_home_dir()) ? - file.replace(GLib.get_home_dir(), '~') - : file}\` changed`) - - this.compileApply(); - } - ) - ) - - monitorFile( - `${GLib.get_user_cache_dir()}/wal/colors.scss`, - (file: string) => { - execAsync(`bash -c "cp -f ${file} ./style/_wal.scss"`).catch(r => { - console.error(`Stylesheet: Failed to copy pywal stylesheet to style dir. Stderr: ${r}`); - }); - } - ); - } -} diff --git a/ags/scripts/time.ts b/ags/scripts/time.ts deleted file mode 100644 index cdb804ad..00000000 --- a/ags/scripts/time.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { GLib, Variable } from "astal"; - -const time = new Variable(GLib.DateTime.new_now_local()).poll(500, () => - GLib.DateTime.new_now_local())(); - -export const getDateTime = () => time; diff --git a/ags/scripts/utils.ts b/ags/scripts/utils.ts deleted file mode 100644 index 35fb02ad..00000000 --- a/ags/scripts/utils.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { exec, execAsync, Gio, GLib } from "astal"; - -export const decoder = new TextDecoder("utf-8"), - encoder = new TextEncoder(); - -export function getHyprlandInstanceSig(): (string|null) { - return GLib.getenv("HYPRLAND_INSTANCE_SIGNATURE"); -} - -export function getHyprlandVersion(): string { - return exec(`${GLib.getenv("HYPRLAND_CMD") || "Hyprland"} --version | head -n1`).split(" ")[1]; -} - -export function makeDirectory(dir: string): void { - execAsync([ "mkdir", "-p", dir ]); -} - -export function deleteFile(path: string): void { - execAsync([ "rm", "-r", path ]); -} - -export function isInstalled(commandName: string): boolean { - const proc = Gio.Subprocess.new(["bash", "-c", `command -v ${commandName}`], - Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE); - - const [ , stdout, stderr ] = proc.communicate_utf8(null, null); - if(stdout && !stderr) - return true; - - return false; -} diff --git a/ags/scripts/varmap.ts b/ags/scripts/varmap.ts deleted file mode 100644 index 16b59e8c..00000000 --- a/ags/scripts/varmap.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { Subscribable } from "astal/binding"; - -export class VarMap implements Subscribable { - - #subs = new Set<(v: Map) => void>(); - #map: Map; - - constructor(initial?: Map) { - this.#map = initial || new Map(); - } - - private notifyMap() { - const subs = this.#subs; - for(const sub of subs) { - sub(this.#map); - } - } - - public get(): Map { - return this.#map; - } - - public get size(): number { - return this.#map.size; - } - - public getValue(key: K): (V|undefined) { - return this.#map.get(key); - } - - public getKeyAt(index: number): (K|undefined) { - return [...this.#map.keys()][index]; - } - - public getValueAt(index: number): (V|undefined) { - return [...this.#map.values()][index]; - } - - public set(key: K, value: V): Map { - const newMap: Map = this.#map.set(key, value); - this.notifyMap(); - - return newMap; - } - - public delete(key: K): boolean { - const deleted: boolean = this.#map.delete(key); - this.notifyMap(); - return deleted; - } - - public has(key: K): boolean { - return this.#map.has(key); - } - - public clear(): void { - this.#map.clear(); - this.notifyMap(); - } - - public entries(): MapIterator<[K, V]> { - return this.#map.entries(); - } - - public keys(): MapIterator { - return this.#map.keys(); - } - - public values(): MapIterator { - return this.#map.values(); - } - - public forEach (callback: (value: V, key: K, map: Map) => ReturnType): ReturnType[] { - const result: Array = []; - for(const entry of this.#map.entries()) { - result.push(callback(entry[1], entry[0], this.#map)); - } - - return result; - } - - public subscribe(callback: (v: Map) => void): () => void { - this.#subs.add(callback); - - return () => { - this.#subs.delete(callback); - } - } -} diff --git a/ags/scripts/widget-utils.ts b/ags/scripts/widget-utils.ts deleted file mode 100644 index 4824b255..00000000 --- a/ags/scripts/widget-utils.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { Gtk, Widget } from "astal/gtk3"; - -export function addSliderMarksFromMinMax(slider: Widget.Slider, amountOfMarks: number = 2, markup?: (string | null)) { - if(markup && !markup.includes("{}")) - markup = `${markup}{}` - - slider.add_mark(slider.min, Gtk.PositionType.BOTTOM, markup ? - markup.replaceAll("{}", `${slider.min}`) : null); - - const num = (amountOfMarks - 1); - for(let i = 1; i <= num; i++) { - const part = (slider.max / num) | 0; - - if(i > num) { - slider.add_mark(slider.max, Gtk.PositionType.BOTTOM, `${slider.max}K`); - break; - } - - slider.add_mark(part*i, Gtk.PositionType.BOTTOM, markup ? - markup.replaceAll("{}", `${part*i}`) : null); - } - - return slider; -} diff --git a/ags/style/_bar.scss b/ags/style/_bar.scss deleted file mode 100644 index 6d9b9b32..00000000 --- a/ags/style/_bar.scss +++ /dev/null @@ -1,249 +0,0 @@ -@use "sass:color"; -@use "./mixins"; -@use "./colors"; -@use "./wal"; -@use "./functions"; - -.bar-container { - padding: 6px; - padding-bottom: 0px; - - label { - @include mixins.reset-props; - - font-size: 12px; - font-weight: 600; - } - - // Style widget groups - & > .bar-centerbox > * { - $radius: 18px; - $color-hover: colors.$bg-primary; - $padding: 4px; - - background: rgba(colors.$bg-translucent, .6); - border-radius: $radius; - padding: 0 $padding; - - & > eventbox { - &:hover { - & > box { - background: $color-hover; - } - } - & > box { - border-radius: calc($radius - $padding); - margin: $padding 0; - } - - & > box { - padding: 0 8px; - } - } - - & > button, - & > box > button { - border-radius: calc($radius - $padding); - margin: $padding 0; - padding: 0 9px; - - &:hover { - background: $color-hover; - } - } - } - - .workspaces-row { - padding: 4px; - - & eventbox > box > eventbox { - & > box { - margin: 3px 0; - border-radius: 16px; - transition: 80ms linear; - min-width: 16px; - padding: 0 6px; - background: colors.$bg-tertiary; - - & label.id { - font-weight: 600; - margin-right: 4px; - opacity: 0; - } - } - - &.focus > box { - background: colors.$fg-primary; - min-width: 32px; - - & label.id { - color: colors.$fg-light; - margin-right: 0; - } - } - - & icon { - font-size: 16px; - } - - &.show label.id { - opacity: 1; - } - - &:hover > box { - box-shadow: inset 0 0 0 100px rgba($color: colors.$fg-primary, $alpha: .2); - } - } - - &.special-workspaces { - & > eventbox { - & box { - background: wal.$color4; - } - - &:hover > box { - background: functions.toRGB(color.adjust(wal.$color4, $lightness: -6%)); - } - } - } - } - - .focused-client { - padding: 0 6px; - - & > .icon { - margin-right: 6px; - } - - & > .text-content { - & > .class { - font-size: 9px; - font-family: monospace; - font-weight: 600; - color: colors.$fg-disabled; - margin-top: 0px; - } - - & > .title { - font-size: 12px; - font-weight: 500; - margin-top: -2px; - } - } - } - - .clock.open > button { - background-color: colors.$bg-primary; - } - - .media-eventbox { - & > .media { - background: colors.$bg-primary; - padding: 0 8px; - } - - &:hover > .media { - box-shadow: inset 0 0 0 300px rgba(colors.$fg-primary, .2); - } - - & icon { - font-size: 14px; - } - - & .media-controls { - transition: none; - margin-left: 6px; - border-top-right-radius: 12px; - border-bottom-right-radius: 12px; - - & > button { - margin: 4px 1px; - - & icon { - font-size: 10px; - } - } - } - - - &.reveal { - & .media > box { - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - - } - } - - .tray { - padding: 0 6px; - - & .item { - &:hover { - background: none; - } - - margin: 0 6px; - padding: 0; - - &:first-child { - margin-left: 0; - } - - &:last-child { - margin-right: 0; - } - } - } - - .status { - @include mixins.reset-props; - - &:hover > box, - &.open > box { - background: colors.$bg-primary; - } - - & > box { - padding: 0 8px; - - & icon { - font-size: 14px; - } - - & revealer > eventbox > box { - background: rgba($color: colors.$bg-tertiary, $alpha: .7); - border-radius: 12px; - margin: 4px 0; - margin-left: 5px; - padding: 2px 6px; - } - - & .status-icons { - padding-left: 4px; - - & icon.notification-count { - font-size: 6px; - margin-top: -14px; - } - } - } - } - - .apps { - & > box { - min-width: 22px; - & > icon { - transition: 120ms linear; - font-size: 14px; - } - } - &.open > box { - background: colors.$bg-primary; - } - - &:hover icon { - -gtk-icon-transform: scale(1.144); - } - } -} diff --git a/ags/style/_colors.scss b/ags/style/_colors.scss deleted file mode 100644 index b487360e..00000000 --- a/ags/style/_colors.scss +++ /dev/null @@ -1,14 +0,0 @@ -@use "sass:color"; -@use "./wal"; -@use "./functions"; - -$bg-primary: functions.toRGB(color.adjust($color: wal.$color1, $lightness: -34%)); -$bg-secondary: functions.toRGB(color.adjust($color: wal.$color1, $lightness: -16%)); -$bg-tertiary: functions.toRGB(color.adjust($color: $bg-secondary, $lightness: 10%)); -$bg-light: wal.$foreground; -$bg-translucent: functions.toRGB(color.change($color: $bg-primary, $alpha: 75%)); -$bg-translucent-primary: $bg-translucent; -$bg-translucent-secondary: functions.toRGB(color.change($color: $bg-translucent, $alpha: 78%)); -$fg-primary: wal.$foreground; -$fg-light: $bg-primary; -$fg-disabled: functions.toRGB(color.adjust($color: wal.$foreground, $lightness: -11%)); diff --git a/ags/style/_functions.scss b/ags/style/_functions.scss deleted file mode 100644 index 2f0382d9..00000000 --- a/ags/style/_functions.scss +++ /dev/null @@ -1,14 +0,0 @@ -@use "sass:color"; - - -/** - * GTK3 only supports sRGB color space, unfortunately - */ -@function toRGB($color) { - @return rgba( - color.channel($color, "red"), - color.channel($color, "green"), - color.channel($color, "blue"), - color.alpha($color) - ); -} diff --git a/ags/style/_osd.scss b/ags/style/_osd.scss deleted file mode 100644 index 5740e113..00000000 --- a/ags/style/_osd.scss +++ /dev/null @@ -1,48 +0,0 @@ -@use "sass:color"; -@use "./wal"; -@use "./functions" as funs; - - -.osd { - background: funs.toRGB(color.change($color: wal.$background, $alpha: 65%)); - padding: 16px; - border-radius: 24px; - min-width: 180px; - - .icon { - margin-right: 10px; - font-size: 24px; - } - - .volume { - margin-top: -6px; - - .device { - margin-bottom: 5px; - font-size: 14px; - font-weight: 600; - } - - levelbar { - trough block { - border-radius: 2px; - background: funs.toRGB(color.adjust($color: wal.$color1, $lightness: -36%)); - - &.empty { - border-radius: 2px; - } - - &.filled { - padding: 3px 0; - background: wal.$color1; - } - } - } - - .value { - font-size: 11px; - font-weight: 400; - padding: 0 4px; - } - } -} diff --git a/ags/style/_runner.scss b/ags/style/_runner.scss deleted file mode 100644 index 18600852..00000000 --- a/ags/style/_runner.scss +++ /dev/null @@ -1,86 +0,0 @@ -@use "./colors"; - -.runner.main { - $radius: 24px; - - background: rgba($color: colors.$bg-primary, $alpha: .8); - border-radius: $radius; - box-shadow: inset 0 0 0 1px colors.$bg-secondary, - 0 0 8px 1px colors.$bg-translucent; - - padding: 4px; - - & entry { - transition: 80ms ease-in; - min-height: 1.6em; - padding: 14px; - border-radius: inherit; - background: none; - - &:focus { - box-shadow: none; - } - - & image.left { - margin-right: 6px; - } - } - - & list { - padding: 6px; - padding-top: 0; - - & > *:selected > .result, - & > *:active > .result, - & > *:hover > .result { - background: colors.$bg-secondary; - } - - & > *:first-child { - margin-top: 12px; - } - - &:last-child { - margin-bottom: 0; - } - } - - & trough { - margin-bottom: 10px; - } - - & list .result { - padding: 10px; - background: colors.$bg-primary; - margin: 2px 0; - border-radius: 14px; - - & icon { - font-size: 28px; - margin-right: 6px; - } - - & .title { - font-weight: 500; - font-size: 16px; - } - - & .description { - font-size: 12px; - color: colors.$fg-disabled; - } - } - - & .not-found { - padding-top: 24px; - - & icon { - font-size: 64px; - margin-bottom: .4em; - } - - & label { - font-size: 16px; - } - } -} diff --git a/ags/style/_wal.scss b/ags/style/_wal.scss deleted file mode 100644 index a94b0429..00000000 --- a/ags/style/_wal.scss +++ /dev/null @@ -1,26 +0,0 @@ -// SCSS Variables -// Generated by 'wal' -$wallpaper: "/home/joaov/wallpapers/Frieren Ring.jpeg"; - -// Special -$background: #523c42; -$foreground: #d3cecf; -$cursor: #d3cecf; - -// Colors -$color0: #523c42; -$color1: #6c839d; -$color2: #7a84a4; -$color3: #9f8a9d; -$color4: #84a2b5; -$color5: #9f9cab; -$color6: #b7a1b2; -$color7: #b0a7a9; -$color8: #937b81; -$color9: #90AFD2; -$color10: #A3B0DB; -$color11: #D4B9D2; -$color12: #B0D9F2; -$color13: #D5D0E5; -$color14: #F5D7EE; -$color15: #d3cecf; diff --git a/ags/tsconfig.json b/ags/tsconfig.json deleted file mode 100644 index d79ca1e5..00000000 --- a/ags/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/tsconfig", - "compilerOptions": { - "experimentalDecorators": true, - "strict": true, - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "Bundler", - "checkJs": true, - "allowJs": false, - "jsx": "react-jsx", - "jsxImportSource": "astal/gtk3" - } -} diff --git a/ags/widget/AskPopup.ts b/ags/widget/AskPopup.ts deleted file mode 100644 index 84c9f215..00000000 --- a/ags/widget/AskPopup.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Binding } from "astal"; -import { Widget } from "astal/gtk3"; -import { tr } from "../i18n/intl"; -import { CustomDialog, CustomDialogProps } from "./CustomDialog"; - - -export type AskPopupProps = { - title?: string | Binding; - text: string | Binding; - cancelText?: string; - acceptText?: string; - onAccept?: () => void; - onCancel?: () => void; -}; - -/** - * A Popup Widget that asks yes or no to a defined promt. - * Runs onAccept() when user accepts, or else onDecline() when - * user doesn't accept / closes window. - * This window isn't usually registered in this shell windowing - * system. - */ -export function AskPopup(props: AskPopupProps): Widget.Window { - let accepted: boolean = false; - - const window = CustomDialog({ - namespace: "ask-popup", - widthRequest: 400, - heightRequest: 250, - title: props.title ?? tr("ask_popup.title"), - text: props.text, - onFinish: () => !accepted && props.onCancel?.(), - options: [ - { text: props.cancelText ?? tr("cancel") }, - { - text: props.acceptText ?? tr("accept"), - onClick: () => { - accepted = true; - props.onAccept?.(); - } - } - ] - } as CustomDialogProps); - - return window; -} diff --git a/ags/widget/BackgroundWindow.ts b/ags/widget/BackgroundWindow.ts deleted file mode 100644 index 6306c751..00000000 --- a/ags/widget/BackgroundWindow.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Binding } from "astal"; -import { Astal, Gdk, Widget } from "astal/gtk3"; - - -const { TOP, LEFT, RIGHT, BOTTOM } = Astal.WindowAnchor; - -export type BackgroundWindowProps = { - /** GtkWindow Layer */ - layer?: Astal.Layer | Binding; - /** Monitor number where the window should open */ - monitor: number | Binding; - /** Custom stylesheet used in the window. default: `background: rgba(0, 0, 0, .2)` */ - css?: string | Binding; - /** Function that is called when the user triggers a mouse-click or escape action on the window */ - onAction?: (window: Widget.Window) => void; - /** Function that is called when the user clicks on the window with primary mouse button */ - onClickPrimary?: (window: Widget.Window) => void; - /** Function that is called when the user clicks on the window with secodary mouse button */ - onClickSecondary?: (window: Widget.Window) => void; - keymode?: Astal.Keymode; - exclusivity?: Astal.Exclusivity; -}; - -/** Creates a fullscreen GtkWindow that is used for making - * the user focus on the content after this window(e.g.: AskPopup, - * Authentication Window(futurely) or any PopupWindow) - * - * @param props Properties for background-window - * - * @returns The generated background window - */ -export function BackgroundWindow(props: BackgroundWindowProps) { - return new Widget.Window({ - namespace: "background-window", - css: props.css ?? "background: rgba(0, 0, 0, .2);", - monitor: props.monitor, - layer: props.layer ?? Astal.Layer.OVERLAY, - anchor: TOP | LEFT | BOTTOM | RIGHT, - keymode: props.keymode, - exclusivity: props.exclusivity ?? Astal.Exclusivity.IGNORE, - onKeyPressEvent: (self, event: Gdk.Event) => { - event.get_keyval()[1] === Gdk.KEY_Escape && - props.onAction?.(self); - }, - onButtonPressEvent: (self, event: Gdk.Event) => { - if(event.get_button()[1]) { - props.onAction?.(self); - return; - } - - if(event.get_button()[1] === Gdk.BUTTON_PRIMARY) { - props.onClickPrimary?.(self); - return; - } - - if(event.get_button()[1] === Gdk.BUTTON_SECONDARY) - props.onClickSecondary?.(self); - } - } as Widget.WindowProps); -} diff --git a/ags/widget/CustomDialog.ts b/ags/widget/CustomDialog.ts deleted file mode 100644 index 55523b69..00000000 --- a/ags/widget/CustomDialog.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Binding } from "astal"; -import { Astal, Gtk, Widget } from "astal/gtk3"; -import { Windows } from "../windows"; -import { PopupWindow, PopupWindowProps } from "./PopupWindow"; -import { Separator } from "./Separator"; -import { tr } from "../i18n/intl"; - -export type CustomDialogProps = { - namespace?: string | Binding; - className?: string | Binding; - cssBackground?: string; - title?: string | Binding; - text?: string | Binding; - heightRequest?: number | Binding; - widthRequest?: number | Binding; - childOrientation?: Gtk.Orientation | Binding; - children?: Array | Binding>; - child?: Gtk.Widget | Binding; - onFinish?: () => void; - options?: Array; - optionsOrientation?: Gtk.Orientation | Binding; -}; - -export interface CustomDialogOption { - onClick?: () => void; - text: string | Binding; - closeOnClick?: boolean | Binding; -} - -export function CustomDialog(props: CustomDialogProps = { - options: [{ text: tr("accept") }] -}): Widget.Window { - const window = Windows.createWindowForFocusedMonitor((mon: number) => PopupWindow({ - namespace: props.namespace ?? "custom-dialog", - monitor: mon, - cssBackgroundWindow: props.cssBackground ?? "background: rgba(0, 0, 0, .3);", - exclusivity: Astal.Exclusivity.IGNORE, - layer: Astal.Layer.OVERLAY, - halign: Gtk.Align.CENTER, - valign: Gtk.Align.CENTER, - widthRequest: props.widthRequest ?? 400, - heightRequest: props.heightRequest ?? 220, - onDestroy: props.onFinish, - child: new Widget.Box({ - className: props.className ?? "custom-dialog-container", - orientation: Gtk.Orientation.VERTICAL, - children: [ - new Widget.Label({ - className: "title", - visible: props.title, - label: props.title - } as Widget.LabelProps), - new Widget.Label({ - className: "text", - visible: props.text, - label: props.text, - yalign: 0, - expand: true - } as Widget.LabelProps), - new Widget.Box({ - className: "custom-children custom-child", - visible: props.children || props.child, - orientation: props.childOrientation ?? Gtk.Orientation.VERTICAL, - children: props.children, - child: props.child - } as Widget.BoxProps), - Separator({ - alpha: .2, - visible: props.options && props.options.length > 0, - spacing: 8, - orientation: Gtk.Orientation.VERTICAL - }), - new Widget.Box({ - className: "options", - orientation: props.optionsOrientation ?? Gtk.Orientation.HORIZONTAL, - hexpand: true, - heightRequest: 38, - homogeneous: true, - children: props.options && props.options.map(option => { - const onClick = () => { - option.onClick?.(); - (option.closeOnClick ?? true) && - window.close(); - }; - const connections: Array = []; - const btn = new Widget.Button({ - className: "option", - label: option.text, - hexpand: true, - setup: (self) => { - connections.push( - self.connect("click-release", (_, event: Astal.ClickEvent) => - event.button === Astal.MouseButton.PRIMARY && - onClick()), - self.connect("activate", (_) => onClick()) - ); - }, - onDestroy: (self) => connections.map(id => self.disconnect(id)) - } as Widget.ButtonProps); - - return btn; - }) - } as Widget.BoxProps) - ] - } as Widget.BoxProps) - } as PopupWindowProps))(); - - return window; -} diff --git a/ags/widget/EntryPopup.ts b/ags/widget/EntryPopup.ts deleted file mode 100644 index 266a4288..00000000 --- a/ags/widget/EntryPopup.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Binding } from "astal"; -import { Widget } from "astal/gtk3"; -import { tr } from "../i18n/intl"; -import { CustomDialog, CustomDialogProps } from "./CustomDialog"; - -export type EntryPopupProps = { - title: string | Binding; - text?: string | Binding; - cancelText?: string | Binding; - acceptText?: string | Binding; - closeOnAccept?: boolean; - entryPlaceholder?: string | Binding; - onAccept: (userInput: string) => void; - onCancel?: () => void; - onFinish?: () => void; - isPassword?: boolean | Binding; -}; - -export function EntryPopup(props: EntryPopupProps): Widget.Window { - props.closeOnAccept = props.closeOnAccept ?? true; - - const entry = new Widget.Entry({ - className: props.isPassword && "password", - visibility: (props.isPassword instanceof Binding) ? - props.isPassword.as(isPasswd => !isPasswd) - : !props.isPassword, - invisibleChar: 0x00B7, // set '·' as the invisible char - xalign: .5, - placeholderText: props.entryPlaceholder, - onActivate: (self) => { - props.closeOnAccept && window.close(); - entered = true; - props.onAccept(self.text); - self.text = ""; - }, - } as Widget.EntryProps); - - let entered: boolean = false; - - const window = CustomDialog({ - namespace: "entry-popup", - widthRequest: 420, - heightRequest: 220, - title: props.title, - text: props.text, - child: entry, - options: [ - { - text: props.cancelText ?? tr("cancel"), - onClick: props.onCancel - }, - { - text: props.acceptText ?? tr("accept"), - closeOnClick: props.closeOnAccept, - onClick: () => { - entered = true; - props.onAccept(entry.text); - entry.text = ""; - } - } - ], - onFinish: () => { - !entered && props.onCancel?.() - props.onFinish?.(); - } - } as CustomDialogProps); - - return window; -} diff --git a/ags/widget/Notification.ts b/ags/widget/Notification.ts deleted file mode 100644 index e6cc6569..00000000 --- a/ags/widget/Notification.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { Astal, Gtk, Widget } from "astal/gtk3"; -import AstalNotifd from "gi://AstalNotifd"; -import { Separator } from "./Separator"; -import { HistoryNotification, Notifications } from "../scripts/notifications"; -import { GLib } from "astal"; -import { getAppIcon } from "../scripts/apps"; -import Pango from "gi://Pango"; - - -function getNotificationImage(notif: AstalNotifd.Notification|HistoryNotification): (string|undefined) { - const img = notif.image || notif.appIcon; - - if(!img || !img.includes('/')) - return undefined; - - switch(true) { - case /^[/]/.test(img): - return `file://${img}`; - - case /^[~]/.test(img): - case /^file:\/\/[~]/i.test(img): - return `file://${GLib.get_home_dir()}/${img.replace(/^(file\:\/\/|[~]|file\:\/\[~])/i, "")}`; - } - - return img; -} - -export function NotificationWidget(notification: AstalNotifd.Notification|number|HistoryNotification, - onClose?: (notif: AstalNotifd.Notification|HistoryNotification) => void, - showTime?: boolean /* It's showTime :speaking_head: :boom: :bangbang: */, - holdOnHover?: boolean): Gtk.Widget { - - notification = (typeof notification === "number") ? - AstalNotifd.get_default().get_notification(notification) - : notification; - - return new Widget.EventBox({ - onClick: () => { - if(notification instanceof AstalNotifd.Notification) { - const viewAction = notification.actions.filter(action => - action.label.toLowerCase() === "view")?.[0]; - - viewAction && notification.invoke(viewAction.id); - } - - onClose?.(notification); - }, - onHover: () => holdOnHover && Notifications.getDefault().holdNotification(notification.id), - onHoverLost: () => holdOnHover && onClose?.(notification), - hexpand: true, - vexpand: false, - child: new Widget.Box({ - className: `notification ${ (notification instanceof AstalNotifd.Notification) ? - Notifications.getDefault().getUrgencyString(notification.urgency) : "" }`, - homogeneous: false, - expand: true, - orientation: Gtk.Orientation.VERTICAL, - spacing: 5, - children: [ - new Widget.Box({ - className: "top", - orientation: Gtk.Orientation.HORIZONTAL, - hexpand: true, - vexpand: false, - children: [ - new Widget.Icon({ - className: "icon app-icon", - icon: notification.appIcon && Astal.Icon.lookup_icon(notification.appIcon) ? - notification.appIcon - : getAppIcon(notification.appName), - setup: (self) => self.set_visible(Boolean(self.get_icon())), - halign: Gtk.Align.START, - css: "font-size: 16px;" - }), - new Widget.Label({ - className: "app-name", - halign: Gtk.Align.START, - hexpand: true, - label: notification.appName || "Unknown Application" - } as Widget.LabelProps), - new Widget.Box({ - halign: Gtk.Align.END, - children: [ - new Widget.Label({ - xalign: 1, - visible: !showTime ? false : true, - className: "time", - label: GLib.DateTime.new_from_unix_local(notification.time).format("%H:%M"), - } as Widget.LabelProps), - new Widget.Button({ - className: "close", - onClick: () => onClose && onClose(notification), - image: new Widget.Icon({ - className: "close icon", - icon: "window-close-symbolic" - } as Widget.IconProps) - } as Widget.ButtonProps) - ] - } as Widget.BoxProps) - ] - } as Widget.BoxProps), - Separator({ - orientation: Gtk.Orientation.VERTICAL, - alpha: 10 - }), - new Widget.Box({ - className: "content", - orientation: Gtk.Orientation.HORIZONTAL, - children: [ - new Widget.Box({ - className: "image", - setup: (box) => { - const img = getNotificationImage(notification); - - box.set_visible(Boolean(img)); - img && box.set_css(`background-image: image(url("${img}"))`); - } - } as Widget.BoxProps), - new Widget.Box({ - className: "text", - orientation: Gtk.Orientation.VERTICAL, - expand: true, - children: [ - new Widget.Label({ - className: "summary", - useMarkup: true, - xalign: 0, - truncate: true, - label: notification.summary.replace(/\&/g, "&") - }), - new Widget.Label({ - className: "body", - useMarkup: true, - halign: Gtk.Align.START, - xalign: 0, - truncate: false, - wrap: true, - singleLineMode: false, - wrapMode: Pango.WrapMode.WORD_CHAR, - label: notification.body.replace(/&/g, "&") - } as Widget.LabelProps) - ] - } as Widget.BoxProps) - ] - } as Widget.BoxProps), - new Widget.Box({ - className: "actions button-row", - hexpand: true, - visible: (notification instanceof AstalNotifd.Notification) ? - (notification.actions.filter(action => action.label.toLowerCase() !== "view").length > 0) - : false, - children: (notification instanceof AstalNotifd.Notification) ? - notification.actions.filter(action => action.label.toLowerCase() !== "view") - .map((action: AstalNotifd.Action) => - new Widget.Button({ - className: "action", - label: action.label, - hexpand: true, - onClicked: () => { - notification.invoke(action.id); - onClose && onClose(notification); - } - } as Widget.ButtonProps) - ) - : [] - } as Widget.BoxProps) - ] - } as Widget.BoxProps), - } as Widget.EventBoxProps); -} diff --git a/ags/widget/PopupWindow.ts b/ags/widget/PopupWindow.ts deleted file mode 100644 index fe66b05c..00000000 --- a/ags/widget/PopupWindow.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { Binding } from "astal"; -import { Astal, Gdk, Gtk, Widget } from "astal/gtk3"; -import { BackgroundWindow } from "./BackgroundWindow"; - -type PopupWindowSpecificProps = { - onDestroy?: (self: Widget.Window) => void; - onKeyPressEvent?: (self: Widget.Window, event: Gdk.Event) => void; - onButtonPressEvent?: (self: Gtk.Widget, event: Gdk.Event) => void; - /** Stylesheet for the background of the popup-window */ - cssBackgroundWindow?: string; - onClickedOutside?: (self: Widget.Window) => void; -}; - -export type PopupWindowProps = Pick & PopupWindowSpecificProps; - -const { TOP, LEFT, RIGHT, BOTTOM } = Astal.WindowAnchor; - -export function PopupWindow(props: PopupWindowProps): Widget.Window { - props.layer = props.layer ?? Astal.Layer.OVERLAY; - - const bgWindow = props.cssBackgroundWindow ? BackgroundWindow({ - monitor: props.monitor ?? 0, - layer: props.layer, - css: props.cssBackgroundWindow, - }) : undefined; - - const winProps: Widget.WindowProps = {}; - for(const key of Object.keys(props).filter(k => k !== "onClickedOutside")) { - // @ts-ignore ignore the `onClickedOutside()` method because astal thinks it's a signal - winProps[key as keyof typeof winProps] = props[key as keyof typeof props]; - } - - return new Widget.Window({ - ...winProps, - namespace: props?.namespace ?? "popup-window", - className: `popup-window ${(props.namespace instanceof Binding ? - props.namespace.get() : props.namespace) || ""}`, - keymode: Astal.Keymode.EXCLUSIVE, - anchor: TOP | LEFT | RIGHT | BOTTOM, - exclusivity: props.exclusivity ?? Astal.Exclusivity.NORMAL, - halign: undefined, - valign: undefined, - focusOnMap: true, - widthRequest: undefined, - heightRequest: undefined, - marginTop: undefined, - marginBottom: undefined, - marginLeft: undefined, - marginRight: undefined, - onDestroy: (self) => { - bgWindow?.close(); - props.onDestroy?.(self); - }, - onButtonPressEvent: (self, event) => { - if((event.get_button()[1] === Gdk.BUTTON_PRIMARY || - event.get_button()[1] === Gdk.BUTTON_SECONDARY)) { - - const [ , x, y ] = event.get_coords(); - const allocation = (self.get_child()! as Widget.Box).get_child()!.get_allocation(); - - if((x < allocation.x || x > (allocation.x + allocation.width)) || - (y < allocation.y || y > (allocation.y + allocation.height))) { - - if(!props.onClickedOutside) { - self.close(); - return; - } - - props.onClickedOutside?.(self); - } - } - }, - onKeyPressEvent: (self, event: Gdk.Event) => { - if(event.get_keyval()[1] === Gdk.KEY_Escape) { - self.close(); - return; - } - - props.onKeyPressEvent?.(self, event); - }, - child: new Widget.Box({ - expand: props.expand ?? false, - halign: props.halign, - valign: props.valign, - hexpand: true, - css: `box { - margin-left: ${props.marginLeft ?? 0}px; - margin-right: ${props.marginRight ?? 0}px; - margin-top: ${props.marginTop ?? 0}px; - margin-bottom: ${props.marginBottom ?? 0}px; - }`, - - child: new Widget.Box({ - onButtonPressEvent: props.onButtonPressEvent ?? (() => true), - widthRequest: props.widthRequest, - heightRequest: props.heightRequest, - child: props.child - } as Widget.BoxProps) - } as Widget.BoxProps) - } as Widget.WindowProps); -} diff --git a/ags/widget/Separator.ts b/ags/widget/Separator.ts deleted file mode 100644 index 34c50de8..00000000 --- a/ags/widget/Separator.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Binding } from "astal"; -import { Gtk, Widget } from "astal/gtk3"; - -export interface SeparatorProps { - class?: string; - alpha?: number; - cssColor?: string; - orientation?: Gtk.Orientation; - size?: number; - spacing?: number; - margin?: number; - visible?: boolean | Binding; -} - -export function Separator(props: SeparatorProps = { - orientation: Gtk.Orientation.HORIZONTAL -}) { - props.alpha = props.alpha ? - (props.alpha > 1 ? - props.alpha / 100 - : props.alpha) - : 1; - - props.orientation = props.orientation ?? Gtk.Orientation.HORIZONTAL; - - return new Widget.Box({ - name: "separator", - ...(props.orientation === Gtk.Orientation.HORIZONTAL ? - { vexpand: true } : { hexpand: true }), - className: `separator ${ props.orientation === Gtk.Orientation.VERTICAL ? - "vertical" : "horizontal" }`, - visible: props.visible, - css: `.vertical { - padding: ${props.spacing ?? 0}px ${props.margin ?? 7}px; - } - .horizontal { - padding: ${props.margin ?? 4}px ${props.spacing ?? 0}px; - }`, - child: new Widget.Box({ - className: `${ props.orientation === Gtk.Orientation.VERTICAL ? - "vertical" : "horizontal" } ${ props.class ? props.class : "" }`, - ...(props.orientation === Gtk.Orientation.HORIZONTAL ? - { vexpand: true } : { hexpand: true }), - css: `* { - background: ${ props.cssColor ?? "lightgray" }; - opacity: ${props.alpha}; - } - .horizontal { - min-width: ${ props.size ?? 1 }px; - } - - .vertical { - min-height: ${ props.size ?? 1 }px; - }` - } as Widget.BoxProps) - } as Widget.BoxProps); -} diff --git a/ags/widget/bar/Apps.ts b/ags/widget/bar/Apps.ts deleted file mode 100644 index e80a83b3..00000000 --- a/ags/widget/bar/Apps.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Gtk, Widget } from "astal/gtk3"; -import { tr } from "../../i18n/intl"; -import { Windows } from "../../windows"; -import { bind } from "astal"; - -export function Apps(): Gtk.Widget { - return new Widget.EventBox({ - onClickRelease: () => Windows.open("apps-window"), - className: bind(Windows, "openWindows").as((openWindows) => - Object.hasOwn(openWindows, "apps-window") ? "apps open" : "apps"), - child: new Widget.Box({ - child: new Widget.Icon({ - tooltipText: tr("apps"), - icon: "applications-other-symbolic", - halign: Gtk.Align.CENTER, - hexpand: true - } as Widget.IconProps) - } as Widget.BoxProps) - } as Widget.EventBoxProps); -} diff --git a/ags/widget/bar/Clock.ts b/ags/widget/bar/Clock.ts deleted file mode 100644 index e137dd78..00000000 --- a/ags/widget/bar/Clock.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Gtk, Widget } from "astal/gtk3"; -import { getDateTime } from "../../scripts/time"; -import { bind, GLib } from "astal"; -import { Windows } from "../../windows"; -import { Config } from "../../scripts/config"; - -export function Clock(): Gtk.Widget { - return new Widget.Box({ - className: bind(Windows, "openWindows").as((openWins) => - Object.hasOwn(openWins, "center-window") ? "open clock" : "clock"), - child: new Widget.Button({ - onClick: () => Windows.toggle("center-window"), - label: getDateTime().as((dateTime: GLib.DateTime) => - dateTime.format(Config.getDefault().getProperty("clock.date_format", "string") as string)) - } as Widget.ButtonProps) - } as Widget.BoxProps); -} diff --git a/ags/widget/bar/FocusedClient.ts b/ags/widget/bar/FocusedClient.ts deleted file mode 100644 index c099fe6e..00000000 --- a/ags/widget/bar/FocusedClient.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { bind } from "astal"; -import { Gtk, Widget } from "astal/gtk3"; -import AstalHyprland from "gi://AstalHyprland"; -import { getAppIcon } from "../../scripts/apps"; - -const hyprland = AstalHyprland.get_default(); - -export function FocusedClient(): Gtk.Widget { - return new Widget.Box({ - className: "focused-client", - visible: bind(hyprland, "focusedClient").as(fClient => - !fClient ? false : (fClient?.initialClass == null ? false : true)), - children: bind(hyprland, "focusedClient").as(focusedClient => focusedClient ? [ - new Widget.Icon({ - className: "icon", - vexpand: true, - css: ".icon { font-size: 18px; }", - icon: bind(focusedClient, "class").as(clss => - getAppIcon(clss) ?? "application-x-executable-symbolic") - }), - new Widget.Box({ - className: "text-content", - orientation: Gtk.Orientation.VERTICAL, - homogeneous: false, - valign: Gtk.Align.CENTER, - children: [ - new Widget.Label({ - className: "class", - xalign: 0, - visible: bind(focusedClient, "class").as(Boolean), - maxWidthChars: 55, - truncate: true, - tooltipText: bind(focusedClient, "class").as(clientClass => - clientClass ?? ""), - label: bind(focusedClient, "class").as(clientClass => - clientClass ?? "no_class") - } as Widget.LabelProps), - new Widget.Label({ - className: "title", - xalign: 0, - maxWidthChars: 50, - visible: bind(focusedClient, "title").as(Boolean), - truncate: true, - tooltipText: bind(focusedClient, "title").as((clientTitle: string) => - clientTitle ?? ""), - label: bind(focusedClient, "title").as(title => - title ?? "") - } as Widget.LabelProps) - ] - }) - ]: []) - } as Widget.BoxProps); -} diff --git a/ags/widget/bar/Media.ts b/ags/widget/bar/Media.ts deleted file mode 100644 index a2487cbc..00000000 --- a/ags/widget/bar/Media.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { bind, exec } from "astal"; -import { Gtk, Widget } from "astal/gtk3"; -import AstalMpris from "gi://AstalMpris"; -import { getSymbolicIcon } from "../../scripts/apps"; -import { Separator, SeparatorProps } from "../Separator"; -import { Windows } from "../../windows"; -import { Clipboard } from "../../scripts/clipboard"; - -export function Media(): Gtk.Widget { - const connections: Array = []; - - const mediaControlsRevealer: Widget.Revealer = new Widget.Revealer({ - transitionType: Gtk.RevealerTransitionType.SLIDE_RIGHT, - transitionDuration: 260, - revealChild: false, - child: new Widget.Box({ - className: "media-controls button-row", - expand: false, - homogeneous: false, - children: bind(AstalMpris.get_default(), "players").as((players: Array) => - players[0] ? [ - new Widget.Button({ - className: "link", - image: new Widget.Icon({ - icon: "edit-paste-symbolic" - } as Widget.IconProps), - tooltipText: "Copy link to Clipboard", - // AstalMpris.Player.metadata works only sometimes, so I'm not using it - visible: bind(players[0], "metadata").as(Boolean), - onClick: async () => { - const link = exec(`playerctl --player=${ - players[0].busName.replace(/^org\.mpris\.MediaPlayer2\./i, "") - } metadata xesam:url`); - - link && Clipboard.getDefault().copyAsync(link); - } - } as Widget.ButtonProps), - new Widget.Button({ - className: "previous", - image: new Widget.Icon({ - icon: "media-skip-backward-symbolic" - } as Widget.IconProps), - tooltipText: "Previous", - onClick: () => players[0].canGoPrevious && players[0].previous() - } as Widget.ButtonProps), - new Widget.Button({ - className: "play-pause", - tooltipText: bind(players[0], "playback_status").as((status) => - status === AstalMpris.PlaybackStatus.PLAYING ? - "Pause" - : "Play"), - image: new Widget.Icon({ - icon: bind(players[0], "playbackStatus").as((status: AstalMpris.PlaybackStatus) => - status === AstalMpris.PlaybackStatus.PLAYING ? - "media-playback-pause-symbolic" - : "media-playback-start-symbolic") - } as Widget.IconProps), - onClick: () => players[0].playbackStatus === AstalMpris.PlaybackStatus.PAUSED ? - players[0].play() - : players[0].pause() - } as Widget.ButtonProps), - new Widget.Button({ - className: "next", - image: new Widget.Icon({ - icon: "media-skip-forward-symbolic" - } as Widget.IconProps), - tooltipText: "Next", - onClick: () => players[0].canGoNext && players[0].next() - } as Widget.ButtonProps) - ] : new Widget.Label({ - label: "Don't Stop The Music!" - } as Widget.LabelProps) - ) - } as Widget.BoxProps) - } as Widget.RevealerProps); - - const mediaWidget = new Widget.EventBox({ - className: "media-eventbox", - visible: bind(AstalMpris.get_default(), "players").as((players: Array) => - players[0] && players[0].get_available()), - onDestroy: (_) => connections.map(id => _.disconnect(id)), - onClick: () => Windows.toggle("center-window"), - child: new Widget.Box({ - className: "media", - children: [ - new Widget.Box({ - spacing: 4, - children: bind(AstalMpris.get_default(), "players").as((players: Array) => - players[0] ? [ - new Widget.Icon({ - icon: bind(players[0], "busName").as((busName: string) => { - const splitName = busName.split('.').filter(str => str !== "" && !str.toLowerCase().includes('instance')); - if (getSymbolicIcon(splitName[splitName.length - 1])) { - return getSymbolicIcon(splitName[splitName.length - 1]); - } else { - return "folder-music-symbolic" - }; - }) - } as Widget.IconProps), - new Widget.Label({ - className: "title", - label: bind(players[0], "title").as((title: string) => title || "No Title"), - maxWidthChars: 20, - truncate: true - } as Widget.LabelProps), - Separator({ - orientation: Gtk.Orientation.HORIZONTAL, - size: 1, - margin: 5, - //cssColor: `rgb(180, 180, 180)`, - alpha: .3 - } as SeparatorProps), - new Widget.Label({ - className: "artist", - label: bind(players[0], "artist").as((artist: string) => artist || "No Artist"), - maxWidthChars: 18, - truncate: true - } as Widget.LabelProps) - ] : new Widget.Label({ - label: "Crazy to think this widget haven't disappeared yet!" - } as Widget.LabelProps) - ) - } as Widget.BoxProps), - mediaControlsRevealer - ] - } as Widget.BoxProps) - } as Widget.EventBoxProps); - - connections.push( - mediaWidget.connect("hover", () => { - mediaControlsRevealer.set_reveal_child(true); - mediaWidget.className = mediaWidget.className + " reveal"; - }), - mediaWidget.connect("hover-lost", (_) => { - mediaControlsRevealer.set_reveal_child(false); - _.className = mediaWidget.className.replaceAll(" reveal", ""); - }) - ); - - return mediaWidget; -} diff --git a/ags/widget/bar/Status.ts b/ags/widget/bar/Status.ts deleted file mode 100644 index c0759810..00000000 --- a/ags/widget/bar/Status.ts +++ /dev/null @@ -1,170 +0,0 @@ -import AstalBluetooth from "gi://AstalBluetooth"; -import AstalNetwork from "gi://AstalNetwork"; -import AstalWp from "gi://AstalWp"; - -import { bind, Binding, Variable } from "astal"; -import { Gtk, Widget } from "astal/gtk3"; -import { Wireplumber } from "../../scripts/volume"; -import { Notifications } from "../../scripts/notifications"; -import { Windows } from "../../windows"; -import { Recording } from "../../scripts/recording"; -import { getDateTime } from "../../scripts/time"; -import { tr } from "../../i18n/intl"; - - -export function Status(): Gtk.Widget { - const recordingTimer: Variable = Variable.derive([ - bind(Recording.getDefault(), "recording"), - getDateTime() - ], (recording, dateTime) => { - if(!recording || !Recording.getDefault().startedAt) - return "..."; - - const startedAtSeconds = dateTime.to_unix() - Recording.getDefault().startedAt!.to_unix(); - if(startedAtSeconds <= 0) return "00:00"; - - const minutes = Math.floor(startedAtSeconds / 60); - const seconds = Math.floor(startedAtSeconds % 60); - - return `${ minutes < 10 ? `0${minutes}` : minutes }:${ seconds < 10 ? `0${seconds}` : seconds }`; - }); - - return new Widget.EventBox({ - className: bind(Windows, "openWindows").as((openWins) => - Object.hasOwn(openWins, "control-center") ? "open status" : "status"), - onClick: () => Windows.toggle("control-center"), - child: new Widget.Box({ - children: [ - new Widget.Box({ - className: "volume-indicators", - spacing: 5, - children: [ - volumeStatus({ - className: "sink", - endpoint: Wireplumber.getDefault().getDefaultSink(), - icon: bind(Wireplumber.getDefault().getDefaultSink(), "volumeIcon").as(icon => - !Wireplumber.getDefault().isMutedSink() && Wireplumber.getDefault().getSinkVolume() > 0 ? - icon : "audio-volume-muted-symbolic"), - }), - volumeStatus({ - className: "source", - endpoint: Wireplumber.getDefault().getDefaultSource(), - icon: bind(Wireplumber.getDefault().getDefaultSource(), "volumeIcon").as(icon => - !Wireplumber.getDefault().isMutedSource() && Wireplumber.getDefault().getSourceVolume() > 0 ? - icon : "microphone-sensitivity-muted-symbolic"), - }) - ] - } as Widget.BoxProps), - new Widget.Revealer({ - revealChild: bind(Recording.getDefault(), "recording"), - transitionDuration: 500, - transitionType: Gtk.RevealerTransitionType.SLIDE_LEFT, - onDestroy: () => recordingTimer.drop(), - child: new Widget.EventBox({ - onClick: () => Recording.getDefault().recording && - Recording.getDefault().stopRecording(), - tooltipText: tr("control_center.tiles.recording.enabled_desc"), - child: new Widget.Box({ - children: [ - new Widget.Icon({ - className: "recording state", - icon: "media-record-symbolic", - css: "margin-right: 4px;" - } as Widget.IconProps), - new Widget.Label({ - className: "rec-time", - label: recordingTimer() - } as Widget.LabelProps) - ] - } as Widget.BoxProps) - } as Widget.EventBoxProps) - } as Widget.RevealerProps), - StatusIcons() - ] - } as Widget.BoxProps) - } as Widget.EventBoxProps); -} - -function volumeStatus(props: { className?: string, endpoint: AstalWp.Endpoint, icon?: (string|Binding) }): Gtk.Widget { - return new Widget.EventBox({ - className: props.className, - onScroll: (_, event) => - event.delta_y > 0 ? - Wireplumber.getDefault().decreaseEndpointVolume(props.endpoint, 5) - : Wireplumber.getDefault().increaseEndpointVolume(props.endpoint, 5), - child: new Widget.Box({ - spacing: 2, - children: [ - new Widget.Icon({ - visible: props.icon, - icon: props.icon, - } as Widget.IconProps), - new Widget.Label({ - className: "volume", - label: bind(props.endpoint, "volume").as((volume: number) => - Math.floor(volume * 100) + "%") - } as Widget.LabelProps), - ] - } as Widget.BoxProps) - } as Widget.EventBoxProps) -} - -function StatusIcons(): Gtk.Widget { - const bluetoothIcon: Variable = Variable.derive([ - bind(AstalBluetooth.get_default(), "isPowered"), - bind(AstalBluetooth.get_default(), "isConnected") - ], (powered, connected) => { - return powered ? ( - connected ? - "bluetooth-active-symbolic" - : "bluetooth-symbolic" - ) : "bluetooth-disabled-symbolic" - }); - - const networkIcon: Variable = Variable.derive([ - bind(AstalNetwork.get_default(), "primary"), - ], - (primary) => { - switch(primary) { - case AstalNetwork.Primary.WIRED: return AstalNetwork.get_default().wired.get_icon_name(); - - case AstalNetwork.Primary.WIFI: return AstalNetwork.get_default().wifi.get_icon_name(); - } - - return "network-no-route-symbolic"; - }); - - return new Widget.Box({ - className: "status-icons", - spacing: 8, - children: [ - new Widget.Icon({ - className: "bluetooth state", - visible: bind(AstalBluetooth.get_default(), "adapter").as(Boolean), - icon: bluetoothIcon(), - onDestroy: () => bluetoothIcon.drop() - } as Widget.IconProps), - new Widget.Icon({ - className: "network state", - icon: networkIcon(), - onDestroy: () => networkIcon.drop() - } as Widget.IconProps), - new Widget.Box({ - children: [ - new Widget.Icon({ - className: "bell state", - icon: bind(Notifications.getDefault().getNotifd(), "dontDisturb").as((dnd) => - dnd ? "minus-circle-filled-symbolic" - : "preferences-system-notifications-symbolic") - } as Widget.IconProps), - new Widget.Icon({ - className: "notification-count", - visible: bind(Notifications.getDefault(), "history").as(history => - history.length > 0), - icon: "circle-filled-symbolic" - } as Widget.IconProps) - ] - } as Widget.BoxProps) - ] - } as Widget.BoxProps); -} diff --git a/ags/widget/bar/Tray.ts b/ags/widget/bar/Tray.ts deleted file mode 100644 index 512b442d..00000000 --- a/ags/widget/bar/Tray.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { bind, Gio, Variable } from "astal"; -import { Astal, Gdk, Gtk, Widget } from "astal/gtk3"; -import AstalTray from "gi://AstalTray" - -const astalTray = AstalTray.get_default(); - -function menuFromModel(model: Gio.MenuModel, actionGroup: Gio.ActionGroup | null): Gtk.Menu { - const menu = Gtk.Menu.new_from_model(model); - menu.insert_action_group("dbusmenu", actionGroup) - - return menu; -} - -export function Tray(): Gtk.Widget { - return new Widget.Box({ - className: "tray", - visible: bind(astalTray, "items").as((items: Array) => items.length > 0), - children: bind(astalTray, "items").as((items: Array) => items - .filter(item => item?.gicon) - .map((item: AstalTray.TrayItem) => - new Widget.Box({ - className: "item", - child: Variable.derive( - [ bind(item, "menuModel"), bind(item, "actionGroup") ], - (menuModel: Gio.MenuModel, actionGroup: Gio.ActionGroup) => { - const menu = menuFromModel(menuModel, actionGroup); - - return new Widget.Button({ - className: "item-button", - tooltipMarkup: bind(item, "tooltipMarkup"), - onClick: (_, event: Astal.ClickEvent) => { - if(event.button === Astal.MouseButton.SECONDARY) { - item.about_to_show(); - menu.popup_at_widget(_, Gdk.Gravity.NORTH, Gdk.Gravity.SOUTH_WEST, null); - } else if(event.button === Astal.MouseButton.PRIMARY) - item.activate(event.x, event.y); - }, - halign: Gtk.Align.CENTER, - child: new Widget.Icon({ - gIcon: bind(item, "gicon") - }) - } as Widget.ButtonProps) - } - )() - } as Widget.BoxProps) - ) - ) - } as Widget.BoxProps); -} diff --git a/ags/widget/bar/Workspaces.ts b/ags/widget/bar/Workspaces.ts deleted file mode 100644 index 217ed60d..00000000 --- a/ags/widget/bar/Workspaces.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { bind, Variable } from "astal"; -import { Gtk, Widget } from "astal/gtk3"; -import AstalHyprland from "gi://AstalHyprland"; -import { getAppIcon, getSymbolicIcon } from "../../scripts/apps"; -import { Windows } from "../../windows"; -import { Config } from "../../scripts/config"; -import { Separator, SeparatorProps } from "../Separator"; - -let showWsNum: (Variable|undefined); -export const showWorkspaceNumber = (show: boolean) => - showWsNum?.set(show); - - -export function Workspaces(): Gtk.Widget { - showWsNum ??= new Variable(false); - - return new Widget.Box({ - className: "workspaces-row", - orientation: Gtk.Orientation.HORIZONTAL, - children: [ - new Widget.EventBox({ - className: "special", - visible: bind(AstalHyprland.get_default(), "workspaces").as((workspaces) => - workspaces.filter(ws => ws.id < 0).sort((a, b) => a.id - b.id).length > 0), - child: new Widget.Box({ - className: "special-workspaces", - spacing: 4, - children: bind(AstalHyprland.get_default(), "workspaces").as((workspaces) => - workspaces.filter(ws => ws.id < 0).sort((a, b) => a.id - b.id).map((workspace) => - new Widget.EventBox({ - className: bind(AstalHyprland.get_default(), "focusedWorkspace").as(focusWs => - `${focusWs.id === workspace.id ? "focus" : ""}`), - tooltipText: bind(workspace, "name").as((name) => { - name = name.replace(/^special\:/, ""); - return name.charAt(0).toUpperCase().concat(name.substring(1, name.length)); - }), - child: new Widget.Box({ - hexpand: true, - child: bind(workspace, "lastClient").as(lastClient => - new Widget.Icon({ - className: "last-app-icon", - halign: Gtk.Align.CENTER, - visible: Variable.derive([ - bind(workspace, "lastClient"), - bind(AstalHyprland.get_default(), "focusedWorkspace") - ], (lastClient, focusedWorkspace) => focusedWorkspace?.id === workspace.id ? - false : Boolean(lastClient))(), - icon: bind(lastClient, "initialClass").as((initialClass) => - getSymbolicIcon(initialClass) ?? getAppIcon(initialClass) ?? - "application-x-executable-symbolic") - } as Widget.IconProps) - ) - } as Widget.BoxProps), - onClickRelease: () => AstalHyprland.get_default().dispatch( - "togglespecialworkspace", workspace.name.replace(/^special\:/, "") - ) - } as Widget.EventBoxProps) - ) - ) - } as Widget.BoxProps) - } as Widget.EventBoxProps), - Separator({ - alpha: .2, - orientation: Gtk.Orientation.HORIZONTAL, - margin: 12, - spacing: 8, - visible: bind(AstalHyprland.get_default(), "workspaces").as(wss => - wss.filter(ws => ws.id < 0).length > 0) - } as SeparatorProps), - new Widget.EventBox({ - onScroll: (_, event) => - event.delta_y > 0 ? - AstalHyprland.get_default().dispatch("workspace", "e-1") - : AstalHyprland.get_default().dispatch("workspace", "e+1"), - onHover: () => showWorkspaceNumber(true), - onHoverLost: () => showWorkspaceNumber(false), - onDestroy: () => { - // check if the current widgets is from the only bar - if((Windows.openWindows["bar"] as (Array|undefined))?.length === 1) { - showWsNum?.drop(); - showWsNum = undefined; - } - }, - child: new Widget.Box({ - className: "workspaces", - spacing: 4, - children: bind(AstalHyprland.get_default(), "workspaces").as((workspaces) => - workspaces.filter((ws) => ws.id > 0).sort((a, b) => a.id - b.id).map((workspace, wsIndex, workspaces) => { - - const showIds: Variable = Variable.derive([ - Config.getDefault().bindProperty("workspaces.always_show_id", "boolean").as(Boolean), - Config.getDefault().bindProperty("workspaces.enable_helper", "boolean").as(Boolean), - showWsNum!() - ], (alwaysShowIds, enableHelper, showIds) => { - if(enableHelper && !alwaysShowIds) { - const previousWorkspace = workspaces[wsIndex-1]; - const nextWorkspace = workspaces[wsIndex+1]; - - if((workspaces.filter((_, i) => i < wsIndex).length > 0 && - previousWorkspace?.id < (workspace.id-1)) || - (workspaces.filter((_, i) => i > wsIndex).length > 0 && - nextWorkspace?.id > (workspace.id+1))) { - - return true; - } - } - - return alwaysShowIds || showIds; - }); - - const className = Variable.derive([ - bind(AstalHyprland.get_default(), "focusedWorkspace"), - showIds!() - ], (focusedWs, showWsNumbers) => - `${focusedWs.id === workspace.id ? "focus" : ""} ${ - showWsNumbers ? "show" : ""}` - ); - - const tooltipText = Variable.derive([ - bind(workspace, "lastClient"), - bind(AstalHyprland.get_default(), "focusedWorkspace") - ], (lastClient, focusWs) => focusWs.id === workspace.id ? "" : - `Workspace ${workspace.id}${ lastClient ? ` - ${ - !lastClient.title.toLowerCase().includes(lastClient.class) ? - `${lastClient.get_class()}: ` - : "" - } ${lastClient.title}` : "" }` - ); - - return new Widget.EventBox({ - className: className(), - onClickRelease: () => workspace.focus(), - tooltipText: tooltipText(), - onDestroy: () => { - showIds.drop(); - className.drop(); - tooltipText.drop(); - }, - child: new Widget.Box({ - hexpand: true, - children: bind(workspace, "lastClient").as((lastClient) => { - const widgets: Array = [ - new Widget.Revealer({ - transitionDuration: 200, - transitionType: Gtk.RevealerTransitionType.SLIDE_LEFT, - revealChild: showIds!(), - hexpand: true, - child: new Widget.Label({ - label: bind(workspace, "id").as(String), - className: "id", - } as Widget.LabelProps) - } as Widget.RevealerProps), - ]; - - if(lastClient) { - widgets.push(new Widget.Icon({ - className: "last-app-icon", - halign: Gtk.Align.CENTER, - expand: true, - visible: bind(AstalHyprland.get_default(), "focusedWorkspace").as(focusedWorkspace => - workspace.id === focusedWorkspace.id ? - false - : Boolean(lastClient)), - icon: lastClient ? - bind(lastClient, "initialClass").as((clss) => - getSymbolicIcon(clss) ?? getAppIcon(clss) ?? "application-x-executable-symbolic") - : undefined - } as Widget.IconProps)); - } - - return widgets; - }) - } as Widget.BoxProps) - } as Widget.EventBoxProps); - }) - ) - } as Widget.BoxProps) - } as Widget.EventBoxProps) - ] - } as Widget.BoxProps); -} diff --git a/ags/widget/center-window/BigMedia.ts b/ags/widget/center-window/BigMedia.ts deleted file mode 100644 index d283975c..00000000 --- a/ags/widget/center-window/BigMedia.ts +++ /dev/null @@ -1,227 +0,0 @@ -import { AstalIO, bind, Binding, exec, timeout } from "astal"; -import { Gtk, Widget } from "astal/gtk3"; -import AstalMpris from "gi://AstalMpris"; -import { Clipboard } from "../../scripts/clipboard"; - - -export function BigMedia(): Gtk.Widget { - let dragTimer: (AstalIO.Time|undefined); - - return new Widget.Box({ - className: "big-media", - orientation: Gtk.Orientation.VERTICAL, - homogeneous: false, - width_request: 250, - visible: bind(AstalMpris.get_default(), "players").as((players: Array) => - players[0] ? true : false), - children: bind(AstalMpris.get_default(), "players").as((players: Array) => - players[0] && [ - new Widget.Box({ - halign: Gtk.Align.CENTER, - child: new Widget.Box({ - className: "image", - hexpand: false, - orientation: Gtk.Orientation.VERTICAL, - marginTop: 6, - visible: getAlbumArt(players[0]).as(Boolean), - css: getAlbumArt(players[0]).as((artUrl: string|undefined) => - artUrl ? `.image { background-image: url('${artUrl}'); }` : undefined), - width_request: 132, - height_request: 128 - } as Widget.BoxProps) - } as Widget.BoxProps), - new Widget.Box({ - className: "info", - orientation: Gtk.Orientation.VERTICAL, - vexpand: true, - valign: Gtk.Align.CENTER, - children: [ - new Widget.Label({ - className: "title", - tooltipText: bind(players[0], "title").as((title: string) => !title ? "No Title" : title), - label: bind(players[0], "title").as((title: string) => !title ? "No Title" : title), - truncate: true, - maxWidthChars: 25, - } as Widget.LabelProps), - new Widget.Label({ - className: "artist", - tooltipText: bind(players[0], "artist").as((artist: string) => !artist ? "No Artist" : artist), - label: bind(players[0], "artist").as((artist: string) => !artist ? "No Artist" : artist), - maxWidthChars: 28, - truncate: true, - } as Widget.LabelProps) - ] - } as Widget.BoxProps), - new Widget.Box({ - className: "progress", - hexpand: true, - visible: bind(players[0], "canSeek"), - children: [ - new Widget.Slider({ - min: 0, - hexpand: true, - max: bind(players[0], "length").as((length: number) => - Math.floor(length)), - value: bind(players[0], "position").as((position: number) => - Math.floor(position)), - onDragged: (slider: Widget.Slider) => { - if(dragTimer === undefined) - dragTimer = timeout(600, () => - players[0].set_position(Math.round(slider.value))); - else { - dragTimer.cancel(); - dragTimer = timeout(600, () => - players[0].set_position(Math.round(slider.value))); - } - } - }) - ] - }), - new Widget.CenterBox({ - className: "bottom", - homogeneous: false, - hexpand: true, - marginBottom: 6, - startWidget: new Widget.Label({ - className: "elapsed", - valign: Gtk.Align.START, - halign: Gtk.Align.START, - label: bind(players[0], "position").as((pos: number) => { - const sec: number = Math.floor(pos % 60); - return pos > 0 && players[0].length > 0 ? - `${Math.floor(pos / 60)}:${sec < 10 ? "0" : ""}${sec}` - : `0:00`; - }) - } as Widget.LabelProps), - centerWidget: new Widget.Box({ - className: "controls button-row", - children: [ - new Widget.Button({ - className: "link", - image: new Widget.Icon({ - icon: "edit-paste-symbolic" - } as Widget.IconProps), - tooltipText: "Copy link to Clipboard", - visible: bind(players[0], "metadata").as(Boolean), - onClick: async () => { - const link = exec(`playerctl --player=${ - players[0].busName.replace(/^org\.mpris\.MediaPlayer2\./i, "") - } metadata xesam:url`); - - link && Clipboard.getDefault().copyAsync(link); - } - } as Widget.ButtonProps), - new Widget.Button({ - className: "shuffle", - visible: bind(players[0], "shuffleStatus").as((shuffleStatus) => - shuffleStatus !== AstalMpris.Shuffle.UNSUPPORTED), - image: new Widget.Icon({ - icon: bind(players[0], "shuffleStatus").as((shuffleStatus) => - shuffleStatus === AstalMpris.Shuffle.ON ? - "media-playlist-shuffle-symbolic" - : "media-playlist-consecutive-symbolic") - } as Widget.IconProps), - tooltipText: bind(players[0], "shuffleStatus").as((shuffleStatus) => - shuffleStatus === AstalMpris.Shuffle.ON ? - "Shuffle" - : "No shuffle"), - onClick: () => players[0].shuffle() - } as Widget.ButtonProps), - new Widget.Button({ - className: "previous", - image: new Widget.Icon({ - icon: "media-skip-backward-symbolic" - } as Widget.IconProps), - tooltipText: "Previous", - onClick: () => players[0].canGoPrevious && players[0].previous() - } as Widget.ButtonProps), - new Widget.Button({ - className: "pause", - tooltipText: bind(players[0], "playback_status").as((status) => - status === AstalMpris.PlaybackStatus.PLAYING ? "Pause" : "Play"), - image: new Widget.Icon({ - icon: bind(players[0], "playbackStatus").as((status) => - status === AstalMpris.PlaybackStatus.PLAYING ? - "media-playback-pause-symbolic" - : "media-playback-start-symbolic"), - } as Widget.IconProps), - onClick: () => players[0].playbackStatus === AstalMpris.PlaybackStatus.PAUSED ? - players[0].play() - : players[0].pause() - } as Widget.ButtonProps), - new Widget.Button({ - className: "next", - image: new Widget.Icon({ - icon: "media-skip-forward-symbolic" - } as Widget.IconProps), - tooltipText: "Next", - onClick: () => players[0].canGoNext && players[0].next() - } as Widget.ButtonProps), - new Widget.Button({ - className: "repeat", - visible: bind(players[0], "loopStatus").as((loopStatus) => - loopStatus !== AstalMpris.Loop.UNSUPPORTED), - image: new Widget.Icon({ - icon: bind(players[0], "loopStatus").as((loopStatus) => { - switch(loopStatus) { - case AstalMpris.Loop.TRACK: - return "media-playlist-repeat-song-symbolic"; - - case AstalMpris.Loop.PLAYLIST: - return "media-playlist-repeat-symbolic"; - } - - return "loop-arrow-symbolic"; - }) - } as Widget.IconProps), - tooltipText: bind(players[0], "loopStatus").as((loopStatus) => { - switch(loopStatus) { - case AstalMpris.Loop.TRACK: - return "Loop song"; - - case AstalMpris.Loop.PLAYLIST: - return "Loop playlist"; - } - - return "No loop"; - }), - onClick: () => players[0].loop() - } as Widget.ButtonProps) - ] - } as Widget.BoxProps), - endWidget: new Widget.Label({ - className: "length", - valign: Gtk.Align.START, - halign: Gtk.Align.END, - label: bind(players[0], "length").as((len/* bananananananana */: number) => { - const sec: number = Math.floor(len % 60); - return (len > 0 && Number.isFinite(len)) ? - `${Math.floor(len / 60)}:${sec < 10 ? "0" : ""}${sec}` - : "0:00"; - }) - } as Widget.LabelProps) - }) - ]) - } as Widget.BoxProps); -} - - -/** - * This function handles album art/cover of playing media. If a file is provided - * by the player, it adds the "file://" uri as a prefix, so you can use it in css. - * - * @param player the player you want to pull album art from - * @returns Binding to player.artUrl containing the album art uri, or an undefined binding ig none was found. -* */ -function getAlbumArt(player: AstalMpris.Player): Binding { - return bind(player, "artUrl").as((artUrl: string) => { - - if(!artUrl) - return undefined; - - if(artUrl.startsWith("/")) - return "file://" + artUrl; - - return artUrl; - }); -} diff --git a/ags/widget/center-window/Calendar.ts b/ags/widget/center-window/Calendar.ts deleted file mode 100644 index 4fa52718..00000000 --- a/ags/widget/center-window/Calendar.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { register, Variable } from "astal"; -import { Gtk, Widget } from "astal/gtk3"; - -type CalendarProps = Pick & { - - showWeekDays?: boolean; - showHeader?: boolean; - fillGrid?: boolean; // I need a better name for this LMAOOO -}; - -@register({ GTypeName: "Calendar" }) -class Calendar extends Gtk.Box { - #showWeekDays = new Variable(true); - #showHeader = new Variable(true); - #fillGrid = new Variable(false); - - set fillGrid(newValue: boolean) { this.#fillGrid.set(newValue); } - get fillGrid() { return this.#fillGrid.get(); } - set showHeader(newValue: boolean) { this.#showHeader.set(newValue); } - get showHeader() { return this.#showHeader.get(); } - set showWeekDays(newValue: boolean) { this.#showWeekDays.set(newValue); } - get showWeekDays() { return this.#showWeekDays.get(); } - - constructor(props?: CalendarProps) { - super(); - this.add(new Widget.Box({ - ...props, - widthRequest: 128, - heightRequest: 128, - children: [ - new Widget.Box({ - className: "header", - heightRequest: 24, - hexpand: true, - - } as Widget.BoxProps) - ] - } as Widget.BoxProps)); - } -} diff --git a/ags/widget/control-center/NotifHistory.ts b/ags/widget/control-center/NotifHistory.ts deleted file mode 100644 index a5728056..00000000 --- a/ags/widget/control-center/NotifHistory.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { bind } from "astal"; -import { Gtk, Widget } from "astal/gtk3"; -import { HistoryNotification, Notifications } from "../../scripts/notifications"; -import { NotificationWidget } from "../Notification"; -import { tr } from "../../i18n/intl"; - - -export const NotifHistory = () => { - return new Widget.Box({ - orientation: Gtk.Orientation.VERTICAL, - className: bind(Notifications.getDefault(), "history").as(history => history.length > 0 ? "history" : "history hide"), - children: [ - new Widget.Scrollable({ - className: "history", - hscroll: Gtk.PolicyType.NEVER, - vscroll: Gtk.PolicyType.AUTOMATIC, - propagateNaturalHeight: true, - propagateNaturalWidth: false, - onDraw: (scrollable) => { - if(!(scrollable.get_child()! as Gtk.Viewport).get_child()) return; - - scrollable.minContentHeight = - ((scrollable.get_child()! as Gtk.Viewport).get_child() as Widget.Box - ).get_children()?.[0].get_allocation().height - || 0; - }, - child: new Widget.Box({ - className: "notifications", - hexpand: true, - orientation: Gtk.Orientation.VERTICAL, - homogeneous: false, - spacing: 4, - valign: Gtk.Align.START, - children: bind(Notifications.getDefault(), "history").as((history: Array) => - history.map((notification: HistoryNotification) => NotificationWidget(notification, - () => Notifications.getDefault().removeHistory(notification.id), true) - )) - } as Widget.BoxProps) - } as Widget.ScrollableProps), - new Widget.Box({ - vexpand: false, - hexpand: true, - halign: Gtk.Align.END, - className: "button-row", - children: [ - new Widget.Button({ - className: "clear-all", - child: new Widget.Box({ - children: [ - new Widget.Icon({ - css: "margin-right: 6px;", - icon: "edit-clear-all-symbolic" - } as Widget.IconProps), - new Widget.Label({ - label: tr("clear") - } as Widget.LabelProps) - ] - } as Widget.BoxProps), - onClick: () => Notifications.getDefault().clearHistory(), - } as Widget.ButtonProps) - ] - }) - ] - } as Widget.BoxProps); -} diff --git a/ags/widget/control-center/Pages.ts b/ags/widget/control-center/Pages.ts deleted file mode 100644 index ef108d22..00000000 --- a/ags/widget/control-center/Pages.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { register, timeout } from "astal"; -import { Gtk, Widget } from "astal/gtk3"; -import { Page } from "./pages/Page"; - - -export { Pages }; -export type PagesProps = { - initialPage?: Page; - className?: string; - transitionDuration?: number; -}; - -@register({ GTypeName: "Pages" }) -class Pages extends Widget.Box { - #page: (Page|undefined); - #transDuration: number; - #transType: Gtk.RevealerTransitionType = Gtk.RevealerTransitionType.SLIDE_DOWN; - - get isOpen() { return (this.get_children().length > 0); } - - constructor(props?: PagesProps) { - super({ - className: props?.className, - orientation: Gtk.Orientation.VERTICAL - }); - - this.name = "pages"; - - if(props?.className !== null && props?.className !== undefined) - this.className = props?.className; - - this.#transDuration = props?.transitionDuration ?? 280; - - if(props?.initialPage) - this.open(props.initialPage); - } - - toggle(newPage?: Page, onToggled?: () => void): void { - if(!newPage || (this.#page?.id === newPage?.id)) { - this.close(onToggled); - return; - } - - if(!this.isOpen) { - newPage && this.open(newPage, onToggled); - return; - } - - if(this.#page?.id !== newPage.id) { - this.close(); - this.open(newPage, onToggled); - } - } - - open(newPage: Page, onOpened?: () => void) { - this.add(new Widget.Revealer({ - transitionDuration: this.#transDuration, - transitionType: this.#transType, - revealChild: false, - child: newPage - } as Widget.RevealerProps)); - this.#page = newPage; - - this.reorder_child(this.get_children()[this.get_children().length - 1], 0); - (this.get_children()[0] as Widget.Revealer).set_reveal_child(true); - onOpened?.(); - } - - close(onClosed?: () => void): void { - (this.get_children() as Array).forEach((pageRevealer, i, pageRevealers) => { - pageRevealer.set_reveal_child(false); - if(this.#page?.id === (pageRevealer.get_child() as Page).id) - this.#page = undefined; - - timeout(this.#transDuration, () => { - this.remove(pageRevealer); - pageRevealer.destroy(); - - i === (pageRevealers.length - 1) && - onClosed?.(); - }); - }); - } -} diff --git a/ags/widget/control-center/QuickActions.ts b/ags/widget/control-center/QuickActions.ts deleted file mode 100644 index b3b40fc4..00000000 --- a/ags/widget/control-center/QuickActions.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { exec, GLib, Variable } from "astal"; -import { Gtk, Widget } from "astal/gtk3"; -import { Windows } from "../../windows"; -import { Wallpaper } from "../../scripts/wallpaper"; -import { execApp } from "../../scripts/apps"; - - -function LockButton(): Widget.Button { - return new Widget.Button({ - image: new Widget.Icon({ - icon: "system-lock-screen-symbolic" - } as Widget.IconProps), - onClick: () => { - Windows.close("control-center"); - execApp("hyprlock"); - } - } as Widget.ButtonProps) -} - -function ColorPickerButton(): Widget.Button { - return new Widget.Button({ - image: new Widget.Icon({ - icon: "color-select-symbolic" - } as Widget.IconProps), - onClick: () => { - Windows.close("control-center"); - execApp("sh $HOME/.config/hypr/scripts/color-picker.sh"); - } - } as Widget.ButtonProps) -} - -function ScreenshotButton(): Widget.Button { - return new Widget.Button({ - image: new Widget.Icon({ - icon: "applets-screenshooter-symbolic" - } as Widget.IconProps), - onClick: () => { - Windows.close("control-center"); - execApp(`sh ${GLib.get_user_config_dir()}/hypr/scripts/screenshot.sh`); - } - } as Widget.ButtonProps); -} - -function SelectWallpaperButton(): Widget.Button { - return new Widget.Button({ - image: new Widget.Icon({ - icon: "preferences-desktop-wallpaper-symbolic" - } as Widget.IconProps), - onClick: () => { - Windows.close("control-center"); - Wallpaper.getDefault().pickWallpaper(); - } - } as Widget.ButtonProps); -} - -function LogoutButton(): Widget.Button { - return new Widget.Button({ - image: new Widget.Icon({ - icon: "system-shutdown-symbolic" - } as Widget.IconProps), - onClick: () => { - Windows.close("control-center"); - Windows.open("logout-menu"); - } - } as Widget.ButtonProps); -} - -export const QuickActions = () => { - const uptime = new Variable("Just turned on").poll(1000, - () => exec("uptime -p").replace(/^up /, "")); - - return new Widget.Box({ - className: "quickactions", - children: [ - new Widget.Box({ - orientation: Gtk.Orientation.VERTICAL, - halign: Gtk.Align.START, - hexpand: true, - className: "left", - children: [ - new Widget.Label({ - className: "hostname", - xalign: 0, - tooltipText: "Host name", - label: GLib.get_host_name() - } as Widget.LabelProps), - new Widget.Box({ - children: [ - new Widget.Icon({ - icon: "hourglass-symbolic" - } as Widget.IconProps), - new Widget.Label({ - className: "uptime", - xalign: 0, - tooltipText: "Uptime", - onDestroy: () => uptime.drop(), - label: uptime() - } as Widget.LabelProps) - ] - } as Widget.BoxProps) - ] - } as Widget.BoxProps), - new Widget.Box({ - orientation: Gtk.Orientation.HORIZONTAL, - className: "right button-row", - halign: Gtk.Align.END, - hexpand: true, - children: [ - LockButton(), - ColorPickerButton(), - ScreenshotButton(), - SelectWallpaperButton(), - LogoutButton() - ] - } as Widget.BoxProps) - ] - } as Widget.BoxProps); -} diff --git a/ags/widget/control-center/Sliders.ts b/ags/widget/control-center/Sliders.ts deleted file mode 100644 index 156134ad..00000000 --- a/ags/widget/control-center/Sliders.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { bind } from "astal"; -import { Gtk, Widget } from "astal/gtk3"; -import { Wireplumber } from "../../scripts/volume"; -import { Pages } from "./Pages"; -import { PageSound } from "./pages/Sound"; -import { PageMicrophone } from "./pages/Microphone"; - -export function Sliders() { - const slidersPages = new Pages(); - - return new Widget.Box({ - className: "sliders", - orientation: Gtk.Orientation.VERTICAL, - expand: true, - spacing: 10, - children: [ - new Widget.Box({ - className: "sink speaker", - spacing: 3, - children: bind(Wireplumber.getWireplumber(), "defaultSpeaker").as((sink) => [ - new Widget.Button({ - onClick: () => Wireplumber.getDefault().toggleMuteSink(), - image: new Widget.Icon ({ - icon: bind(sink, "volumeIcon").as((icon) => - !Wireplumber.getDefault().isMutedSink() && Wireplumber.getDefault().getSinkVolume() > 0 ? icon : "audio-volume-muted-symbolic"), - } as Widget.IconProps), - } as Widget.ButtonProps), - new Widget.Slider({ - drawValue: false, - hexpand: true, - setup: (slider) => slider.value = Math.floor(sink.volume * 100), - value: bind(sink, "volume").as((vol) => Math.floor(vol * 100)), - max: Wireplumber.getDefault().getMaxSinkVolume(), - onDragged: (slider) => sink.volume = slider.value / 100 - } as Widget.SliderProps), - new Widget.Button({ - className: "more", - image: new Widget.Icon({ - icon: "go-next-symbolic", - } as Widget.IconProps), - onClick: (_) => slidersPages.toggle(PageSound()) - } as Widget.ButtonProps) - ]) - } as Widget.BoxProps), - new Widget.Box({ - className: "source microphone", - spacing: 3, - children: bind(Wireplumber.getWireplumber(), "defaultMicrophone").as((source) => [ - new Widget.Button({ - onClick: () => Wireplumber.getDefault().toggleMuteSource(), - image: new Widget.Icon ({ - icon: bind(source, "volumeIcon").as((icon) => - !Wireplumber.getDefault().isMutedSource() && Wireplumber.getDefault().getSourceVolume() > 0 ? icon : "microphone-sensitivity-muted-symbolic"), - } as Widget.IconProps), - } as Widget.ButtonProps), - new Widget.Slider({ - drawValue: false, - hexpand: true, - setup: (slider) => slider.set_value(Math.floor(source.volume * 100)), - value: bind(source, "volume").as((vol) => Math.floor(vol * 100)), - max: Wireplumber.getDefault().getMaxSourceVolume(), - onDragged: (slider) => source.volume = slider.value / 100 - } as Widget.SliderProps), - new Widget.Button({ - className: "more", - image: new Widget.Icon({ - icon: "go-next-symbolic", - } as Widget.IconProps), - onClick: (_) => slidersPages.toggle(PageMicrophone()) - } as Widget.ButtonProps) - ]) - } as Widget.BoxProps), - slidersPages - ] - } as Widget.BoxProps); -} diff --git a/ags/widget/control-center/Tiles.ts b/ags/widget/control-center/Tiles.ts deleted file mode 100644 index 5630a2af..00000000 --- a/ags/widget/control-center/Tiles.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Gtk, Widget } from "astal/gtk3"; -import { TileNetwork } from "./tiles/Network"; -import { TileBluetooth } from "./tiles/Bluetooth"; -import { TileDND } from "./tiles/DoNotDisturb"; -import { TileRecording } from "./tiles/Recording"; -import { TileNightLight } from "./tiles/NightLight"; -import { Pages } from "./Pages"; -import { GObject } from "astal"; - -export const tileList: Array<() => Gtk.Widget> = [ - TileNetwork, - TileBluetooth, - TileRecording, - TileDND, - TileNightLight -]; - -export let TilesPages: (Pages|null) = null; - -export function Tiles(): Gtk.Widget { - const tilesFlowBox: Gtk.FlowBox = new Gtk.FlowBox({ - visible: true, - orientation: Gtk.Orientation.HORIZONTAL, - rowSpacing: 6, - columnSpacing: 6, - minChildrenPerLine: 2, - maxChildrenPerLine: 2, - expand: true, - homogeneous: true, - } as Gtk.FlowBox.ConstructorProps); - - tileList.map((item: (() => Gtk.Widget)) => { - const tile = item(); - tilesFlowBox.insert(tile, -1); - - const children = tilesFlowBox.get_children(); - children[children.length-1]!.set_can_focus(false); - const binding: GObject.Binding = tile.bind_property("visible", - children[children.length-1], "visible", - GObject.BindingFlags.SYNC_CREATE); - - const destroyId: number = tile.connect("destroy-event", (self: typeof tile) => { - binding.unbind(); - self.disconnect(destroyId); - }); - }); - - return new Widget.Box({ - className: "tiles-container", - orientation: Gtk.Orientation.VERTICAL, - onDestroy: () => TilesPages = null, - setup: (box) => { - if(!TilesPages) TilesPages = new Pages({ - className: "tile-pages" - }); - - box.set_children([ - tilesFlowBox, - TilesPages! - ]); - } - } as Widget.BoxProps); -} diff --git a/ags/widget/control-center/pages/Bluetooth.ts b/ags/widget/control-center/pages/Bluetooth.ts deleted file mode 100644 index 5d93a4f5..00000000 --- a/ags/widget/control-center/pages/Bluetooth.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { bind, Gio, Variable } from "astal"; -import { Gtk, Widget } from "astal/gtk3"; -import AstalBluetooth from "gi://AstalBluetooth"; -import { Page, PageButton } from "./Page"; -import { tr } from "../../../i18n/intl"; -import { Windows } from "../../../windows"; -import { Notifications } from "../../../scripts/notifications"; -import AstalNotifd from "gi://AstalNotifd"; -import { execApp } from "../../../scripts/apps"; - -export const BluetoothPage: (() => Page) = () => new Page({ - id: "bluetooth", - title: tr("control_center.pages.bluetooth.title"), - description: tr("control_center.pages.bluetooth.description"), - className: "bluetooth", - headerButtons: [ - new Widget.Button({ - className: "discover", - image: new Widget.Icon({ - icon: bind(AstalBluetooth.get_default().adapter, "discovering").as((discovering) => - !discovering ? - "arrow-circular-top-right-symbolic" - : "media-playback-stop-symbolic") - } as Widget.IconProps), - tooltipText: bind(AstalBluetooth.get_default().adapter, "discovering").as((discovering) => - !discovering ? - tr("control_center.pages.bluetooth.start_discovering") - : tr("control_center.pages.bluetooth.stop_discovering")), - onClick: () => { - if(AstalBluetooth.get_default().adapter.discovering) { - AstalBluetooth.get_default().adapter.stop_discovery(); - return; - } - - AstalBluetooth.get_default().adapter.start_discovery(); - } - } as Widget.ButtonProps) - ], - onClose: () => AstalBluetooth.get_default().adapter.discovering && - AstalBluetooth.get_default().adapter.stop_discovery(), - bottomButtons: [{ - title: tr("control_center.pages.more_settings"), - onClick: () => { - Windows.close("control-center"); - execApp("overskride", "[float; animation slide right]"); - } - }], - spacing: 2, - children: [ - new Widget.Box({ - className: "adapters", - visible: bind(AstalBluetooth.get_default(), "adapters").as((adapters) => - adapters.length > 1), - spacing: 2, - children: bind(AstalBluetooth.get_default(), "adapters").as((adapters) => [ - new Widget.Label({ - className: "sub-header", - label: tr("control_center.pages.bluetooth.adapters") - } as Widget.LabelProps), - ...adapters.map(adapter => - PageButton({ - title: adapter.alias ?? "Adapter", - icon: "bluetooth-active-symbolic", - onClick: () => AstalBluetooth.get_default(), - }) - ) - ] - ) - } as Widget.BoxProps), - new Widget.Box({ - className: "connections", - orientation: Gtk.Orientation.VERTICAL, - hexpand: true, - spacing: 2, - children: [ - new Widget.Box({ - className: "paired", - orientation: Gtk.Orientation.VERTICAL, - spacing: 2, - visible: bind(AstalBluetooth.get_default(), "devices").as((devs) => - devs.filter(dev => dev.paired || dev.connected || dev.trusted).length > 0), - children: bind(AstalBluetooth.get_default(), "devices").as((devs) => { - const connectedDevices = devs.filter((dev) => dev.connected || dev.paired || dev.trusted) - - return [ - new Widget.Label({ - className: "sub-header", - label: tr("devices"), - xalign: 0, - } as Widget.LabelProps), - ...connectedDevices.map((dev) => DeviceWidget(dev)) - ] - }) - } as Widget.BoxProps), - new Widget.Box({ - className: "discovered", - orientation: Gtk.Orientation.VERTICAL, - spacing: 2, - visible: bind(AstalBluetooth.get_default(), "devices").as((devs) => - devs.filter((dev) => !dev.connected && !dev.paired && !dev.trusted).length > 0), - children: bind(AstalBluetooth.get_default(), "devices").as((devices) => { - const discoveredDevices = devices.filter((dev) => !dev.connected && !dev.paired && !dev.trusted); - - return [ - new Widget.Label({ - className: "sub-header", - label: tr("control_center.pages.bluetooth.new_devices"), - xalign: 0 - } as Widget.LabelProps), - ...discoveredDevices.map((dev: AstalBluetooth.Device) => DeviceWidget(dev)) - ] - }) - } as Widget.BoxProps) - ] - } as Widget.BoxProps) - ] -}); - -function DeviceWidget(dev: AstalBluetooth.Device): Gtk.Widget { - const devActions: Variable> = Variable.derive([ - bind(dev, "connected"), - bind(dev, "paired"), - bind(dev, "trusted") - ], (connected, paired, trusted) => paired ? [ - new Widget.Button({ - image: new Widget.Icon({ - icon: connected ? - "list-remove-symbolic" - : "user-trash-symbolic" - } as Widget.IconProps), - tooltipText: tr(connected ? "disconnect" : "control_center.pages.bluetooth.unpair_device"), - onClick: () => { - if(!connected) { - AstalBluetooth.get_default().adapter?.remove_device(dev); - return; - } - - dev.disconnect_device(null); - }, - } as Widget.ButtonProps), - new Widget.Button({ - image: new Widget.Icon({ - icon: trusted ? - "shield-safe-symbolic" - : "shield-danger-symbolic" - } as Widget.IconProps), - tooltipText: tr(`control_center.pages.bluetooth.${trusted ? "un": ""}trust_device`), - onClick: () => dev.set_trusted(!trusted) - } as Widget.ButtonProps) - ] : []); - - return PageButton({ - className: bind(dev, "connected").as((connected) => connected ? "connected" : ""), - title: bind(dev, "alias").as(alias => alias ?? "Unknown Device"), - icon: dev.icon ?? "bluetooth-active-symbolic", - description: bind(dev, "connecting").as(connecting => - connecting ? `${tr("connecting")}...` : ""), - tooltipText: bind(dev, "connected").as(connected => !connected ? - tr("connect") - : ""), - onDestroy: () => devActions.drop(), - onClick: () => { - if(dev.connected) return; - - let skipConnection: boolean = false; - if(!dev.paired) - (async () => dev.pair())().catch((err: Gio.IOErrorEnum) => { - skipConnection = true; - Notifications.getDefault().sendNotification({ - appName: "bluetooth", - summary: "Device pairing error", - body: `Couldn't connect to ${dev.alias ?? dev.name}, an error occurred: ${err.message || err.stack}`, - urgency: AstalNotifd.Urgency.NORMAL - }) - }).then(() => dev.set_trusted(true)); - - if(!skipConnection) - (async () => dev.connect_device(null))().catch((err: Gio.IOErrorEnum) => - Notifications.getDefault().sendNotification({ - appName: "bluetooth", - summary: "Device connection error", - body: `Couldn't connect to ${dev.alias ?? dev.name}, an error occurred: ${err.message || err.stack}`, - urgency: AstalNotifd.Urgency.NORMAL - }) - ); - }, - endWidget: new Widget.Box({ - visible: bind(dev, "batteryPercentage").as((batt: number) => - batt <= -1 ? false : true), - children: [ - new Widget.Box({ - visible: bind(dev, "connected"), - children: [ - new Widget.Label({ - halign: Gtk.Align.END, - label: bind(dev, "batteryPercentage").as((batt: number) => - `${Math.floor(batt * 100)}%`) - } as Widget.LabelProps), - new Widget.Icon({ - icon: bind(dev, "batteryPercentage").as(batt => - `battery-level-${Math.floor(batt * 100)}-symbolic`), - css: "font-size: 16px; margin-left: 6px;" - } as Widget.IconProps) - ] - } as Widget.BoxProps) - ] - } as Widget.BoxProps), - extraButtons: devActions() - }); -} diff --git a/ags/widget/control-center/pages/Microphone.ts b/ags/widget/control-center/pages/Microphone.ts deleted file mode 100644 index 3d101cc3..00000000 --- a/ags/widget/control-center/pages/Microphone.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { bind } from "astal"; -import { Page, PageButton, PageProps } from "./Page"; -import { Wireplumber } from "../../../scripts/volume"; -import { Astal, Widget } from "astal/gtk3"; -import { tr } from "../../../i18n/intl"; - - -export function PageMicrophone(): Page { - return new Page({ - id: "microphone", - title: tr("control_center.pages.microphone.title"), - description: tr("control_center.pages.microphone.description"), - children: bind(Wireplumber.getWireplumber().get_audio()!, "microphones").as((microphones) => [ - new Widget.Label({ - className: "sub-header", - label: tr("devices"), - xalign: 0 - } as Widget.LabelProps), - ...microphones.map((microphone) => - PageButton({ - className: bind(microphone, "isDefault").as(isDefault => isDefault ? "default" : ""), - icon: bind(microphone, "icon").as(icon => - Astal.Icon.lookup_icon(icon) ? icon : "audio-input-microphone-symbolic"), - title: bind(microphone, "description").as(desc => desc ?? "Microphone"), - onClick: () => microphone.set_is_default(true), - endWidget: new Widget.Icon({ - icon: "object-select-symbolic", - visible: bind(microphone, "isDefault"), - css: "font-size: 18px;" - } as Widget.IconProps) - }) - ) - ]) - } as PageProps); -} diff --git a/ags/widget/control-center/pages/Network.ts b/ags/widget/control-center/pages/Network.ts deleted file mode 100644 index 3f39b774..00000000 --- a/ags/widget/control-center/pages/Network.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { Gtk, Widget } from "astal/gtk3"; -import { Page, PageButton } from "./Page"; -import AstalNetwork from "gi://AstalNetwork"; -import { bind, GLib } from "astal"; -import NM from "gi://NM"; -import { Windows } from "../../../windows"; -import { tr } from "../../../i18n/intl"; -import { execApp } from "../../../scripts/apps"; -import { EntryPopup, EntryPopupProps } from "../../EntryPopup"; -import { Notifications } from "../../../scripts/notifications"; -import { AskPopup, AskPopupProps } from "../../AskPopup"; -import { encoder } from "../../../scripts/utils"; - -export const PageNetwork: (() => Page) = () => new Page({ - id: "network", - title: tr("control_center.pages.network.title"), - className: "network", - headerButtons: [ - new Widget.Button({ - className: "reload", - image: new Widget.Icon({ - icon: "arrow-circular-top-right-symbolic" - } as Widget.IconProps), - visible: bind(AstalNetwork.get_default(), "primary").as((primary) => - primary === AstalNetwork.Primary.WIFI), - tooltipText: "Re-scan connections", - onClick: () => AstalNetwork.get_default().wifi.scan() - } as Widget.ButtonProps) - ], - bottomButtons: [{ - title: tr("control_center.pages.more_settings"), - onClick: () => { - Windows.close("control-center"); - execApp("nm-connection-editor", "[animationstyle gnomed]"); - } - }], - children: [ - new Widget.Box({ - className: "devices", - hexpand: true, - orientation: Gtk.Orientation.VERTICAL, - visible: bind(AstalNetwork.get_default().get_client(), "devices").as((devs) => devs.length > 0), - children: bind(AstalNetwork.get_default().get_client(), "devices").as((devices) => { - devices = devices.filter(dev => dev.interface !== "lo"); - - return [ - new Widget.Label({ - label: tr("devices"), - xalign: 0, - className: "sub-header", - } as Widget.LabelProps), - ...devices.filter(device => device.real).map(dev => PageButton({ - className: "device", - icon: bind(dev, "deviceType").as(deviceType => - deviceType === NM.DeviceType.WIFI ? - "network-wireless-symbolic" - : "network-wired-symbolic"), - title: bind(dev, "interface").as(iface => iface ?? - tr("control_center.pages.network.interface")), - extraButtons: [ - new Widget.Button({ - image: new Widget.Icon({ - icon: "view-more-symbolic" - } as Widget.IconProps), - onClick: () => { - Windows.close("control-center"); - execApp( - `nm-connection-editor --edit ${dev.activeConnection?.connection.get_uuid()}`, - "[animationstyle gnomed; float]" - ); - } - } as Widget.ButtonProps) - ] - }) - ) - ] - }) - } as Widget.BoxProps), - new Widget.Box({ - className: "wireless-aps", - visible: bind(AstalNetwork.get_default(), "primary").as((primary) => primary === AstalNetwork.Primary.WIFI), - hexpand: true, - orientation: Gtk.Orientation.VERTICAL, - children: AstalNetwork.get_default().wifi ? bind(AstalNetwork.get_default().wifi, "accessPoints").as((aps) => [ - new Widget.Label({ - className: "sub-header", - label: "Wi-Fi" - } as Widget.LabelProps), - ...aps.filter(ap => ap.ssid).map(ap => PageButton({ - className: bind(AstalNetwork.get_default().wifi, "activeAccessPoint").as(activeAP => - activeAP.ssid === ap.ssid ? "active" : ""), - title: bind(ap, "ssid").as(ssid => - ssid ?? "Unknown SSID"), - icon: bind(ap, "iconName"), - endWidget: new Widget.Icon({ - // @ts-ignore ts-for-gir generated the types wrong - icon: bind(ap, "flags").as(flags => flags & NM["80211ApFlags" as keyof typeof NM].PRIVACY ? - "channel-secure-symbolic" - : "channel-insecure-symbolic"), - css: "font-size: 18px;" - } as Widget.IconProps), - extraButtons: [ - new Widget.Button({ - image: new Widget.Icon({ - icon: "window-close-symbolic", - css: "font-size: 18px;" - } as Widget.IconProps) - } as Widget.ButtonProps) - ], - onClick: () => { - const ssid: string = ap.ssid ?? "Unknown SSID", - ssidBytes = GLib.Bytes.new(encoder.encode(ssid)); - - const connection = new NM.Connection(); - const setting = NM.SettingWireless.new(); - setting.ssid = ssidBytes; - setting.bssid = ap.bssid; - - connection.add_setting(setting); - - // @ts-ignore same as previous, type gen issues - // Check if access point has encryption(needs a password) - if(ap.flags & NM["80211ApFlags" as keyof typeof NM].PRIVACY) { - const passwdPopup = EntryPopup({ - isPassword: true, - title: `${tr("connect")}: ${ssid}`, - acceptText: tr("connect"), - closeOnAccept: false, - text: `Input password for ${ssid}`, - onAccept: (input) => { - const pskSetting = NM.SettingWirelessSecurity.new(); - pskSetting.keyMgmt = "wpa-psk"; - - // @ts-ignore type gen issues (the type exists) - if(ap.flags & NM["80211ApSecurityFlags" as keyof typeof NM].KEY_MGMT_SAE) - pskSetting.keyMgmt = "sae"; - - pskSetting.psk = input; - - AstalNetwork.get_default().get_client().add_connection_async( - connection, true, null, (client, asyncRes) => { - const remoteConnection = client!.add_connection_finish(asyncRes); - if(!remoteConnection) { - notifyConnectionError(ssid); - return; - } - - passwdPopup.close(); - saveToDisk(remoteConnection, ssid); - } - ); - }, - } as EntryPopupProps); - - return; - } - - AstalNetwork.get_default().get_client().add_connection_async(connection, false, null, (_, asyncRes) => { - const remoteConnection = AstalNetwork.get_default().get_client().add_connection_finish(asyncRes); - - if(!remoteConnection) { - notifyConnectionError(ssid); - return; - } - - activateWirelessConnection(remoteConnection, ssid); - }); - } - })) - ] - ) : [], - } as Widget.BoxProps) - ] -}); - -function activateWirelessConnection(connection: NM.RemoteConnection, ssid: string): void { - AstalNetwork.get_default().get_client().activate_connection_async( - connection, AstalNetwork.get_default().wifi.get_device(), null, null, (_, asyncRes) => { - const activeConnection = AstalNetwork.get_default().get_client().activate_connection_finish(asyncRes); - if(!activeConnection) { - Notifications.getDefault().sendNotification({ - appName: "network", - summary: "Couldn't activate wireless connection", - body: `An error occurred while activating the wireless connection "${ssid}"` - }); - return; - } - } - ); -} - -function notifyConnectionError(ssid: string): void { - Notifications.getDefault().sendNotification({ - appName: "network", - summary: "Coudn't connect Wi-Fi", - body: `An error occurred while trying to connect to the "${ssid}" access point. \nMaybe the password is invalid?` - }); -} -function saveToDisk(remoteConnection: NM.RemoteConnection, ssid: string): void { - AskPopup({ - text: `Save password for connection "${ssid}"?`, - acceptText: "Yes", - onAccept: () => remoteConnection.commit_changes_async(true, null, (_, asyncRes) => - !remoteConnection.commit_changes_finish(asyncRes) && Notifications.getDefault().sendNotification({ - appName: "network", - summary: "Couldn't save Wi-Fi password", - body: `An error occurred while trying to write the password for "${ssid}" to disk` - })) - } as AskPopupProps); -} diff --git a/ags/widget/control-center/pages/NightLight.ts b/ags/widget/control-center/pages/NightLight.ts deleted file mode 100644 index c0d10474..00000000 --- a/ags/widget/control-center/pages/NightLight.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Widget } from "astal/gtk3"; -import { Page, PageProps } from "./Page"; -import { bind } from "astal"; -import { NightLight } from "../../../scripts/nightlight"; -import { addSliderMarksFromMinMax } from "../../../scripts/widget-utils"; -import { tr } from "../../../i18n/intl"; - -export const PageNightLight: (() => Page) = () => new Page({ - id: "night-light", - title: tr("control_center.pages.night_light.title"), - description: tr("control_center.pages.night_light.description"), - className: "night-light", - children: [ - new Widget.Label({ - className: "sub-header", - label: tr("control_center.pages.night_light.temperature"), - xalign: 0 - } as Widget.LabelProps), - new Widget.Slider({ - className: "temperature", - setup: (slider) => { - slider.value = NightLight.getDefault().temperature; - addSliderMarksFromMinMax(slider, 5, "{}K"); - }, - value: bind(NightLight.getDefault(), "temperature"), - tooltipText: bind(NightLight.getDefault(), "temperature").as((temp) => `${temp}K`), - min: 1000, - max: NightLight.getDefault().maxTemperature, - onDragged: (slider) => - NightLight.getDefault().temperature = (Math.floor(slider.value)), - } as Widget.SliderProps), - new Widget.Label({ - className: "sub-header", - label: tr("control_center.pages.night_light.gamma"), - css: "margin-top: 6px;", - xalign: 0 - } as Widget.LabelProps), - new Widget.Slider({ - className: "gamma", - setup: (slider) => { - slider.value = NightLight.getDefault().gamma; - addSliderMarksFromMinMax(slider, 5, "{}%"); - }, - value: bind(NightLight.getDefault(), "gamma"), - max: NightLight.getDefault().maxGamma, - tooltipText: bind(NightLight.getDefault(), "gamma").as((gamma) => `${gamma}%`), - onDragged: (slider) => - NightLight.getDefault().gamma = (Math.floor(slider.value)), - } as Widget.SliderProps) - ] -} as PageProps); diff --git a/ags/widget/control-center/pages/Page.ts b/ags/widget/control-center/pages/Page.ts deleted file mode 100644 index a30f15aa..00000000 --- a/ags/widget/control-center/pages/Page.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { Binding, register } from "astal"; -import { Gtk, Widget } from "astal/gtk3"; -import { Separator, SeparatorProps } from "../../Separator"; - -export type PageProps = { - setup?: () => void; - onClose?: () => void; - id: string; - className?: string | Binding; - title: string | Binding; - description?: string | Binding; - headerButtons?: Array | Binding>; - bottomButtons?: Array | Binding>; - orientation?: Gtk.Orientation | Binding; - spacing?: number; - child?: Gtk.Widget | Binding; - children?: Array | Binding>; -}; - -export type BottomButton = { - title: string | Binding; - description?: string | Binding; - tooltipText?: string | Binding; - tooltipMarkup?: string | Binding; - onClick?: () => void; -}; - -export { Page }; - -@register({ GTypeName: "Page" }) -class Page extends Widget.Box { - readonly #id: string | number; - readonly bottomButtons?: Array; - - #title: string | Binding; - #description?: string | Binding; - - public get title() { return this.#title; } - public get description() { return this.#description; } - public get id() { return this.#id; } - public onClose?: () => void; - - constructor(props: PageProps) { - super({ - hexpand: true, - orientation: Gtk.Orientation.VERTICAL, - className: (props.className instanceof Binding) ? - props.className.as((clsName) => `page ${ clsName ?? "" }`) - : `page ${props.className ?? ""}`, - setup: props.setup, - children: [ - new Widget.Box({ - className: "header", - orientation: Gtk.Orientation.VERTICAL, - hexpand: true, - children: [ - new Widget.Box({ - className: "top", - children: [ - new Widget.Label({ - hexpand: true, - className: "title", - truncate: true, - visible: (props.title instanceof Binding) ? - props.title.as(Boolean) - : (props.title ? true : false), - label: props.title, - halign: Gtk.Align.START - } as Widget.LabelProps), - new Widget.Box({ - className: "button-row", - visible: (props.headerButtons instanceof Binding) ? - props.headerButtons.as(Boolean) - : (props.headerButtons ? true : false), - children: props.headerButtons - } as Widget.BoxProps) - ] - } as Widget.BoxProps), - new Widget.Label({ - className: "description", - hexpand: true, - truncate: true, - xalign: 0, - visible: (props.description instanceof Binding) ? - props.description.as(Boolean) - : props.description ? true : false, - label: props.description - } as Widget.LabelProps), - ] - } as Widget.BoxProps), - new Widget.Box({ - className: "content", - spacing: props.spacing ?? 4, - orientation: props.orientation ?? Gtk.Orientation.VERTICAL, - expand: true, - setup: props.setup, - child: props.child, - children: props.children - } as Widget.BoxProps), - Separator({ - alpha: .2, - spacing: 6, - orientation: Gtk.Orientation.VERTICAL, - visible: (props.bottomButtons instanceof Binding) ? - props.bottomButtons.as(buttons => buttons.length > 0) - : (!props.bottomButtons ? false : props.bottomButtons.length > 0) - } as SeparatorProps), - new Widget.Box({ - className: "bottom-buttons", - orientation: Gtk.Orientation.VERTICAL, - visible: (props.bottomButtons instanceof Binding) ? - props.bottomButtons.as(buttons => buttons.length > 0) - : (!props.bottomButtons ? false : props.bottomButtons.length > 0), - spacing: 2, - children: (props.bottomButtons instanceof Binding) ? - props.bottomButtons.as(buttons => buttons.map(button => - new Widget.Button({ - onClicked: button.onClick, - tooltipMarkup: button.tooltipMarkup, - tooltipText: button.tooltipText, - child: new Widget.Box({ - orientation: Gtk.Orientation.VERTICAL, - children: [ - new Widget.Label({ - className: "title", - label: button.title, - xalign: 0 - } as Widget.LabelProps), - new Widget.Label({ - className: "description", - label: button.description, - visible: Boolean(button.description), - xalign: 0 - } as Widget.LabelProps) - ] - } as Widget.BoxProps) - } as Widget.ButtonProps) - ) - ) - : (!props.bottomButtons ? [] : props.bottomButtons.map(button => - new Widget.Button({ - onClicked: button.onClick, - tooltipMarkup: button.tooltipMarkup, - tooltipText: button.tooltipText, - child: new Widget.Box({ - orientation: Gtk.Orientation.VERTICAL, - children: [ - new Widget.Label({ - className: "title", - label: button.title, - xalign: 0 - } as Widget.LabelProps), - new Widget.Label({ - className: "description", - label: button.description, - visible: Boolean(button.description), - xalign: 0 - } as Widget.LabelProps) - ] - } as Widget.BoxProps) - } as Widget.ButtonProps) - )) - } as Widget.BoxProps) - ] - }); - - this.#id = props.id; - this.#title = props.title; - this.#description = props.description; - - this.onClose = props.onClose; - } -} - -export function PageButton({ onDestroy, ...props }: { - className?: string | Binding; - icon?: string | Binding; - title: string | Binding; - endWidget?: Gtk.Widget | Binding; - description?: string | Binding; - extraButtons?: Array | Binding>; - onDestroy?: (self: Widget.Box) => void; - onClick?: (self: Widget.Button) => void; - tooltipText?: string | Binding; - tooltipMarkup?: string | Binding; -}): Gtk.Widget { - return new Widget.Box({ - onDestroy, - children: [ - new Widget.Button({ - onClick: props.onClick, - className: props.className, - hexpand: true, - tooltipText: props.tooltipText, - tooltipMarkup: props.tooltipMarkup, - child: new Widget.Box({ - className: "page-button", - orientation: Gtk.Orientation.HORIZONTAL, - expand: true, - children: [ - new Widget.Icon({ - className: "icon", - icon: props.icon, - visible: props.icon, - hexpand: false, - css: "font-size: 20px; margin-right: 6px;" - } as Widget.IconProps), - new Widget.Box({ - orientation: Gtk.Orientation.VERTICAL, - hexpand: true, - vexpand: false, - children: [ - new Widget.Label({ - className: "title", - xalign: 0, - // truncating is not working, so I had to do this - label: (props.title instanceof Binding) ? - props.title.as((title) => - `${title.substring(0, 35)}${ - title.length > 35 ? '…' : ""}`) - : `${props.title.substring(0, 35)}${ - props.title.length > 35 ? '…' : ""}`, - tooltipText: props.title, - truncate: true, - } as Widget.LabelProps), - new Widget.Label({ - className: "description", - xalign: 0, - visible: (props.description instanceof Binding) ? - props.description.as(Boolean) - : Boolean(props.description), - label: props.description, - truncate: true, - tooltipText: props.description - } as Widget.LabelProps) - ] - } as Widget.BoxProps), - new Widget.Box({ - visible: (props.endWidget instanceof Binding) ? - props.endWidget.as(Boolean) - : props.endWidget, - halign: Gtk.Align.END, - child: props.endWidget - } as Widget.BoxProps) - ] - } as Widget.BoxProps) - } as Widget.ButtonProps), - new Widget.Box({ - className: "extra-buttons button-row", - visible: (props.extraButtons instanceof Binding) ? - props.extraButtons.as(extra => extra.length > 0) - : (props.extraButtons ? props.extraButtons.length > 0 : false), - children: props.extraButtons - } as Widget.BoxProps) - ] - } as Widget.BoxProps); -} diff --git a/ags/widget/control-center/pages/Sound.ts b/ags/widget/control-center/pages/Sound.ts deleted file mode 100644 index cd0d9fce..00000000 --- a/ags/widget/control-center/pages/Sound.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Page, PageButton, PageProps } from "./Page"; -import { bind, Variable } from "astal"; -import { Astal, Gtk, Widget } from "astal/gtk3"; -import { getAppIcon } from "../../../scripts/apps"; -import { Wireplumber } from "../../../scripts/volume"; -import { tr } from "../../../i18n/intl"; - -export function PageSound(): Page { - const endpoints = Variable.derive([ - bind(Wireplumber.getWireplumber().get_audio()!, "speakers"), - bind(Wireplumber.getWireplumber().get_audio()!, "streams") - ]); - - return new Page({ - id: "sound", - title: tr("control_center.pages.sound.title"), - description: tr("control_center.pages.sound.description"), - onClose: endpoints.drop, - children: endpoints(([speakers, streams]) => [ - new Widget.Label({ - className: "sub-header", - label: tr("devices"), - xalign: 0 - } as Widget.LabelProps), - ...speakers.map((speaker) => - PageButton({ - className: bind(speaker, "isDefault").as(isDefault => isDefault ? "default" : ""), - icon: bind(speaker, "icon").as(icon => - Astal.Icon.lookup_icon(icon)? icon : "audio-card-symbolic"), - title: bind(speaker, "description").as(desc => desc ?? "Speaker"), - onClick: () => speaker.set_is_default(true), - endWidget: new Widget.Icon({ - icon: "object-select-symbolic", - visible: bind(speaker, "isDefault"), - css: "font-size: 18px;" - } as Widget.IconProps) - }) - ), - new Widget.Label({ - className: "sub-header", - label: tr("apps"), - visible: streams.length > 0, - xalign: 0 - } as Widget.LabelProps), - ...streams.map((stream) => - new Widget.EventBox({ - hexpand: true, - setup: (eventbox) => { - const connections: Array = []; - - eventbox.add(new Widget.Box({ - orientation: Gtk.Orientation.HORIZONTAL, - children: [ - new Widget.Icon({ - icon: bind(stream, "name").as(name => - getAppIcon(name.split(' ')[0]) ?? "application-x-executable-symbolic"), - css: "font-size: 18px; margin-right: 6px;" - } as Widget.IconProps), - new Widget.Box({ - orientation: Gtk.Orientation.VERTICAL, - hexpand: true, - children: [ - new Widget.Revealer({ - transitionDuration: 180, - transitionType: Gtk.RevealerTransitionType.SLIDE_DOWN, - setup: (self) => connections.push( - eventbox.connect("hover", () => self.revealChild = true), - eventbox.connect("hover-lost", () => self.revealChild = false) - ), - onDestroy: () => connections.map(id => eventbox.disconnect(id)), - child: new Widget.Label({ - label: bind(stream, "name").as(name => name || "Unknown"), - truncate: true, - tooltipText: bind(stream, "name"), - className: "name", - xalign: 0 - } as Widget.LabelProps) - } as Widget.RevealerProps), - new Widget.Slider({ - min: 0, - drawValue: false, - max: 100, - setup: (self) => self.value = Math.floor(stream.volume * 100), - value: bind(stream, "volume").as((vol) => Math.floor(vol * 100)), - onDragged: (self) => stream.volume = self.value / 100 - } as Widget.SliderProps) - ] - } as Widget.BoxProps) - ] - } as Widget.BoxProps)) - } - } as Widget.EventBoxProps) - ) - ]) - } as PageProps); -} diff --git a/ags/widget/control-center/tiles/Bluetooth.ts b/ags/widget/control-center/tiles/Bluetooth.ts deleted file mode 100644 index 655ab068..00000000 --- a/ags/widget/control-center/tiles/Bluetooth.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { bind, Variable } from "astal"; -import { Tile, TileProps } from "./Tile"; -import AstalBluetooth from "gi://AstalBluetooth"; -import { BluetoothPage } from "../pages/Bluetooth"; -import { TilesPages } from "../Tiles"; - - -export const TileBluetooth = () => { - const icon: Variable = Variable.derive([ - bind(AstalBluetooth.get_default(), "isPowered"), - bind(AstalBluetooth.get_default(), "isConnected") - ], - (powered: boolean, isConnected: boolean) => - powered ? ( isConnected ? - "bluetooth-active-symbolic" - : "bluetooth-symbolic" - ) : "bluetooth-disabled-symbolic" - ); - return Tile({ - title: "Bluetooth", - visible: bind(AstalBluetooth.get_default(), "adapter").as(Boolean), - description: bind(AstalBluetooth.get_default(), "isConnected").as((connected) => { - const connectedDev = AstalBluetooth.get_default().devices.filter(dev => dev.connected)?.[0]; - return connected && connectedDev ? connectedDev.get_alias() : "" - }), - onDestroy: () => icon.drop(), - onToggledOn: () => AstalBluetooth.get_default().adapter?.set_powered(true), - onToggledOff: () => AstalBluetooth.get_default().adapter?.set_powered(false), - onClickMore: () => TilesPages?.toggle(BluetoothPage()), - enableOnClickMore: true, - icon: icon(), - iconSize: 16, - toggleState: bind(AstalBluetooth.get_default(), "isPowered") - } as TileProps)(); -} diff --git a/ags/widget/control-center/tiles/DoNotDisturb.ts b/ags/widget/control-center/tiles/DoNotDisturb.ts deleted file mode 100644 index 5a085886..00000000 --- a/ags/widget/control-center/tiles/DoNotDisturb.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { bind } from "astal"; -import { Notifications } from "../../../scripts/notifications"; -import { Tile } from "./Tile"; -import { tr } from "../../../i18n/intl"; - -export const TileDND = Tile({ - title: tr("control_center.tiles.dnd.title"), - description: bind(Notifications.getDefault().getNotifd(), "dontDisturb").as( - (dnd: boolean) => dnd ? tr("control_center.tiles.enabled") : tr("control_center.tiles.disabled")), - onToggledOff: () => Notifications.getDefault().getNotifd().dontDisturb = false, - onToggledOn: () => Notifications.getDefault().getNotifd().dontDisturb = true, - icon: "minus-circle-filled-symbolic", - iconSize: 16, - toggleState: Notifications.getDefault().getNotifd().dontDisturb -}); diff --git a/ags/widget/control-center/tiles/Network.ts b/ags/widget/control-center/tiles/Network.ts deleted file mode 100644 index a68daaa7..00000000 --- a/ags/widget/control-center/tiles/Network.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { bind, execAsync, Variable } from "astal"; -import { Tile, TileProps } from "./Tile"; -import AstalNetwork from "gi://AstalNetwork"; -import { Widget } from "astal/gtk3"; -import { PageNetwork } from "../pages/Network"; -import { tr } from "../../../i18n/intl"; -import { TilesPages } from "../Tiles"; - -export const TileNetwork = () => new Widget.Box({ - child: Variable.derive([ - bind(AstalNetwork.get_default(), "primary"), - bind(AstalNetwork.get_default(), "wired"), - bind(AstalNetwork.get_default(), "wifi") - ], - (primary: AstalNetwork.Primary, wired: AstalNetwork.Wired, wifi: AstalNetwork.Wifi) => { - if(primary === AstalNetwork.Primary.WIFI) { - return Tile({ - title: tr("control_center.tiles.network.wireless"), - description: Variable.derive( - [ bind(wifi, "ssid"), bind(wifi, "internet") ], - (ssid: string, internet: AstalNetwork.Internet) => - ssid ? ssid : (() => { - switch(internet) { - case AstalNetwork.Internet.CONNECTED: - return tr("connected"); - case AstalNetwork.Internet.DISCONNECTED: - return tr("disconnected"); - case AstalNetwork.Internet.CONNECTING: - return tr("connecting") + "..."; - } - })() - )(), - onToggledOn: () => wifi.set_enabled(true), - onToggledOff: () => wifi.set_enabled(false), - onClickMore: () => TilesPages?.toggle(PageNetwork()), - icon: "network-wireless-signal-excellent-symbolic", - toggleState: bind(wifi, "enabled") - } as TileProps)(); - - } else if(primary === AstalNetwork.Primary.WIRED) { - return Tile({ - title: tr("control_center.tiles.network.wired") || "Wired", - description: bind(wired, "internet").as((internet: AstalNetwork.Internet) => { - switch(internet) { - case AstalNetwork.Internet.CONNECTED: - return tr("connected"); - case AstalNetwork.Internet.DISCONNECTED: - return tr("disconnected"); - case AstalNetwork.Internet.CONNECTING: - return tr("connecting") + "..."; - } - }), - onToggledOn: () => execAsync("nmcli n on"), - onToggledOff: () => execAsync("nmcli n off"), - onClickMore: () => TilesPages?.toggle(PageNetwork()), - icon: bind(wired, "internet").as((internet: AstalNetwork.Internet) => { - switch(internet) { - case AstalNetwork.Internet.CONNECTED: - return "network-wired-symbolic"; - case AstalNetwork.Internet.DISCONNECTED: - return "network-wired-disconnected-symbolic"; - } - - return "network-wired-no-route-symbolic"; - }), - iconSize: 16, - toggleState: bind(wired, "internet").as((internet: AstalNetwork.Internet) => - internet === AstalNetwork.Internet.CONNECTING - || internet === AstalNetwork.Internet.CONNECTED - ) - } as TileProps)(); - } - - return Tile({ - title: tr("control_center.tiles.network.network"), - description: tr("disconnected"), - onToggledOn: () => execAsync("nmcli n on"), - onToggledOff: () => execAsync("nmcli n off"), - onClickMore: () => TilesPages?.toggle(PageNetwork()), - icon: "network-wired-disconnected-symbolic", - iconSize: 16, - toggleState: bind(wired, "internet").as((internet: AstalNetwork.Internet) => - internet === AstalNetwork.Internet.CONNECTING || internet === AstalNetwork.Internet.CONNECTED) - } as TileProps)(); - })() -} as Widget.BoxProps); diff --git a/ags/widget/control-center/tiles/NightLight.ts b/ags/widget/control-center/tiles/NightLight.ts deleted file mode 100644 index dbbf1bc4..00000000 --- a/ags/widget/control-center/tiles/NightLight.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { bind, Variable } from "astal"; -import { Tile, TileProps } from "./Tile"; -import { NightLight } from "../../../scripts/nightlight"; -import { PageNightLight } from "../pages/NightLight"; -import { tr } from "../../../i18n/intl"; -import { TilesPages } from "../Tiles"; -import { isInstalled } from "../../../scripts/utils"; -import { Widget } from "astal/gtk3"; - -export const TileNightLight = () => isInstalled("hyprsunset") ? Tile({ - title: tr("control_center.tiles.night_light.title"), - icon: "weather-clear-night-symbolic", - description: Variable.derive([ - bind(NightLight.getDefault(), "temperature"), - bind(NightLight.getDefault(), "gamma") - ], (temp, gamma) => `${temp === NightLight.getDefault().identityTemperature ? - tr("control_center.tiles.night_light.default_desc") : `${temp}K`} ${ - gamma < NightLight.getDefault().maxGamma ? `(${gamma}%)` : ""}` - )(), - onToggledOff: () => NightLight.getDefault().identity = true, - onToggledOn: () => NightLight.getDefault().identity = false, - enableOnClickMore: true, - onClickMore: () => TilesPages?.toggle(PageNightLight()), - toggleState: bind(NightLight.getDefault(), "identity").as(identity => !identity) - } as TileProps)() -: new Widget.Box({ visible: false } as Widget.BoxProps); diff --git a/ags/widget/control-center/tiles/Recording.ts b/ags/widget/control-center/tiles/Recording.ts deleted file mode 100644 index 47c78265..00000000 --- a/ags/widget/control-center/tiles/Recording.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Tile, TileProps } from "./Tile"; -import { Recording } from "../../../scripts/recording"; -import { bind, Variable } from "astal"; -import { tr } from "../../../i18n/intl"; -import { getDateTime } from "../../../scripts/time"; -import { isInstalled } from "../../../scripts/utils"; - -const wfRecorderInstalled = isInstalled("wf-recorder"); - -export const TileRecording = () => { - const description: Variable = Variable.derive([ - bind(Recording.getDefault(), "recording"), - getDateTime() - ], (recording, dateTime) => { - if(!recording || !Recording.getDefault().startedAt) - return tr("control_center.tiles.recording.disabled_desc") || "Start recording"; - - const startedAtSeconds = dateTime.to_unix() - Recording.getDefault().startedAt!.to_unix(); - if(startedAtSeconds <= 0) return "00:00"; - - const minutes = Math.floor(startedAtSeconds / 60); - const seconds = Math.floor(startedAtSeconds % 60); - - return `${ minutes < 10 ? `0${minutes}` : minutes }:${ seconds < 10 ? `0${seconds}` : seconds }`; - }); - - return Tile({ - title: tr("control_center.tiles.recording.title") || "Screen Recording", - description: description(), - icon: "media-record-symbolic", - visible: wfRecorderInstalled, - onDestroy: () => description.drop(), - onToggledOff: () => Recording.getDefault().stopRecording(), - onToggledOn: () => Recording.getDefault().startRecording(), - toggleState: bind(Recording.getDefault(), "recording"), - iconSize: 16 - } as TileProps)(); -} diff --git a/ags/widget/control-center/tiles/Tile.ts b/ags/widget/control-center/tiles/Tile.ts deleted file mode 100644 index 8e0af31a..00000000 --- a/ags/widget/control-center/tiles/Tile.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { Binding, Variable } from "astal"; -import { Gtk, Widget } from "astal/gtk3"; -import { tr } from "../../../i18n/intl"; - -export type TileProps = { - className?: string | Binding; - icon?: string | Binding; - visible?: boolean | Binding; - iconSize?: number | Binding; - title: string | Binding; - description?: string | Binding; - toggleState?: boolean | Binding; - enableOnClickMore?: boolean | Binding; - onDestroy?: () => void; - onToggledOn: () => void; - onToggledOff: () => void; - onClickMore?: () => void; -} - -export function Tile(props: TileProps): (() => Gtk.Widget) { - const subs: Array<() => void> = []; - const toggled = new Variable(((props.toggleState instanceof Binding) ? - props.toggleState.get() - : props.toggleState) ?? false); - - if(props?.toggleState instanceof Binding) - subs.push(props.toggleState.subscribe((state) => - toggled.set(state ?? false) - )); - - return () => new Widget.Box({ - className: (props.className instanceof Binding) ? - Variable.derive([ - props.className, - toggled() - ], (className, isToggled) => - `tile ${className} ${isToggled ? "toggled" : ""} ${ - props.onClickMore ? "has-more" : "" - }` - )() - : toggled().as((state: boolean) => - `tile${state ? " toggled" : ""}${ - props.onClickMore ? " has-more" : "" - }` - ), - expand: true, - visible: props.visible, - onDestroy: () => { - subs.map(sub => sub?.()); - props.onDestroy?.(); - }, - children: [ - new Widget.Button({ - className: "toggle-button", - onClick: () => { - if(toggled.get()) { - toggled.set(false); - props.onToggledOff && props.onToggledOff(); - return; - } - - toggled.set(true); - props.onToggledOn && props.onToggledOn(); - }, - child: new Widget.Box({ - className: "content", - expand: true, - hexpand: true, - children: [ - new Widget.Icon({ - className: "icon", - icon: props.icon, - visible: (props.icon instanceof Binding) ? - props.icon.as(Boolean) - : Boolean(props.icon), - css: `font-size: ${props.iconSize ?? 16}px;` - } as Widget.IconProps), - new Widget.Box({ - className: "text", - orientation: Gtk.Orientation.VERTICAL, - vexpand: true, - hexpand: true, - valign: Gtk.Align.CENTER, - children: [ - new Widget.Label({ - className: "title", - xalign: 0, - halign: Gtk.Align.START, - truncate: true, - label: props.title - } as Widget.LabelProps), - new Widget.Label({ - className: "description", - visible: (props.description instanceof Binding) ? - props.description.as(Boolean) - : Boolean(props.description), - halign: Gtk.Align.START, - truncate: true, - xalign: 0, - label: (props.description instanceof Binding) ? - props.description.as((desc) => desc ? desc : "") - : (props.description || "") - } as Widget.LabelProps) - ] - } as Widget.BoxProps) - ] - } as Widget.BoxProps) - } as Widget.ButtonProps), - new Widget.Button({ - className: "more icon", - visible: props.onClickMore !== undefined, - halign: Gtk.Align.END, - tooltipText: tr("control_center.tiles.more") || "More", - image: new Widget.Icon({ - icon: "go-next-symbolic", - css: "icon { font-size: 16px; }" - }), - onClick: () => { - ((props.enableOnClickMore instanceof Binding) ? - props.enableOnClickMore.get() - : props.enableOnClickMore) && props?.onToggledOn(); - - props.onClickMore && props?.onClickMore() - }, - widthRequest: 32 - }) - ] - }); -} diff --git a/ags/widget/runner/ResultWidget.ts b/ags/widget/runner/ResultWidget.ts deleted file mode 100644 index 04800a6f..00000000 --- a/ags/widget/runner/ResultWidget.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Binding, register } from "astal"; -import { Gtk, Widget } from "astal/gtk3"; - -export { ResultWidget, ResultWidgetProps }; - -type ResultWidgetProps = { - icon?: string | Binding | Gtk.Widget | Binding; - title: string | Binding; - description?: string | Binding; - closeOnClick?: boolean; - setup?: () => void; - onClick?: () => void; -}; - -@register({ GTypeName: "ResultWidget" }) -class ResultWidget extends Widget.Box { - - public readonly onClick: (() => void); - public readonly setup: ((() => void)|undefined); - public icon: (string | Binding | Gtk.Widget | Binding | undefined); - public closeOnClick: boolean = true; - - - constructor(props: ResultWidgetProps) { - super({ - className: "result", - hexpand: true - }); - - this.icon = props.icon; - this.setup = props.setup; - this.closeOnClick = props.closeOnClick ?? true; - this.onClick = () => props.onClick?.(); - - if(this.icon !== undefined) { - if(this.icon instanceof Binding) { - if(typeof this.icon.get() === "string") { - this.add(new Widget.Icon({ - icon: this.icon as Binding - } as Widget.IconProps)); - } else { - this.add(new Widget.Box({ - child: this.icon as Binding - } as Widget.BoxProps)); - } - } else { - if(typeof this.icon === "string") { - this.add(new Widget.Icon({ - icon: this.icon as string - } as Widget.IconProps)); - } else - this.add(this.icon as Gtk.Widget); - } - } - - this.add(new Widget.Box({ - orientation: Gtk.Orientation.VERTICAL, - valign: Gtk.Align.CENTER, - children: [ - new Widget.Label({ - className: "title", - xalign: 0, - truncate: true, - label: props.title - } as Widget.LabelProps), - new Widget.Label({ - className: "description", - visible: (props.description instanceof Binding) ? - props.description.as(Boolean) - : Boolean(props.description), - truncate: true, - xalign: 0, - label: props.description || "" - } as Widget.LabelProps) - ] - } as Widget.BoxProps)); - } -} diff --git a/ags/window/AppsWindow.ts b/ags/window/AppsWindow.ts deleted file mode 100644 index 1e9a903b..00000000 --- a/ags/window/AppsWindow.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { GObject, Variable } from "astal"; -import { Astal, Gdk, Gtk, Widget } from "astal/gtk3"; -import { execApp, getAppIcon, getApps, getAstalApps } from "../scripts/apps"; -import AstalApps from "gi://AstalApps"; -import { PopupWindow } from "../widget/PopupWindow"; - -export const AppsWindow = (mon: number): (Widget.Window) => { - const searchString = new Variable(""); - const searchSubscription = searchString.subscribe((str: string) => { - updateResults(str); - }); - - let results: Array = []; - - const flowboxConnections: Array = []; - const flowbox = new Gtk.FlowBox({ - rowSpacing: 60, - columnSpacing: 60, - homogeneous: true, - visible: true, - minChildrenPerLine: 1, - activateOnSingleClick: true - } as Gtk.FlowBox.ConstructorProps); - - const entry = new Widget.Entry({ - className: "entry", - halign: Gtk.Align.CENTER, - placeholderText: "Start typing...", - primary_icon_name: "system-search", - onChanged: (entry) => searchString.set(entry.text), - onActivate: () => flowbox.get_selected_children()?.[0]?.get_child()?.activate() - } as Widget.EntryProps); - - async function updateResults(str?: string) { - if(!str) results = getApps().sort((a, b) => - a.name > b.name ? 1 : -1); - else results = getAstalApps().fuzzy_query(str); - - flowbox.get_children().map(flowboxChild => - flowbox.remove(flowboxChild)); - - results.map(app => { - flowbox.insert(AppWidget(app), -1); - - const child = flowbox.get_child_at_index(flowbox.get_children().length - 1); - child?.set_valign(Gtk.Align.START); - }); - - const firstChild = flowbox.get_child_at_index(0); - firstChild && flowbox.select_child(firstChild); - } - - function AppWidget(app: AstalApps.Application) { - const connections: Array = []; - // Astal3.Button doesn't work the way I need, so I'll use normal GtkButton - const button = new Gtk.Button({ - visible: true, - // widthRequest: 180, - heightRequest: 140, - expand: true, - tooltipMarkup: `${app.name}${app.description ? - `\n${app.description}` - : ""}`.replace(/\&/g, "&"), - child: new Widget.Box({ - orientation: Gtk.Orientation.VERTICAL, - children: [ - new Widget.Icon({ - className: "icon", - expand: true, - icon: getAppIcon(app) || "application-x-executable" - } as Widget.IconProps), - new Widget.Label({ - className: "name", - truncate: true, - maxWidthChars: 10, - valign: Gtk.Align.START, - label: app.name || "Unnamed App" - } as Widget.LabelProps) - ] - } as Widget.BoxProps) as Gtk.Widget, - } as Gtk.Button.ConstructorProps); - - button.set_can_focus(false); - - const openFun = () => { - execApp(app); - window.close(); - }; - - connections.push( - button.connect("activate", openFun), - button.connect("clicked", openFun) - ); - - button.vfunc_destroy = () => { - connections.map(id => - GObject.signal_handler_is_connected(button, id) && - button.disconnect(id) - ); - }; - - return button; - } - - const window = PopupWindow({ - namespace: "apps-window", - layer: Astal.Layer.OVERLAY, - exclusivity: Astal.Exclusivity.IGNORE, - monitor: mon, - marginTop: 64, - cssBackgroundWindow: "background: rgba(0, 0, 0, .2)", - onDestroy: () => { - searchSubscription?.(); - searchString.drop(); - flowboxConnections.map(id => flowbox.disconnect(id)); - }, - onKeyPressEvent: (_, event: Gdk.Event) => { - if(event.get_keyval()[1] === Gdk.KEY_Escape) { - _.close(); - return; - } - - if(event.get_keyval()[1] !== Gdk.KEY_Right && - event.get_keyval()[1] !== Gdk.KEY_Down && - event.get_keyval()[1] !== Gdk.KEY_Up && - event.get_keyval()[1] !== Gdk.KEY_Left && - event.get_keyval()[1] !== Gdk.KEY_Return && - event.get_keyval()[1] !== Gdk.KEY_space && - event.get_keyval()[1] !== Gdk.KEY_Escape) { - !entry.isFocus && entry.grab_focus_without_selecting(); - } - }, - child: new Widget.Box({ - className: "apps-window-container", - expand: true, - orientation: Gtk.Orientation.VERTICAL, - children: [ - entry, - new Widget.Box({ - className: "apps-area", - child: new Widget.Scrollable({ - vscroll: Gtk.PolicyType.AUTOMATIC, - hscroll: Gtk.PolicyType.NEVER, - overlayScrolling: true, - expand: true, - child: flowbox - } as Widget.ScrollableProps) - } as Widget.BoxProps) - ] - } as Widget.BoxProps) - }); - - const connId = window.connect("focus-in-event", (_) => { - updateResults(); - window.disconnect(connId); - }); - - flowboxConnections.push( - flowbox.connect("child-activated", (_, item) => { - if(!item || !item.get_child()) return; - item.get_child()!.activate(); - }) - ); - - return window; -} diff --git a/ags/window/Bar.ts b/ags/window/Bar.ts deleted file mode 100644 index d10d5b99..00000000 --- a/ags/window/Bar.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Astal, Gtk, Widget } from "astal/gtk3"; - -import { Tray } from "../widget/bar/Tray"; -import { Workspaces } from "../widget/bar/Workspaces"; -import { FocusedClient } from "../widget/bar/FocusedClient"; -import { Media } from "../widget/bar/Media"; -import { Apps } from "../widget/bar/Apps"; -import { Clock } from "../widget/bar/Clock"; -import { Status } from "../widget/bar/Status"; - -export const Bar = (mon: number) => { - const widgetSpacing = 4; - return new Widget.Window({ - namespace: "top-bar", - anchor: Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT | Astal.WindowAnchor.RIGHT, - layer: Astal.Layer.TOP, - exclusivity: Astal.Exclusivity.EXCLUSIVE, - heightRequest: 46, - monitor: mon, - visible: true, - canFocus: false, - child: new Widget.Box({ - className: "bar-container", - child: new Widget.CenterBox({ - className: "bar-centerbox", - expand: true, - homogeneous: false, - startWidget: new Widget.Box({ - className: "widgets-left", - homogeneous: false, - halign: Gtk.Align.START, - spacing: widgetSpacing, - children: [ - Apps(), - Workspaces(), - FocusedClient() - ] - } as Widget.BoxProps), - centerWidget: new Widget.Box({ - className: "widgets-center", - homogeneous: false, - spacing: widgetSpacing, - halign: Gtk.Align.CENTER, - children: [ - Clock(), - Media() - ] - } as Widget.BoxProps), - endWidget: new Widget.Box({ - className: "widgets-right", - homogeneous: false, - spacing: widgetSpacing, - halign: Gtk.Align.END, - children: [ - Tray(), - Status() - ] - } as Widget.BoxProps) - } as Widget.CenterBoxProps) - } as Widget.BoxProps) - } as Widget.WindowProps); -} diff --git a/ags/window/CenterWindow.ts b/ags/window/CenterWindow.ts deleted file mode 100644 index ccccc96b..00000000 --- a/ags/window/CenterWindow.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Gtk, Widget } from "astal/gtk3"; -import { bind, GLib } from "astal"; - -import { getDateTime } from "../scripts/time"; -import { Separator, SeparatorProps } from "../widget/Separator"; -import { PopupWindow, PopupWindowProps } from "../widget/PopupWindow"; -import { BigMedia } from "../widget/center-window/BigMedia"; -import AstalMpris from "gi://AstalMpris"; - -export const CenterWindow = (mon: number) => PopupWindow({ - namespace: "center-window", - marginTop: 10, - halign: Gtk.Align.CENTER, - valign: Gtk.Align.START, - monitor: mon, - child: new Widget.Box({ - className: "center-window-container", - spacing: 6, - children: [ - new Widget.Box({ - className: "left", - orientation: Gtk.Orientation.VERTICAL, - children: [ - new Widget.Box({ - className: "datetime", - orientation: Gtk.Orientation.VERTICAL, - halign: Gtk.Align.CENTER, - valign: Gtk.Align.CENTER, - vexpand: true, - children: [ - new Widget.Label({ - className: "time", - label: getDateTime().as((dateTime: GLib.DateTime) => - dateTime.format("%H:%M")) - } as Widget.LabelProps), - new Widget.Label({ - className: "date", - label: getDateTime().as((dateTime: GLib.DateTime) => - dateTime.format("%A, %B %d")) - } as Widget.LabelProps) - ] - } as Widget.BoxProps), - new Widget.Box({ - className: "calendar-box", - vexpand: false, - hexpand: true, - valign: Gtk.Align.START, - child: new Gtk.Calendar({ - visible: true, - showHeading: true, - showDayNames: true, - showWeekNumbers: false - } as Gtk.Calendar.ConstructorProps) - } as Widget.BoxProps) - ] - } as Widget.BoxProps), - Separator({ - orientation: Gtk.Orientation.HORIZONTAL, - cssColor: "gray", - margin: 5, - spacing: 8, - alpha: .3, - visible: bind(AstalMpris.get_default(), "players").as(players => players.length > 0), - } as SeparatorProps), - BigMedia() - ] - } as Widget.BoxProps) -} as PopupWindowProps); diff --git a/ags/window/ControlCenter.ts b/ags/window/ControlCenter.ts deleted file mode 100644 index e6a5fb48..00000000 --- a/ags/window/ControlCenter.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Astal, Gtk, Widget } from "astal/gtk3"; -import { QuickActions } from "../widget/control-center/QuickActions"; -import { Tiles } from "../widget/control-center/Tiles"; -import { Sliders } from "../widget/control-center/Sliders"; -import { NotifHistory } from "../widget/control-center/NotifHistory"; -import { PopupWindow, PopupWindowProps } from "../widget/PopupWindow"; - - -export const ControlCenter = (mon: number) => PopupWindow({ - namespace: "control-center", - className: "control-center", - halign: Gtk.Align.END, - valign: Gtk.Align.START, - layer: Astal.Layer.OVERLAY, - marginTop: 10, - marginRight: 10, - marginBottom: 10, - monitor: mon, - widthRequest: 395, - child: new Widget.Box({ - orientation: Gtk.Orientation.VERTICAL, - spacing: 16, - children: [ - new Widget.Box({ - className: "control-center-container", - orientation: Gtk.Orientation.VERTICAL, - vexpand: false, - children: [ - QuickActions(), - Sliders(), - Tiles() - ] - } as Widget.BoxProps), - NotifHistory() - ] - } as Widget.BoxProps) -} as PopupWindowProps); diff --git a/ags/window/FloatingNotifications.ts b/ags/window/FloatingNotifications.ts deleted file mode 100644 index ceb018e4..00000000 --- a/ags/window/FloatingNotifications.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Astal, Gtk, Widget } from "astal/gtk3"; -import { bind } from "astal/binding"; -import { Notifications } from "../scripts/notifications"; -import { NotificationWidget } from "../widget/Notification"; - - -export const FloatingNotifications = (mon: number) => new Widget.Window({ - namespace: "floating-notifications", - canFocus: false, - anchor: Astal.WindowAnchor.TOP | Astal.WindowAnchor.RIGHT, - monitor: mon, - layer: Astal.Layer.OVERLAY, - widthRequest: 450, - exclusivity: Astal.Exclusivity.NORMAL, - child: new Widget.Box({ - className: "floating-notifications-container", - orientation: Gtk.Orientation.VERTICAL, - homogeneous: false, - spacing: 12, - visible: bind(Notifications.getDefault(), "notifications").as(notifs => notifs.length > 0), - children: bind(Notifications.getDefault(), "notifications").as((notifs) => - notifs.map((item) => new Widget.Box({ - className: "float-notification", - child: NotificationWidget(item, - () => Notifications.getDefault().removeNotification(item), - false, true) - } as Widget.BoxProps)) - ), - } as Widget.BoxProps) -} as Widget.WindowProps); diff --git a/ags/window/LogoutMenu.ts b/ags/window/LogoutMenu.ts deleted file mode 100644 index 28f62a5b..00000000 --- a/ags/window/LogoutMenu.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { Astal, Gdk, Gtk, Widget } from "astal/gtk3"; -import { getDateTime } from "../scripts/time"; -import { execAsync, Gio, GLib } from "astal"; -import { AskPopup, AskPopupProps } from "../widget/AskPopup"; -import { Windows } from "../windows"; -import { Notifications } from "../scripts/notifications"; -import AstalNotifd from "gi://AstalNotifd"; -import { NightLight } from "../scripts/nightlight"; -import { Config } from "../scripts/config"; - - -const { TOP, LEFT, RIGHT, BOTTOM } = Astal.WindowAnchor; - -export const LogoutMenu = (mon: number) => new Widget.Window({ - namespace: "logout-menu", - anchor: TOP | LEFT | RIGHT | BOTTOM, - layer: Astal.Layer.OVERLAY, - exclusivity: Astal.Exclusivity.IGNORE, - keymode: Astal.Keymode.EXCLUSIVE, - monitor: mon, - onKeyPressEvent: (_, event: Gdk.Event) => { - event.get_keyval()[1] === Gdk.KEY_Escape && - _.close(); - }, - child: new Widget.EventBox({ - className: "logout-menu", - onClick: () => Windows.close("logout-menu"), - child: new Widget.Box({ - expand: true, - orientation: Gtk.Orientation.VERTICAL, - children: [ - new Widget.Box({ - className: "top", - hexpand: true, - vexpand: false, - orientation: Gtk.Orientation.VERTICAL, - valign: Gtk.Align.START, - children: [ - new Widget.Label({ - className: "time", - label: getDateTime().as((dateTime: GLib.DateTime) => - dateTime.format("%H:%M")) - } as Widget.LabelProps), - new Widget.Label({ - className: "date", - label: getDateTime().as((dateTime: GLib.DateTime) => - dateTime.format("%A, %B %d %Y")) - } as Widget.LabelProps) - ] - } as Widget.BoxProps), - new Widget.Box({ - className: "button-row", - homogeneous: true, - vexpand: true, - valign: Gtk.Align.CENTER, - height_request: 360, - children: [ - new Widget.Button({ - className: "poweroff", - image: new Widget.Icon({ - icon: "system-shutdown-symbolic" - } as Widget.IconProps), - onClick: () => AskPopup(poweroffAsk), - onActivate: () => AskPopup(poweroffAsk) - } as Widget.ButtonProps), - new Widget.Button({ - className: "reboot", - image: new Widget.Icon({ - icon: "arrow-circular-top-right-symbolic" - } as Widget.IconProps), - onClick: () => AskPopup(rebootAsk), - onActivate: () => AskPopup(rebootAsk) - } as Widget.ButtonProps), - new Widget.Button({ - className: "suspend", - image: new Widget.Icon({ - icon: "weather-clear-night-symbolic" - } as Widget.IconProps), - onClick: () => AskPopup(suspendAsk), - onActivate: () => AskPopup(suspendAsk) - } as Widget.ButtonProps), - new Widget.Button({ - className: "logout", - image: new Widget.Icon({ - icon: "system-log-out-symbolic" - } as Widget.IconProps), - onClick: () => AskPopup(logoutAsk), - onActivate: () => AskPopup(logoutAsk) - } as Widget.ButtonProps), - ] - } as Widget.BoxProps) - ] - }) - } as Widget.EventBoxProps) -} as Widget.WindowProps); - -const logoutAsk: AskPopupProps = { - title: "Log out", - text: "Are you sure you want to log out? Your session will be ended.", - onAccept: () => { - Config.getDefault().getProperty("night_light.save_on_shutdown", "boolean") && - NightLight.getDefault().saveData(); - - execAsync(`hyprctl dispatch exit`).catch((err: Gio.IOErrorEnum) => - Notifications.getDefault().sendNotification({ - appName: "colorshell", - summary: "Couldn't exit Hyprland", - body: `An error occurred and colorshell couldn't exit Hyprland. Stderr: \n${ - err.message ? `${err.message}\n` : ""}${err.stack}`, - urgency: AstalNotifd.Urgency.NORMAL, - actions: [{ - text: "Report Issue on colorshell", - onAction: () => execAsync( - `xdg-open https://github.com/retrozinndev/colorshell/issues/new` - ).catch((err: Gio.IOErrorEnum) => - Notifications.getDefault().sendNotification({ - appName: "colorshell", - summary: "Couldn't open link", - body: `Do you have \`xdg-utils\` installed? Stderr: \n${ - err.message ? `${err.message}\n` : ""}${err.stack}` - }) - ) - }] - }) - ) - } -}; - -const suspendAsk: AskPopupProps = { - title: "Suspend", - text: "Are you sure you want to Suspend?", - onAccept: () => execAsync("systemctl suspend") -}; - -const rebootAsk: AskPopupProps = { - title: "Reboot", - text: "Are you sure you want to Reboot? Unsaved work will be lost.", - onAccept: () => { - Config.getDefault().getProperty("night_light.save_on_shutdown", "boolean") && - NightLight.getDefault().saveData(); - - execAsync("systemctl reboot"); - } -}; - -const poweroffAsk: AskPopupProps = { - title: "Power Off", - text: "Are you sure you want to power off? Unsaved work will be lost.", - onAccept: () => { - Config.getDefault().getProperty("night_light.save_on_shutdown", "boolean") && - NightLight.getDefault().saveData(); - - execAsync("systemctl poweroff"); - } -}; diff --git a/ags/window/OSD.ts b/ags/window/OSD.ts deleted file mode 100644 index ddc86550..00000000 --- a/ags/window/OSD.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { bind, Variable } from "astal"; -import { Astal, Gtk, Widget } from "astal/gtk3"; -import { Wireplumber } from "../scripts/volume"; - -export enum OSDModes { - SINK, - BRIGHTNESS -} - -let osdMode: (Variable|null); - -export function setOSDMode(newMode: OSDModes): void { - if(!osdMode) return; - - osdMode.set(newMode); -} - -export const OSD = (mon: number) => { - osdMode = new Variable(OSDModes.SINK); - - return new Widget.Window({ - namespace: "osd", - className: "osd-window", - layer: Astal.Layer.OVERLAY, - anchor: Astal.WindowAnchor.BOTTOM, - canFocus: false, - clickThrough: true, - focusOnClick: false, - marginBottom: 80, - monitor: mon, - onDestroy: () => { - osdMode?.drop(); - osdMode = null; - }, - child: new Widget.Box({ - className: "osd", - expand: true, - children: [ - new Widget.Icon({ - className: "icon", - icon: bind(Wireplumber.getDefault().getDefaultSink(), "volumeIcon").as(icon => - !Wireplumber.getDefault().isMutedSink() && Wireplumber.getDefault().getSinkVolume() > 0 ? icon : "audio-volume-muted-symbolic"), - } as Widget.IconProps), - new Widget.Box({ - className: "volume", - orientation: Gtk.Orientation.VERTICAL, - valign: Gtk.Align.CENTER, - expand: true, - children: [ - new Widget.Label({ - className: "device", - label: bind(Wireplumber.getDefault().getDefaultSink(), "description").as(description => - description ?? "Speaker"), - truncate: true, - } as Widget.LabelProps), - new Widget.Box({ - expand: true, - child: new Widget.LevelBar({ - className: "levelbar", - value: bind(Wireplumber.getDefault().getDefaultSink(), "volume").as((volume: number) => - Math.floor(volume * 100)), - maxValue: bind(Wireplumber.getWireplumber(), "defaultSpeaker").as(() => - Wireplumber.getDefault().getMaxSinkVolume()), - expand: true - } as Widget.LevelBarProps) - } as Widget.BoxProps) - ] - } as Widget.BoxProps) - ] - } as Widget.BoxProps) - } as Widget.WindowProps); -} diff --git a/ags/windows.ts b/ags/windows.ts deleted file mode 100644 index c4dc4131..00000000 --- a/ags/windows.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { App, Widget } from "astal/gtk3"; - -import { Bar } from "./window/Bar"; -import { OSD } from "./window/OSD"; -import { ControlCenter } from "./window/ControlCenter"; -import { CenterWindow } from "./window/CenterWindow"; -import { LogoutMenu } from "./window/LogoutMenu"; -import { FloatingNotifications } from "./window/FloatingNotifications"; -import { AppsWindow } from "./window/AppsWindow"; -import AstalHyprland from "gi://AstalHyprland"; -import { GObject, property, register, signal } from "astal"; - -/** - * Windowing System - * Possible actions: getting window states(visible or not), close, open or toggle windows, - * registering windows(they are monitored through signals, and their state is changed when needed) - * Also contains util functions to create dynamic windows, opening the window only on focused - * monitor, or all available monitors! - */ -@register({ GTypeName: "Windows" }) -class WindowsClass extends GObject.Object { - #openWindows: Record> = {}; - private static instance: (WindowsClass | null); - - @signal(String) - declare opened: () => string; - @signal(String) - declare closed: () => string; - - #windows: Record (Widget.Window | Array))> = { - "bar": this.createWindowForMonitors(Bar), - "osd": this.createWindowForFocusedMonitor(OSD), - "control-center": this.createWindowForFocusedMonitor(ControlCenter), - "center-window": this.createWindowForFocusedMonitor(CenterWindow), - "logout-menu": this.createWindowForFocusedMonitor(LogoutMenu), - "floating-notifications": this.createWindowForFocusedMonitor(FloatingNotifications), - "apps-window": this.createWindowForFocusedMonitor(AppsWindow) - }; - - #windowConnections: Record | Array>)> = {}; - #appConnections: Array = []; - - get windows() { return this.#windows; } - - @property(Object) - get openWindows(): Record> { return this.#openWindows; }; - - constructor() { - super(); - - // Listen to monitor events - this.#appConnections.push( - App.connect("monitor-added", (_, _monitor) => { - AstalHyprland.get_default().get_monitors().length > 0 && - this.reopen(); - }), - App.connect("monitor-removed", (_, monitor) => { - Object.values(this.openWindows).map((window: (Array | Widget.Window), i: number) => { - if(Array.isArray(window)) { - window = window as Array; - window.map(win => { - if(win.get_current_monitor() === monitor) { - win?.close(); - this.openWindows[i] = (this.openWindows[i] as Array).filter(item => - item !== win); - } - }); - - if((this.openWindows[i] as Array).length < 1) - delete this.openWindows[i]; - } - - window = window as Widget.Window; - if(window.get_current_monitor() === monitor) - window.close(); - }); - }) - ); - } - - vfunc_dispose() { - Object.keys(this.#windowConnections).map(name => - this.disconnectWindow(name)); - - this.#appConnections.map(id => - GObject.signal_handler_is_connected(App, id) && - App.disconnect(id)); - } - - private disconnectWindow(name: keyof typeof this.windows) { - const window = this.openWindows[name]; - if(!window) { - console.log("couldn't disconnect, window is not open"); - return; - } - - this.#windowConnections[name].map((id: Array | number) => { - if(Array.isArray(window)) { - window.map((win, i) => { - const curId = (id as Array)[i]; - - GObject.signal_handler_is_connected(win, curId) && - win.disconnect(curId); - }); - return; - } - - GObject.signal_handler_is_connected(window, id as number) && - window.disconnect(id as number); - }); - - delete this.#windowConnections[name]; - } - - private connectWindow(name: keyof typeof this.windows) { - if(Object.hasOwn(this.#windowConnections, name)) return; - - if(!this.openWindows?.[name]) { - console.log(`${name} is not open, will not connect`); - return; - } - - if(Array.isArray(this.openWindows[name])) { - this.#windowConnections[name] = this.openWindows[name].map(win => [ - win.connect("map", (window) => { - if(this.isVisible(name)) return; - - this.#openWindows[name] = window; - this.notify("open-windows"); - }), - win.connect("destroy", () => { - this.disconnectWindow(name); - this.notify("open-windows"); - }) - ]); - - return; - } - - this.#windowConnections[name] = [ - this.openWindows[name].connect("map", (window) => { - if(this.isVisible(name)) return; - - this.#openWindows[name] = window; - this.notify("open-windows"); - }), - this.openWindows[name].connect("destroy", () => { - this.disconnectWindow(name); - delete this.#openWindows[name]; - this.notify("open-windows"); - }) - ]; - } - - public static getDefault(): WindowsClass { - if(!this.instance) - this.instance = new WindowsClass(); - - return this.instance; - } - - /** - * Creates a window instance for every monitor connected - * @param windowFun function: (mon: number) => Widget.Window, returned window must use provided monitor number - * @returns a function that when called, returns Array - * @throws Error if there are no monitors connected - */ - public createWindowForMonitors(windowFun: (mon: number) => Widget.Window): (() => Array) { - const monitors = AstalHyprland.get_default().get_monitors(); - if(monitors.length < 1) - throw new Error("Couldn't create window for monitors", { - cause: `No monitors connected on Hyprland` - }); - - return () => monitors.map(mon => windowFun(mon.id)); - } - - /** - * Creates a window instance for focused monitor only - * @param windowFun function: (mon: number) => Widget.Window, returned window must use provided monitor number - * @returns a function that when called, returns a Widget.Window instance - * @throws Error if no focused monitor is found - */ - public createWindowForFocusedMonitor(windowFun: (mon: number) => Widget.Window): (() => Widget.Window) { - const focusedMonitor = AstalHyprland.get_default() - .get_monitors().filter(mon => mon.focused)[0]; - - if(!focusedMonitor) - throw new Error("Couldn't create window for focused monitor", { - cause: `No focused monitor found (${typeof focusedMonitor})` - }); - - return () => windowFun(focusedMonitor.id); - } - - public addWindow(name: string, window: (() => (Widget.Window | Array))): void { - this.#windows[name] = window; - } - - public hasWindow(name: keyof typeof this.windows): boolean { - return Boolean(this.windows?.[name as keyof typeof this.windows]); - } - - public getWindow(name: (keyof typeof this.windows | string)): ((() => (Widget.Window | Array)) | undefined) { - return this.windows?.[name as keyof typeof this.windows]; - } - - public getOpenWindow(name: (keyof typeof this.openWindows)): (Widget.Window | Array | undefined) { - return this.openWindows?.[name as keyof typeof this.openWindows]; - } - - public getWindows(): Array<(() => (Widget.Window | Array))> { - return Object.values(this.windows); - } - - public getFocusedMonitorId(): (number|null) { - return AstalHyprland.get_default().get_monitors().filter(mon => mon.focused)?.[0]?.id ?? null; - } - - public isVisible(name: keyof typeof this.windows): boolean { - return Object.hasOwn(this.#openWindows, name) || Object.hasOwn(this.#windowConnections, name); - } - - public open(name: keyof typeof this.windows): void { - if(this.isVisible(name)) return; - - let window: (() => (Widget.Window | Array)) = this.getWindow(name)!; - const openWindows: (Array | Widget.Window) = window(); - this.#openWindows[name] = openWindows; - - this.connectWindow(name); - - this.emit("opened", name); - this.notify("open-windows"); - - if(Array.isArray(openWindows)) { - openWindows.map(win => win.show()); - return; - } - - openWindows.show(); - } - - public close(name: keyof typeof this.windows): void { - if(!this.isVisible(name)) return; - this.disconnectWindow(name); - - const window = this.#openWindows[name]; - delete this.#openWindows[name]; - - if(Array.isArray(window)) { - window.map(win => win.close()); - this.emit("closed", name); - this.notify("open-windows"); - return; - } - - window.close(); - this.emit("closed", name); - this.notify("open-windows"); - } - - public toggle(name: keyof typeof this.windows): void { - this.isVisible(name) ? this.close(name) : this.open(name); - } - - public closeAll(): void { - Object.keys(this.openWindows).map(name => this.close(name)); - } - - public reopen(): void { - const openWins = Object.keys(this.openWindows); - this.closeAll(); - openWins.map(name => this.open(name)); - } -} - - -/** - * Windowing System - * Possible actions: getting window states(visible or not), close, open or toggle windows, - * registering windows(they are monitored through signals, and their state is changed when needed) - * Also contains util functions to create dynamic windows, opening the window only on focused - * monitor, or all available monitors! - */ -export const Windows = WindowsClass.getDefault(); diff --git a/hypr/hypridle.conf b/config/hypr/hypridle.conf similarity index 83% rename from hypr/hypridle.conf rename to config/hypr/hypridle.conf index ec54b0ab..271823a9 100644 --- a/hypr/hypridle.conf +++ b/config/hypr/hypridle.conf @@ -7,7 +7,7 @@ general { } listener { - timeout = 3600 # 1800 -> 30m | 3600 -> 1h | 7200 -> 2h + timeout = 1800 # 1800 -> 30m | 3600 -> 1h | 7200 -> 2h on-timeout = hyprlock # on-resume = notify-send "Welcome back to Hyprland, $USER!" } diff --git a/hypr/hyprland.conf b/config/hypr/hyprland.conf similarity index 62% rename from hypr/hyprland.conf rename to config/hypr/hyprland.conf index bb5e2eda..f1b14a96 100644 --- a/hypr/hyprland.conf +++ b/config/hypr/hyprland.conf @@ -1,11 +1,11 @@ -############################### -## Retrozinn's Hyprland Dots ## -############################### +########################################### +## Hyprland Core Settings for Colorshell ## +########################################## # From https://github.com/retrozinndev/Hyprland-Dots # Made with lots of love 󰋑 , by retrozinndev -# Licensed under the MIT License +# Licensed under the BSD 3-Clause License # Shell configurations (it's not recommended to modify) diff --git a/hypr/hyprlock.conf b/config/hypr/hyprlock.conf similarity index 90% rename from hypr/hyprlock.conf rename to config/hypr/hyprlock.conf index 1a0b356b..0e9d2850 100644 --- a/hypr/hyprlock.conf +++ b/config/hypr/hyprlock.conf @@ -42,8 +42,8 @@ background { # Time label { monitor = - text = cmd[update:30000] echo -e "$(date +"%R")" # 24-hours - # text = cmd[update:30000] echo -e "$(date +"%I:%M %p")" # 12-hours (AM/PM) + text = cmd[update:30000] echo -n "$(date +"%R")" # 24-hours + # text = cmd[update:30000] echo -n "$(date +"%I:%M %p")" # 12-hours (AM/PM) color = $foreground shadow_passes = 1 shadow_size = 2 @@ -59,7 +59,7 @@ label { # Date label { monitor = - text = cmd[update:43200000] echo -e "$(date +"%A, %d %B %Y")" + text = cmd[update:43200000] echo -n "$(date +"%A, %d %B %Y")" color = $foreground shadow_passes = 1 shadow_size = 2 @@ -78,13 +78,13 @@ label { font_size = 6 font_family = $minimalFont color = $foreground - text = Currently logged in as $USER + text = cmd[update:0] echo -n "Logged as $USER in $(hostnamectl hostname)" halign = center valign = bottom position = 0, 5 } -Media +# Media label { monitor = font_size = 12 diff --git a/config/hypr/hyprpaper.conf b/config/hypr/hyprpaper.conf new file mode 100644 index 00000000..7b89e41b --- /dev/null +++ b/config/hypr/hyprpaper.conf @@ -0,0 +1,8 @@ +# default hyprpaper config from colorshell +# hypr-chan is a mascot of Hyprland, it's not made by retrozinndev + +$wallpaper = ~/wallpapers/Default Hypr-chan.jpg + +preload = $wallpaper +wallpaper = , $wallpaper +splash = true diff --git a/hypr/scripts/change-wallpaper.sh b/config/hypr/scripts/change-wallpaper.sh similarity index 65% rename from hypr/scripts/change-wallpaper.sh rename to config/hypr/scripts/change-wallpaper.sh index 832f89de..23a4c96d 100644 --- a/hypr/scripts/change-wallpaper.sh +++ b/config/hypr/scripts/change-wallpaper.sh @@ -1,20 +1,16 @@ #!usr/bin/env bash # Prompts the user with dmenu(or dmenu-like app, see hypr/scripts/get-dmenu.sh) -# to choose an image file inside defined $WALLPAPERS_DIR. If the user selects +# to choose an image file inside defined $WALLPAPERS. If the user selects # an entry, it automatically writes changes to the hyprpaper.conf file and # hot-reloads if hyprpaper is running. # -------------- -# Licensed under the MIT License +# Licensed under the BSD 3-Clause License # Made by retrozinndev (João Dias) # From https://github.com/retrozinndev/colorshell style="lighten" # lighten / darken -dmenu=$(sh "$XDG_CONFIG_HOME/hypr/scripts/get-dmenu.sh") - -if [[ -z "$WALLPAPERS_DIR" ]]; then - WALLPAPERS_DIR="$HOME/wallpapers" -fi +WALLPAPERS=`[[ -z "$WALLPAPERS" ]] && echo -n "$HOME/wallpapers" || echo -n "$WALLPAPERS"` function Write_changes() { echo "[LOG] Writing to hyprpaper config file" @@ -39,33 +35,30 @@ function Reload_pywal() { wal -t --cols16 $style -i "$wall" } -if [[ -z "$dmenu" ]]; then - notify-send -u normal -a "Wallpaper" "Dmenu not found" "Couldn't find anyrun or wofi for dmenu! Try installing one of these two before selecting wallpaper!" - exit 1 -fi - -if [[ -z $(ls -A $WALLPAPERS_DIR) ]]; then +if [[ -z $(ls -A -w1 $WALLPAPERS) ]]; then notify-send -u normal -a "Wallpaper" "Wallpapers not found" "Couldn't find any wallpaper inside \`~/wallpapers\`, try putting an image you like in there to choose it!" exit 1 fi -if [[ -z $1 ]]; then +if [[ -z $@ ]]; then # Prompt wallpaper list - wall="$WALLPAPERS_DIR/$(ls $WALLPAPERS_DIR | $dmenu)" + selection=`ls -w1 "$WALLPAPERS" | wofi --show drun` # Check if input wallpaper is empty - if [[ $wall == "$WALLPAPERS_DIR/" ]]; then + if [[ -z $selection ]]; then echo "No wallpaper has been selected by user!" if [[ $RANDOM_WALLPAPER_WHEN_EMPTY == true ]]; then - wall="$WALLPAPERS_DIR/$(ls $WALLPAPERS_DIR | shuf -n 1)" - echo "Selected random from $WALLPAPERS_DIR: $wall" + wall="$WALLPAPERS/$(ls $WALLPAPERS | shuf -n 1)" + echo "Selected random from $WALLPAPERS: $wall" else echo "Skipping hyprpaper changes and exiting." exit 0 fi + else + wall="$WALLPAPERS/$selection" # wofi if no wallpaper specified fi else - wall=$1 + wall=$@ fi Reload_pywal diff --git a/hypr/scripts/color-picker.sh b/config/hypr/scripts/color-picker.sh similarity index 54% rename from hypr/scripts/color-picker.sh rename to config/hypr/scripts/color-picker.sh index 6dd06a47..3af2ce88 100644 --- a/hypr/scripts/color-picker.sh +++ b/config/hypr/scripts/color-picker.sh @@ -1,18 +1,19 @@ #!/usr/bin/env bash function send_notification() { - notify-send -u normal -a "Color Picker" "$1" "$2" + (notify-send -u normal -a "color-picker" "$1" "$2" > /dev/null 2>&1) || \ + (echo "$1: $2") } -# Check if user has hyprpicker installed -if [[ -z $(command -v hyprpicker) ]]; then +# Check if hyprpicker is installed +if ! command -v hyprpicker > /dev/null; then send_notification "An error occurred" "Looks like you don't have hyprpicker installed! Try installing it before using the Color Picker tool." exit 1 fi -selected_color=$(hyprpicker | tail -n 1 | xargs | sed -e 's/ //g') +raw_output=`hyprpicker -al 2> /dev/null` +selected_color=`echo $raw_output | xargs | sed -e 's/ //g'` -if [[ ! -z "$selected_color" ]] && [[ ! "$selected_color" == " " ]]; then - wl-copy $selected_color +if ! [[ -z $selected_color ]]; then send_notification "Selected Color" "The selected color is $selected_color, it was also copied to your clipboard!" fi diff --git a/hypr/scripts/exec.sh b/config/hypr/scripts/exec.sh similarity index 51% rename from hypr/scripts/exec.sh rename to config/hypr/scripts/exec.sh index c5f787de..55edec92 100644 --- a/hypr/scripts/exec.sh +++ b/config/hypr/scripts/exec.sh @@ -1,15 +1,20 @@ #!/usr/bin/env bash # This script executes the provided program with UWSM -# if in usage or launches it normally with hyprctl. +# if active, or else normally. # --------------- -# Licensed under the MIT License +# Licensed under the BSD 3-Clause License # Made by retrozinndev (João Dias) # From: https://github.com/retrozinndev/colorshell -if uwsm check is-active "hyprland-uwsm.desktop"; then - exec uwsm app -- "$@" +if uwsm check is-active; then + exec uwsm-app -- "$@" + exit 0 +fi + +if [[ $1 =~ [.]desktop$ ]]; then + gtk-launch $@ exit 0 fi diff --git a/config/hypr/scripts/gen-pywal.sh b/config/hypr/scripts/gen-pywal.sh new file mode 100644 index 00000000..b7d9c417 --- /dev/null +++ b/config/hypr/scripts/gen-pywal.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +# This script loads/generate color schemes from current +# wallpaper using pywal16. +# ---------- +# Licensed under the BSD 3-Clause License +# Made by retrozinndev (João Dias) +# From https://github.com/retrozinndev/colorshell + +if ! [[ -f "$XDG_CONFIG_HOME/hypr/hyprpaper.conf" ]]; then + echo "[error] wallpaper file not found!" + exit 1 +fi + +raw=`cat "$XDG_CONFIG_HOME/hypr/hyprpaper.conf" | grep '$wallpaper =' | sed -e 's/^$wallpaper = //'` +wallpaper=${raw/\~/"$HOME"} +[[ -d "$XDG_CACHE_HOME/wal" ]] && wal -R || sh $XDG_CONFIG_HOME/hypr/scripts/change-wallpaper.sh "$wallpaper" + +sleep .5 && hyprctl reload diff --git a/hypr/scripts/screenshot.sh b/config/hypr/scripts/screenshot.sh similarity index 93% rename from hypr/scripts/screenshot.sh rename to config/hypr/scripts/screenshot.sh index 292133d9..56dfd7ca 100644 --- a/hypr/scripts/screenshot.sh +++ b/config/hypr/scripts/screenshot.sh @@ -3,7 +3,7 @@ # This script handles taking a screenshot using the # hyprshot tool. # -------------- -# Licensed under the MIT License +# Licensed under the BSD 3-Clause License # Made by retrozinndev (João Dias) # From https://github.com/retrozinndev/colorshell diff --git a/hypr/shell/autostart.conf b/config/hypr/shell/autostart.conf similarity index 82% rename from hypr/shell/autostart.conf rename to config/hypr/shell/autostart.conf index 1845d163..6ccdb8e0 100644 --- a/hypr/shell/autostart.conf +++ b/config/hypr/shell/autostart.conf @@ -15,7 +15,6 @@ exec-once = systemctl enable --user --now hyprpaper # Scripts exec-once = sh $XDG_CONFIG_HOME/hypr/scripts/gen-pywal.sh -exec-once = sleep 3 && sh $XDG_CONFIG_HOME/hypr/scripts/load-hyprsunset.sh # wait some time to actually set the filters # Shell -exec-once = $exec ags run +exec-once = uwsm check is-active && uwsm app colorshell.desktop || gtk-launch colorshell.desktop || colorshell diff --git a/hypr/shell/bindings.conf b/config/hypr/shell/bindings.conf similarity index 62% rename from hypr/shell/bindings.conf rename to config/hypr/shell/bindings.conf index 72d8a859..2e896e9f 100644 --- a/hypr/shell/bindings.conf +++ b/config/hypr/shell/bindings.conf @@ -1,17 +1,14 @@ - # color-shell specific configuration, please don't modify unless you know what you're doing! - -# `astal` and some `.*ctl` commands don't need $exec (uwsm), since it's just some process communication +# some commands don't need $exec (uwsm) if they're just process communication tools bind = $mainMod, SPACE, exec, $menu bind = $mainMod, F11, fullscreen - -bind = , Print, exec, $exec sh $XDG_CONFIG_HOME/hypr/scripts/screenshot.sh -bind = $mainMod, Print, exec, $exec sh $XDG_CONFIG_HOME/hypr/scripts/screenshot.sh full +bind = , Print, exec, $exec $scripts/screenshot.sh +bind = $mainMod, Print, exec, $exec $scripts/screenshot.sh full # restarts colorshell -bind = $mainMod, F7, exec, astal reload +bind = $mainMod, F7, exec, $exec colorshell reload || $exec colorshell bind = $mainMod, K, exec, $exec $terminal bind = $mainMod, Q, killactive @@ -19,24 +16,24 @@ bind = $mainMod, E, exec, $exec $fm bind = $mainMod, F, togglefloating bind = $mainMod, P, pseudo, bind = $mainMod, J, togglesplit -bind = $mainMod, N, exec, astal toggle control-center -bind = $mainMod, M, exec, astal toggle center-window +bind = $mainMod, N, exec, colorshell toggle control-center +bind = $mainMod, M, exec, colorshell toggle center-window bind = $mainMod, L, exec, $exec hyprlock -bind = $mainMod, V, exec, astal runner '>' || $exec sh $XDG_CONFIG_HOME/hypr/scripts/clipboard-menu.sh -bind = $mainMod, W, exec, astal runner '##' +bind = $mainMod, V, exec, colorshell runner '>' +bind = $mainMod, W, exec, colorshell runner '\##' -# bind = $mainMod, $mainMod_L, exec, astal toggle apps-window -bind = $mainMod, $mainMod_l, exec, astal peek-workspace-num +# bind = $mainMod, $mainMod_L, exec, colorshell toggle apps-window +bind = $mainMod, $mainMod_l, exec, colorshell peek-workspace-num -binde = , XF86AudioLowerVolume, exec, astal volume sink-decrease 5 || wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%- # Decrease volume -binde = , XF86AudioRaiseVolume, exec, astal volume sink-increase 5 || wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+ # Increase volume -bind = , XF86AudioMute, exec, astal volume sink-mute || wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle # Mute -bind = , XF86AudioPrev, exec, playerctl previous # Previous media -bind = , XF86AudioNext, exec, playerctl next # Next media -bind = , XF86AudioPlay, exec, playerctl play-pause # Toggle Play/Pause media +binde = , XF86AudioLowerVolume, exec, colorshell volume sink-decrease 5 || wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%- # Decrease volume +binde = , XF86AudioRaiseVolume, exec, colorshell volume sink-increase 5 || wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+ # Increase volume +bind = , XF86AudioMute, exec, colorshell volume sink-mute || wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle # Mute +bind = , XF86AudioPrev, exec, colorshell media previous || playerctl previous # Previous media +bind = , XF86AudioNext, exec, colorshell media next || playerctl next # Next media +bind = , XF86AudioPlay, exec, colorshell media play-pause || playerctl play-pause # Toggle Play/Pause media -bind = , XF86MonBrightnessDown, exec, brightnessctl s 5%- # Lower monitor brightness -bind = , XF86MonBrightnessUp, exec, brightnessctl s +5% # Increase monitor brightness +bind = , XF86MonBrightnessDown, exec, brightnessctl -c backlight s 5%- # Lower monitor brightness +bind = , XF86MonBrightnessUp, exec, brightnessctl -c backlight s +5% # Increase monitor brightness # Move focus with mainMod + arrow keys bind = $mainMod, left, movefocus, l diff --git a/hypr/shell/decorations.conf b/config/hypr/shell/decorations.conf similarity index 92% rename from hypr/shell/decorations.conf rename to config/hypr/shell/decorations.conf index bb2379b7..5efa8a63 100644 --- a/hypr/shell/decorations.conf +++ b/config/hypr/shell/decorations.conf @@ -13,14 +13,12 @@ general { col.inactive_border = $background resize_on_border = false - allow_tearing = false - layout = dwindle } render { - ctm_animation = 1 + ctm_animation = true } misc { @@ -37,8 +35,8 @@ decoration { shadow { enabled = true - range = 4 - render_power = 4 + range = 5 + render_power = 8 color = $background } @@ -46,9 +44,9 @@ decoration { enabled = true new_optimizations = true xray = false # Setting to true may cause glitches on nvidia cards! - size = 18 - passes = 3 - vibrancy = 12 + size = 0 # fixes flickering with new_optimizations + passes = 6 + vibrancy = 6 popups = false # Enable blur for popups popups_ignorealpha = 0.7 diff --git a/hypr/shell/environment.conf b/config/hypr/shell/environment.conf similarity index 91% rename from hypr/shell/environment.conf rename to config/hypr/shell/environment.conf index ccce967c..47aa23af 100644 --- a/hypr/shell/environment.conf +++ b/config/hypr/shell/environment.conf @@ -1,6 +1,9 @@ # color-shell configuration, please don't modify unless you know what you're doing! +# add colorshell to PATH by default +env = PATH, $PATH:$HOME/.local/bin + # XDG Vars env = XDG_CONFIG_HOME, $HOME/.config env = XDG_CACHE_HOME, $HOME/.cache diff --git a/hypr/shell/hyprland.conf b/config/hypr/shell/hyprland.conf similarity index 100% rename from hypr/shell/hyprland.conf rename to config/hypr/shell/hyprland.conf diff --git a/hypr/shell/layout.conf b/config/hypr/shell/layout.conf similarity index 100% rename from hypr/shell/layout.conf rename to config/hypr/shell/layout.conf diff --git a/hypr/shell/rules.conf b/config/hypr/shell/rules.conf similarity index 97% rename from hypr/shell/rules.conf rename to config/hypr/shell/rules.conf index 5c8e9b44..1d32d2be 100644 --- a/hypr/shell/rules.conf +++ b/config/hypr/shell/rules.conf @@ -37,7 +37,6 @@ layerrule = animation fade, control-center layerrule = animation fade, center-window layerrule = animation fade, logout-menu layerrule = animation slide bottom, apps-window -layerrule = animation slide right, floating-notifications layerrule = animation fade, runner layerrule = animation fade, background-window layerrule = animation fade, background-window-blur diff --git a/hypr/shell/variables.conf b/config/hypr/shell/variables.conf similarity index 78% rename from hypr/shell/variables.conf rename to config/hypr/shell/variables.conf index 1bdf451c..7ba36619 100644 --- a/hypr/shell/variables.conf +++ b/config/hypr/shell/variables.conf @@ -6,10 +6,11 @@ ############### # Wiki: https://wiki.hyprland.org/Hypr-Ecosystem/hyprlang#defining-variables +$scripts = sh $XDG_CONFIG_HOME/hypr/scripts # Use this variable to execute apps dinamically (runs with uwsm if being used by compositor) -$exec = sh $XDG_CONFIG_HOME/hypr/scripts/exec.sh +$exec = $scripts/exec.sh $mainMod = SUPER $terminal = kitty $fm = nautilus -$menu = astal runner +$menu = colorshell runner diff --git a/hypr/user/autostart.conf b/config/hypr/user/autostart.conf similarity index 100% rename from hypr/user/autostart.conf rename to config/hypr/user/autostart.conf diff --git a/hypr/user/bindings.conf b/config/hypr/user/bindings.conf similarity index 100% rename from hypr/user/bindings.conf rename to config/hypr/user/bindings.conf diff --git a/hypr/user/decorations.conf b/config/hypr/user/decorations.conf similarity index 100% rename from hypr/user/decorations.conf rename to config/hypr/user/decorations.conf diff --git a/hypr/user/environment.conf b/config/hypr/user/environment.conf similarity index 100% rename from hypr/user/environment.conf rename to config/hypr/user/environment.conf diff --git a/hypr/user/hyprland.conf b/config/hypr/user/hyprland.conf similarity index 100% rename from hypr/user/hyprland.conf rename to config/hypr/user/hyprland.conf diff --git a/hypr/user/input.conf b/config/hypr/user/input.conf similarity index 100% rename from hypr/user/input.conf rename to config/hypr/user/input.conf diff --git a/hypr/user/layout.conf b/config/hypr/user/layout.conf similarity index 100% rename from hypr/user/layout.conf rename to config/hypr/user/layout.conf diff --git a/hypr/user/monitors.conf b/config/hypr/user/monitors.conf similarity index 100% rename from hypr/user/monitors.conf rename to config/hypr/user/monitors.conf diff --git a/hypr/user/rules.conf b/config/hypr/user/rules.conf similarity index 100% rename from hypr/user/rules.conf rename to config/hypr/user/rules.conf diff --git a/kitty/kitty.conf b/config/kitty/kitty.conf similarity index 100% rename from kitty/kitty.conf rename to config/kitty/kitty.conf diff --git a/kitty/user.conf b/config/kitty/user.conf similarity index 100% rename from kitty/user.conf rename to config/kitty/user.conf diff --git a/hypr/hyprpaper.conf b/hypr/hyprpaper.conf deleted file mode 100644 index 985f58a9..00000000 --- a/hypr/hyprpaper.conf +++ /dev/null @@ -1,4 +0,0 @@ -# Default hyprpaper file - -preload = $HOME/wallpapers/Frieren At The Funeral.jpg -wallpaper = , $HOME/wallpapers/Frieren At The Funeral.jpg diff --git a/hypr/scripts/clipboard-menu.sh b/hypr/scripts/clipboard-menu.sh deleted file mode 100644 index 8932880f..00000000 --- a/hypr/scripts/clipboard-menu.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -selection=$(cliphist list | anyrun --plugins libstdin.so | cliphist decode) - -if [[ ! -z "$selection" ]]; then - printf "%s" "$selection" | sed -e 's/\\[n]$//g' | wl-copy -fi diff --git a/hypr/scripts/gen-pywal.sh b/hypr/scripts/gen-pywal.sh deleted file mode 100644 index 6775b697..00000000 --- a/hypr/scripts/gen-pywal.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash - -# This script loads/generate color schemes from current -# wallpaper using pywal16. -# ---------- -# Licensed under the MIT License -# Made by retrozinndev (João Dias) -# From https://github.com/retrozinndev/colorshell - -if ! [[ -f "$HOME/.config/hypr/hyprpaper.conf" ]]; then - notify-send -a "Wallpaper" "Couldn't load" "Wallpaper file not found! Please check for the wallpaper: $wallpaper." - exit 1 -fi - -wallpaper="$(cat $HOME'/.config/hypr/hyprpaper.conf' | grep '$wallpaper =' | sed -e 's/^$wallpaper = //')" - -if [[ -d "$HOME/.cache/wal" ]]; then - wal -R -else - wal -q -t --cols16 darken -i "$wallpaper" -fi diff --git a/hypr/scripts/get-dmenu.sh b/hypr/scripts/get-dmenu.sh deleted file mode 100644 index 75559893..00000000 --- a/hypr/scripts/get-dmenu.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash - -# Checks environment for dmenu or dmenu-like apps -# and prints out a command to pipe of. -# ----------- -# Licensed under the MIT License -# Made by retrozinndev (João Dias) -# From: https://github.com/retrozinndev/colorshell - -DMENUS=( - "anyrun:--plugins:libstdin.so" - "rofi:-dmenu" - "wofi:--show:dmenu" - "dmenu" -) - -for dmenu in ${DMENUS[@]}; do - name=$(printf "$dmenu" | awk -F: '{ print $1 }') - cmd=$(env "$name" -h > /dev/null) - code=$? - - if [[ ! $code == 127 ]]; then - echo "$dmenu" | sed 's/:/ /g' - break; - fi -done diff --git a/hypr/scripts/load-hyprsunset.sh b/hypr/scripts/load-hyprsunset.sh deleted file mode 100644 index 20b023f0..00000000 --- a/hypr/scripts/load-hyprsunset.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash - -# This script loads hyprsunset settings previously -# saved by the save-hyprsunset.sh script on shutdown. -# -------------- -# Licensed under the MIT License -# Made by retrozinndev (João Dias) -# From https://github.com/retrozinndev/colorshell - -[[ -z $XDG_CONFIG_HOME ]] && XDG_CONFIG_HOME="$HOME/.config" - -file_="$XDG_CONFIG_HOME/hypr/hyprsunset.conf" - -if ! [[ -f "$file_" ]]; then - echo "[warn] Couldn't load hyprsunset config: file not found" - exit 0 -fi - -if ! [[ "$XDG_CURRENT_DESKTOP" =~ "Hyprland" ]]; then - echo "[error] Seems like you're not running Hyprland! Exiting" - exit 1 -fi - -if [[ -z $(command -v hyprsunset) ]]; then - echo "[error] Couldn't load hyprsunset settings: it's either not installed or not in PATH" - exit 1 -fi - -temperature=$(cat "$file_" | grep -E "^temperature = (.*)" | awk -F= '{ print $2 }')> /dev/null -gamma=$(cat "$file_" | grep -E "^gamma = (.*)" | awk -F= '{ print $2 }')> /dev/null - -hyprctl hyprsunset temperature $temperature -sleep .05 -hyprctl hyprsunset gamma $gamma diff --git a/hypr/scripts/save-hyprsunset.sh b/hypr/scripts/save-hyprsunset.sh deleted file mode 100644 index 6feb4f1f..00000000 --- a/hypr/scripts/save-hyprsunset.sh +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env bash - -# This script saves hyprsunset values into a file using -# hyprlang, in `$XDG_CONFIG_HOME/hypr/hyprsunset.conf`. -# It is used to save last user configuration on computer -# shutdown. -# -------------- -# Licensed under the MIT License -# Made by retrozinndev (João Dias) -# From https://github.com/retrozinndev/colorshell - -[[ -z $XDG_CONFIG_HOME ]] && XDG_CONFIG_HOME="$HOME/.config" - -if ! [[ "$XDG_CURRENT_DESKTOP" =~ "Hyprland" ]]; then - echo "[error] Seems like you're not running Hyprland! Exiting" - exit 1 -fi - -if [[ -z $(command -v hyprsunset) ]]; then - echo "[error] Couldn't save hyprsunset settings: it's either not installed or not in PATH" - exit 1 -fi - -output="$XDG_CONFIG_HOME/hypr/hyprsunset.conf" - -temperature=$(hyprctl hyprsunset temperature || 6000) -gamma=$(hyprctl hyprsunset gamma || 100) - -printf "temperature = %d\ngamma = %d" "$temperature" "$gamma" > $output diff --git a/install.sh b/install.sh index 8335de59..e50ca7b6 100755 --- a/install.sh +++ b/install.sh @@ -1,87 +1,132 @@ -#!/usr/bin/bash +#!/usr/bin/env bash -source ./utils.sh - -set -e trap "printf \"\nOk, quitting beacuse you entered an exit signal. (SIGINT).\n\"; exit 1" SIGINT -trap "printf \"\nOh noo!! Some application just killed the script!\"; exit 2" SIGTERM - -XDG_CONFIG_HOME=$(sh -c '[[ ! -z "$XDG_CONFIG_HOME" ]] && echo "$XDG_CONFIG_HOME" || echo "$HOME/.config"') - -function Apply_wallpapers() { - echo -n "Would you also like to apply the wallpapers folder? :3 [y/n] " - read answer - printf "\n" - - if [[ $answer =~ "y" ]]; then - echo "Thanks for choosing! Please remember that I am not the author of the wallpapers!" - echo "You can see sources in the repo: https://github.com/retrozinndev/colorshell/WALLPAPERS.md" - - echo "-> Copying wallpapers to ~/wallpapers" - mkdir -p $HOME/wallpapers - cp -f ./wallpapers/* $HOME/wallpapers - else - echo "Ok! The wallpaper is yours to choose!" - echo "Expect some Hyprland source and color errors, it happens because there aren't colors to source when you don't install wallpapers right away, so you have to do it yourself." - echo "Tip: create the ~/wallpapers directory, put your wallpapers there and press ´SUPER + W´ to select :3" - fi -} +trap "printf \"\nOh noo!! Some application just killed the script! (SIGTERM)\"; exit 2" SIGTERM + +XDG_DATA_HOME=`[[ -z "$XDG_DATA_HOME" ]] && echo -n "$HOME/.local/share" || echo -n "$XDG_DATA_HOME"` +XDG_CACHE_HOME=`[[ -z "$XDG_CACHE_HOME" ]] && echo -n $HOME/.cache || echo -n $XDG_CACHE_HOME` +XDG_CONFIG_HOME=`[[ -z "$XDG_CONFIG_HOME" ]] && echo -n "$HOME/.config" || echo -n "$XDG_CONFIG_HOME"` +BIN_HOME=`[[ -z "$BIN_HOME" ]] && echo -n "$HOME/.local/bin" || echo -n "$BIN_HOME"` +APPS_HOME=`[[ -z "$APPS_HOME" ]] && echo -n "$XDG_DATA_HOME/applications" || echo -n "$APPS_HOME"` + +skip_prompts=`[[ "$1" == -y ]] && echo -n true` +is_standalone=`(git remote -v > /dev/null 2>&1) || echo -n true` + +temp_dir="$XDG_CACHE_HOME/colorshell-installer" +repo_directory=`[[ "$is_standalone" ]] && echo -n "$temp_dir/repo" || echo -n "."` + + +# source utils script before installation +if [[ "$is_standalone" ]]; then + mkdir -p "$repo_directory" + default_branch="ryo" # `curl -s https://api.github.com/repos/retrozinndev/colorshell | jq -r .default_branch` + # get utils script + echo "fetching utils script..." + curl -s https://raw.githubusercontent.com/retrozinndev/colorshell/refs/heads/$default_branch/scripts/utils.sh > $temp_dir/utils.sh + source $temp_dir/utils.sh +else + source ./scripts/utils.sh +fi + ######### # Start # ######### +# makes bash force-load the script into memory to avoid issues when +# switching source to a tag + +{ Print_header -echo -e "colorshell is a project made by retrozinndev. source: https://github.com/retrozinndev/colorshell\n" +echo -e "Colorshell is a project made by retrozinndev. +Source: https://github.com/retrozinndev/colorshell\n" sleep .5 echo "Welcome to the colorshell installation script!" -# Warn user of possible problems that can happen -echo "!!!WARNING!!! By running this script, you assume total responsability for any issues that may occur with your filesystem" +# Warn user of possible issues +Send_log warn "!! By running this script, you assume total responsability for any issues that may occur with your filesystem" + +[[ -z "$skip_prompts" ]] && \ + Ask "Do you want to start the shell installation?" + +if [[ "$answer" == y ]] || [[ "$skip_prompts" ]]; then + if [[ "$is_standalone" ]]; then + Send_log "The installer noticed that you're calling the script remotely" + rm -rf $repo_directory 2> /dev/null + Send_log "Cloning repository in \`$repo_directory\`..." + git clone https://github.com/retrozinndev/colorshell.git "$repo_directory" + fi + + Ask "Nice! Do you want to use the stable version instead of the unstable(latest commit)?" -echo -n "Do you want to start the shell installation? [y/n] " -[[ ! $1 == "dots" ]] && read input || printf "\n" + if [[ -z "$skip_prompts" ]] && [[ "$answer" == y ]]; then + Send_log "fetching latest release from colorshell repository" + latest_tag=`curl -s "$repo_api_url/releases" | jq -r '. | select(.[].prerelease == false) | .[0].tag_name'` + + Send_log "Done fetching" + Send_log "Checking out latest non-pre-release version: $latest_tag" + git -C "$repo_directory" checkout $latest_tag > /dev/null 2>&1 + fi -if [[ $1 == "dots" ]] || [[ $input =~ "y" ]]; then - Send_log "Starting installation...\n" + Send_log "Starting installation..." - for dir in ${config_dirs[@]}; do + Send_log "Installing default configurations" + for dir in $(ls -A -w1 "$repo_directory/config"); do dest=$XDG_CONFIG_HOME/$dir - echo "-> Installing $dir in $dest" - mkdir -p "$dest" # create parents + Send_log "Installing $dir in $dest" + mkdir -p `dirname "$dest"` # create parents + + [[ -d "$dest" ]] || [[ -f "$dest" ]] && \ + rm -rf $dest - if [[ -f "./$dir" ]]; then - rm -r "$dest" # delete unused directory - cp -f ./$dir "$dest" # copy actual file - else - cp -rf ./$dir/* "$dest" # force-copy content - fi + cp -rf $repo_directory/config/$dir "$dest" # copy done - echo "-> Copying default user config" - cp -rf ./hypr/user $XDG_CONFIG_HOME/hypr + Send_log "Updating dependencies" + pnpm i && pnpm update + + Send_log "Building colorshell" + prev_wd=`pwd` + cd "$repo_directory" + pnpm build:release + cd "$prev_wd" + + Send_log "Installing colorshell" + # install shell + mkdir -p $BIN_HOME + cp -f $repo_directory/build/release/colorshell $BIN_HOME - echo "-> Copying default hyprpaper.conf" - cp -f ./hypr/hyprpaper.conf $XDG_CONFIG_HOME/hypr + # install gresource + mkdir -p $XDG_DATA_HOME/colorshell + cp -f $repo_directory/build/release/resources.gresource $XDG_DATA_HOME/colorshell - # Ask if user also wants to install default wallpapers - Apply_wallpapers + # install desktop entry + mkdir -p $APPS_HOME + cp -f $repo_directory/build/release/colorshell.desktop $APPS_HOME - if ! [[ $1 == "dots" ]]; then - echo "Ah yes! Looks like it's installed, yay :3"; sleep .8 - echo "If you find any issue, please report it in: https://github.com/retrozinndev/colorshell/issues"; sleep .5 - echo "Thanks for using colorshell! I really appreciate that :D" + + Send_log "Adding default wallpaper in ~/wallpapers" + mkdir -p $HOME/wallpapers + cp -f $repo_directory/resources/wallpaper_default.jpg "$HOME/wallpapers/Default Hypr-chan.jpg" + + if [[ -z "$skip_prompts" ]]; then + echo "Colorshell is installed! :D" + sleep .8 + echo "If you have issues, please report it!" + echo "Issue Tracker: https://github.com/retrozinndev/colorshell/issues" + sleep .5 + echo "Thanks for using colorshell! I really appreciate that :P" printf "\n" exit 0 fi - Send_log "colorshell is installed!" - + Send_log "Colorshell is installed!" exit 0 fi printf "Ok, doing as you said! Bye bye!\n" exit 0 +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..44f95a40 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "name": "colorshell", + "version": "2.0.0", + "packageManager": "pnpm@10.12.1", + "scripts": { + "types": "sh ./scripts/types.sh", + "dev": "sh ./scripts/build.sh -d && sh ./scripts/start.sh", + "start": "sh ./scripts/start.sh", + "clean": "sh ./scripts/clean.sh", + "build": "sh ./scripts/build.sh", + "build:release": "sh ./scripts/release.sh", + "sync-config": "sh ./scripts/sync-config.sh" + }, + "devDependencies": { + "ags": "link:../../../../usr/share/ags/js" + }, + "dependencies": { + "gnim-utils": "github:retrozinndev/gnim-utils" + } +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..43649bc9 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +overrides: + ags: link:../../../../usr/share/ags/js diff --git a/repo/shots/apps-window.png b/repo/shots/apps-window.png index ba03a23e..b826a05a 100644 Binary files a/repo/shots/apps-window.png and b/repo/shots/apps-window.png differ diff --git a/repo/shots/browser-colorshell.png b/repo/shots/browser-colorshell.png new file mode 100644 index 00000000..6af3da35 Binary files /dev/null and b/repo/shots/browser-colorshell.png differ diff --git a/repo/shots/browser.png b/repo/shots/browser.png deleted file mode 100644 index 3125a428..00000000 Binary files a/repo/shots/browser.png and /dev/null differ diff --git a/repo/shots/center-window-control-center.png b/repo/shots/center-window-control-center.png new file mode 100644 index 00000000..ffe1f4a3 Binary files /dev/null and b/repo/shots/center-window-control-center.png differ diff --git a/repo/shots/center-window.png b/repo/shots/center-window.png new file mode 100644 index 00000000..a3b04c25 Binary files /dev/null and b/repo/shots/center-window.png differ diff --git a/repo/shots/clock-window.png b/repo/shots/clock-window.png deleted file mode 100644 index 3fc6b786..00000000 Binary files a/repo/shots/clock-window.png and /dev/null differ diff --git a/repo/shots/control-center-pages.png b/repo/shots/control-center-pages.png new file mode 100644 index 00000000..ec3cfd65 Binary files /dev/null and b/repo/shots/control-center-pages.png differ diff --git a/repo/shots/control-center-runner.png b/repo/shots/control-center-runner.png deleted file mode 100644 index 9bdfb539..00000000 Binary files a/repo/shots/control-center-runner.png and /dev/null differ diff --git a/repo/shots/control-center.png b/repo/shots/control-center.png index 5e760249..924f4286 100644 Binary files a/repo/shots/control-center.png and b/repo/shots/control-center.png differ diff --git a/repo/shots/desktop.png b/repo/shots/desktop.png index ebaf0b5c..468cee61 100644 Binary files a/repo/shots/desktop.png and b/repo/shots/desktop.png differ diff --git a/repo/shots/kitty.png b/repo/shots/kitty.png index 50a7c734..eea4ac2d 100644 Binary files a/repo/shots/kitty.png and b/repo/shots/kitty.png differ diff --git a/repo/shots/neovim.png b/repo/shots/neovim.png new file mode 100644 index 00000000..7722293a Binary files /dev/null and b/repo/shots/neovim.png differ diff --git a/repo/shots/resources.png b/repo/shots/resources.png new file mode 100644 index 00000000..aaefdbfe Binary files /dev/null and b/repo/shots/resources.png differ diff --git a/repo/shots/runner.png b/repo/shots/runner.png index fbc5cf62..bafd7b21 100644 Binary files a/repo/shots/runner.png and b/repo/shots/runner.png differ diff --git a/resources.gresource.xml b/resources.gresource.xml new file mode 100644 index 00000000..dec2c2bc --- /dev/null +++ b/resources.gresource.xml @@ -0,0 +1,30 @@ + + + + + styles/main.scss + styles/_mixins.scss + styles/_colors.scss + styles/_apps-window.scss + styles/_bar.scss + styles/_center-window.scss + styles/_control-center.scss + styles/_float-notifications.scss + styles/_logout-menu.scss + styles/_osd.scss + styles/_runner.scss + + + + + icons/applications-other-symbolic.svg + icons/arrow-circular-top-right-symbolic.svg + icons/circle-filled-symbolic.svg + icons/hourglass-symbolic.svg + icons/loop-arrow-symbolic.svg + icons/minus-circle-filled-symbolic.svg + icons/shield-danger-symbolic.svg + icons/shield-safe-symbolic.svg + icons/user-trash-symbolic.svg + + diff --git a/resources/colorshell.desktop b/resources/colorshell.desktop new file mode 100644 index 00000000..ba684e89 --- /dev/null +++ b/resources/colorshell.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Name=colorshell +Type=Application +Comment=A Desktop Shell for Hyprland that has colors from your wallpaper! +Exec=sh -c "$COLORSHELL_BINARY" +NoDisplay=true +StartupWMClass=io.github.retrozinndev.colorshell diff --git a/ags/icons/applications-other-symbolic.svg b/resources/icons/applications-other-symbolic.svg similarity index 100% rename from ags/icons/applications-other-symbolic.svg rename to resources/icons/applications-other-symbolic.svg diff --git a/ags/icons/arrow-circular-top-right-symbolic.svg b/resources/icons/arrow-circular-top-right-symbolic.svg similarity index 100% rename from ags/icons/arrow-circular-top-right-symbolic.svg rename to resources/icons/arrow-circular-top-right-symbolic.svg diff --git a/ags/icons/circle-filled-symbolic.svg b/resources/icons/circle-filled-symbolic.svg similarity index 100% rename from ags/icons/circle-filled-symbolic.svg rename to resources/icons/circle-filled-symbolic.svg diff --git a/ags/icons/hourglass-symbolic.svg b/resources/icons/hourglass-symbolic.svg similarity index 100% rename from ags/icons/hourglass-symbolic.svg rename to resources/icons/hourglass-symbolic.svg diff --git a/ags/icons/loop-arrow-symbolic.svg b/resources/icons/loop-arrow-symbolic.svg similarity index 100% rename from ags/icons/loop-arrow-symbolic.svg rename to resources/icons/loop-arrow-symbolic.svg diff --git a/ags/icons/minus-circle-filled-symbolic.svg b/resources/icons/minus-circle-filled-symbolic.svg similarity index 100% rename from ags/icons/minus-circle-filled-symbolic.svg rename to resources/icons/minus-circle-filled-symbolic.svg diff --git a/ags/icons/shield-danger-symbolic.svg b/resources/icons/shield-danger-symbolic.svg similarity index 100% rename from ags/icons/shield-danger-symbolic.svg rename to resources/icons/shield-danger-symbolic.svg diff --git a/ags/icons/shield-safe-symbolic.svg b/resources/icons/shield-safe-symbolic.svg similarity index 100% rename from ags/icons/shield-safe-symbolic.svg rename to resources/icons/shield-safe-symbolic.svg diff --git a/ags/icons/user-trash-symbolic.svg b/resources/icons/user-trash-symbolic.svg similarity index 100% rename from ags/icons/user-trash-symbolic.svg rename to resources/icons/user-trash-symbolic.svg diff --git a/ags/style/_apps-window.scss b/resources/styles/_apps-window.scss similarity index 68% rename from ags/style/_apps-window.scss rename to resources/styles/_apps-window.scss index b4caafb1..dc147bbd 100644 --- a/ags/style/_apps-window.scss +++ b/resources/styles/_apps-window.scss @@ -1,21 +1,24 @@ @use "sass:color"; -@use "./mixins"; @use "./colors"; -@use "./functions"; -.apps-window-container { +.apps-window .popup-window-container { $radius: 48px; padding: 28px; background: colors.$bg-translucent; border-radius: $radius $radius 0 0; - & > entry { - background: rgba(colors.$bg-primary, .4); + & entry { + background: transparent; margin-bottom: 32px; min-width: 450px; padding: 14px; border-radius: 18px; + + &:focus-within { + box-shadow: inset 0 0 0 2px colors.$bg-tertiary; + background: rgba(colors.$bg-primary, .2); + } } & flowbox { @@ -23,15 +26,15 @@ & > flowboxchild { & > button { - padding: 8px; + padding: 10px; border-radius: 24px; - & icon { - font-size: 64px; + & image { + -gtk-icon-size: 64px; } & label { - margin-top: 6px; + margin-top: 24px; text-shadow: 1px 1px 1px rgba(colors.$bg-primary, .2); font-weight: 500; } diff --git a/resources/styles/_bar.scss b/resources/styles/_bar.scss new file mode 100644 index 00000000..2a7461c0 --- /dev/null +++ b/resources/styles/_bar.scss @@ -0,0 +1,210 @@ +@use "sass:color"; +@use "./mixins"; +@use "./colors"; + + +$radius: 18px; +$padding: 4px; +$color-hover: colors.$bg-primary; + + +@mixin button-reactivity { + &:active { + box-shadow: 0 0 0 2px $color-hover; + } +} + +.bar-container { + padding: 6px; + padding-bottom: 0px; + + label { + @include mixins.reset-props; + + font-size: 12px; + font-weight: 600; + } + + // Style widget groups + & > .bar-centerbox > * { + background: rgba(colors.$bg-translucent, .6); + border-radius: $radius; + padding: 0 $padding; + + & > box:not(.workspaces-row):not(.tray):not(.focused-client):not(.media), + & > button { + @include button-reactivity; + + border-radius: calc($radius - $padding); + margin: $padding 0; + padding: 0 8px; + + &:hover { + background: $color-hover; + } + } + } + + .workspaces-row { + padding: 4px; + + & .workspace { + transition: 80ms linear; + margin: 3px 0; + border-radius: 16px; + min-width: 14px; + padding: 0 6px; + background: colors.$bg-tertiary; + + &:active { + border-radius: 10px; + } + + & label.id { + font-weight: 600; + margin-right: 4px; + opacity: 0; + } + + &.focus { + background: colors.$fg-primary; + min-width: 32px; + + & label.id { + opacity: 0; + color: colors.$fg-light; + margin-right: 0; + } + } + + & icon { + font-size: 16px; + } + + &.show label.id { + opacity: 1; + } + + &:hover:not(.last-client-icon):not(.focus) { + background: color.scale($color: colors.$bg-tertiary, $lightness: 10%); + } + } + } + + .focused-client { + padding: 0 6px; + + & image { + margin-right: 6px; + -gtk-icon-size: 18px; + } + + & .text-content { + & .class { + font-size: 9px; + font-family: monospace; + font-weight: 600; + color: colors.$fg-disabled; + margin-top: 0px; + } + + & .title { + font-size: 12px; + font-weight: 500; + margin-top: -2px; + } + } + } + + .clock.open { + background: colors.$bg-primary; + } + + .media { + $spacing: 5px; + $hover-color: color.scale($color: colors.$bg-primary, $lightness: 15%); + + background: colors.$bg-primary; + border-radius: calc($radius - $padding); + margin: $padding 0; + padding: 0 calc($padding + 3px); + + & image.player-icon { + -gtk-icon-size: 14px; + margin-right: $spacing; + } + + & .buttons { + margin-left: $spacing; + } + + & .button-row { + border-top-right-radius: 12px; + border-bottom-right-radius: 12px; + padding: 4px 0; + + & > button image { + margin: 0; + -gtk-icon-size: 10px; + } + } + + + &:hover { + background: $hover-color; + } + + &:active { + box-shadow: 0 0 0 2px $hover-color; + } + } + + .tray { + padding: 0 6px; + + & .item { + all: unset; + } + } + + .status { + @include mixins.reset-props; + + &.open { + background: colors.$bg-primary; + } + + & image { + -gtk-icon-size: 14px; + } + + & revealer > box { + background: rgba($color: colors.$bg-tertiary, $alpha: .7); + border-radius: 12px; + margin: 4px 0; + margin-left: 5px; + padding: 2px 6px; + } + + & .status-icons { + padding-left: 4px; + + & image.notification-count { + -gtk-icon-size: 6px; + margin-top: -14px; + } + } + } + + .apps { + min-width: 18px; + & image { + transition: 120ms linear; + -gtk-icon-size: 14px; + } + + &.open { + background: colors.$bg-primary; + } + } +} diff --git a/ags/style/_center-window.scss b/resources/styles/_center-window.scss similarity index 54% rename from ags/style/_center-window.scss rename to resources/styles/_center-window.scss index aed536ec..e688cb6b 100644 --- a/ags/style/_center-window.scss +++ b/resources/styles/_center-window.scss @@ -1,24 +1,23 @@ @use "sass:color"; @use "./wal"; @use "./colors"; +@use "./mixins"; .popup-window.center-window .center-window-container { background: colors.$bg-translucent; border-radius: 24px; padding: 12px; - box-shadow: 0 0 6px colors.$bg-translucent; & .big-media { - padding: 6px; - - & > box > .image { + margin-top: 9px; + & .image { background-size: cover; background-position: center center; border-radius: 10px; } - & > .info { + & .info { padding: { top: 4px; bottom: 6px; @@ -37,25 +36,28 @@ } & slider { - background: transparent; + all: unset; + opacity: 0; min-height: .6em; } & trough { border-radius: 4px; - min-height: .6em; } & trough highlight { - border-radius: 4px; - min-height: .6em; + min-height: .65em; + border-top-right-radius: 2px; + border-bottom-right-radius: 2px; } & .bottom { - & .controls { + & .button-row { margin-top: 9px; + & button { padding: 7px; + & label { font-size: 9px; } @@ -85,46 +87,43 @@ } } - & .calendar-box { - & calendar { - $border-radius: 14px; - font-weight: 600; - padding-bottom: 2px; + & calendar.view { + $border-radius: 14px; - &.view { - background: colors.$bg-primary; - border-radius: $border-radius; - } + font-weight: 600; + background: colors.$bg-primary; + border-radius: $border-radius; - &.header { - background: colors.$bg-secondary; - border-top-left-radius: $border-radius; - border-top-right-radius: $border-radius; - padding: 4px; - } + & header { + background: colors.$bg-secondary; + padding: 6px; + border-top-left-radius: $border-radius; + border-top-right-radius: $border-radius; + } - &.button { - transition: 80ms linear; - border-radius: 6px; + & grid { + margin: 4px; - &:hover { - background-color: colors.$bg-tertiary; - } - } + label.day-number { + $size: 24px; - &:selected { - background: colors.$bg-secondary; - border-radius: 6px; + min-height: $size; + min-width: $size; } + } - &.highlight { - background: transparent; - box-shadow: 0 2px 0 -1px rgba(colors.$bg-secondary, .5); - } + & button { + transition: 80ms linear; + border-radius: 6px; - &:focus { - outline: 1px white; + &:hover { + background-color: colors.$bg-tertiary; } } + + & label:selected { + background: colors.$bg-secondary; + border-radius: 6px; + } } } diff --git a/resources/styles/_colors.scss b/resources/styles/_colors.scss new file mode 100644 index 00000000..e3d450ab --- /dev/null +++ b/resources/styles/_colors.scss @@ -0,0 +1,13 @@ +@use "sass:color"; +@use "./wal"; + +$bg-primary: color.adjust($color: wal.$color1, $lightness: -34%); +$bg-secondary: color.adjust($color: wal.$color1, $lightness: -16%); +$bg-tertiary: color.adjust($color: $bg-secondary, $lightness: 10%); +$bg-light: wal.$foreground; +$bg-translucent: color.change($color: $bg-primary, $alpha: 75%); +$bg-translucent-primary: $bg-translucent; +$bg-translucent-secondary: color.change($color: $bg-translucent, $alpha: 78%); +$fg-primary: wal.$foreground; +$fg-light: $bg-primary; +$fg-disabled: color.adjust($color: wal.$foreground, $lightness: -11%); diff --git a/ags/style/_control-center.scss b/resources/styles/_control-center.scss similarity index 55% rename from ags/style/_control-center.scss rename to resources/styles/_control-center.scss index f454ebe8..2a76c285 100644 --- a/ags/style/_control-center.scss +++ b/resources/styles/_control-center.scss @@ -10,7 +10,6 @@ background: colors.$bg-translucent; border-radius: 28px; padding: 20px; - box-shadow: 0 0 6px 1px colors.$bg-translucent; & > * { margin: 9px 0; @@ -18,25 +17,43 @@ &:first-child { margin-top: 0; } + &:last-child { margin-bottom: 0; } } - /*& eventbox:focus, & button:focus { + & button:focus-visible { box-shadow: inset 0 0 0 1px colors.$fg-primary; - }*/ + } & .quickactions { margin-bottom: .8em; - & .hostname { - font-size: 15px; - font-weight: 600; + & .user-face { + background-position: center; + background-repeat: no-repeat; + background-size: cover; + min-height: 32px; + min-width: 34px; + margin-right: 6px; + border-radius: 50%; + } + + & .user-host { + .user { + font-size: 15px; + font-weight: 600; + } + .host { + color: colors.$fg-disabled; + font-size: 10px; + font-weight: 500; + } } - & > box:not(.button-row) icon { - font-size: 12px; + & > box:not(.button-row) image { + -gtk-icon-size: 12px; color: colors.$fg-disabled; margin-right: 3px; } @@ -64,21 +81,19 @@ } button { - @include mixins.hover-shadow2; - padding: 4px; border-radius: 16px; icon { font-size: 14px; } - } - & .page .content { - & > eventbox > box { - margin: 6px 0; + &:hover { + background: rgba(colors.$fg-primary, .2); } + } + & .page .content { & label.name { font-size: 14px; font-weight: 500; @@ -91,14 +106,10 @@ & label.sub-header { margin-top: 6px; } - - & button.default { - background: colors.$bg-tertiary; - } } } - & .page { + & #page { transition: 120ms linear; background: colors.$bg-secondary; padding: 14px; @@ -107,12 +118,12 @@ & .header { margin-bottom: 12px; - & .top > .title { + & .top .title { font-size: 20px; font-weight: 600; } - & > .description { + & .description { font-size: 12px; font-weight: 500; color: colors.$fg-disabled; @@ -125,13 +136,25 @@ margin-bottom: 4px; } - & button { - @include mixins.hover-shadow; + & .page-button, .extra-buttons { + & button { + padding: 6px; + border-radius: 12px; - padding: 6px; - border-radius: 12px; + &.selected { + background: colors.$bg-tertiary; + } - & label { + &:hover { + background: rgba(colors.$fg-primary, .1); + } + + &:active { + border-radius: 10px; + } + } + + & label.title { font-size: 14px; } @@ -161,48 +184,13 @@ & .extra-buttons { margin-left: 2px; - & > button { - border-radius: 12px; - } - } - } -} - -box.history { - background: colors.$bg-translucent; - box-shadow: 0 0 6px 1px colors.$bg-translucent; - border-radius: 24px; - padding: 18px; - transition: 120ms linear; - - &.hide { - opacity: 0; - } - - & .notifications { - & .notification { - background: colors.$bg-translucent-primary; - padding: 3px; - } - } - - & > .button-row { - margin-top: 12px; - - & button { - padding: 6px; - &:focus { - box-shadow: inset 0 0 0 1px colors.$fg-primary; - } + button { + border-radius: 10px; - & icon { - font-size: 16px; - } - - & label { - font-size: 12px; - font-weight: 600; + &:active { + border-radius: 8px; + } } } } @@ -211,79 +199,103 @@ box.history { .tiles-container { @include mixins.reset-props; - & > flowbox { - & > flowboxchild .tile { - $radius: 16px; + & .tile { + $radius: 18px; + $padding: 4px; - &:not(.toggled) > .toggle-button, - &:not(.toggled) > button.more { - @include mixins.hover-shadow; - background: colors.$bg-primary; - } + background: rgba(colors.$bg-primary, .5); + border-radius: $radius; + padding: $padding; + min-height: 40px; - &.toggled .toggle-button:hover, - &.toggled button.more:hover { - background: colors.$bg-tertiary; + & .icon { + transition: 120ms ease-in; + border-radius: calc($radius - $padding); + padding: 8px 12px; + margin-right: 6px; + background: color.scale($color: colors.$bg-primary, $lightness: 10%); + + & image { + -gtk-icon-size: 18px; } - &.toggled > .toggle-button, - &.toggled > button.more { - background: colors.$bg-secondary; + &:hover { + background: color.scale($color: colors.$bg-primary, $lightness: 15%); } - &.has-more > .toggle-button { - border-top-right-radius: 0; - border-bottom-right-radius: 0; + &:active { + border-radius: calc($radius - $padding - 2px); } + } - & > .toggle-button { - border-radius: $radius; + & .content { + & .title { + font-weight: 600; + font-size: 15.1px; + } - & .content { - padding: 8px; + & .description { + font-size: 12px; + color: colors.$fg-disabled; + font-weight: 400; + } + } - & > .icon { - margin-right: 6px; - } + & .arrow { + -gtk-icon-size: 12px; + color: rgba(colors.$fg-disabled, .4); + } - & > .text { - & > .title { - font-weight: 600; - font-size: 15.1px; - } + &:hover { + background: color.scale($color: colors.$bg-primary, $lightness: 5%); + } - & > .description { - font-size: 12px; - color: colors.$fg-disabled; - font-weight: 400; - } - } - } - } + &.enabled .icon { + background: colors.$bg-secondary; - & > button.more { - border-top-right-radius: $radius; - border-bottom-right-radius: $radius; - - & label { - font-size: 16px; - } + &:hover { + background: colors.$bg-tertiary; } } + + &:active { + border-radius: calc($radius - 2px); + } } } -.tile-pages .page { - transition: 180ms linear; +.tile-pages #page { margin-top: 10px; +} + +box.notif-history { + background: colors.$bg-translucent; + border-radius: 24px; + padding: 18px; + transition: 120ms linear; + + &.hide { + opacity: 0; + } - &.bluetooth { - button.connected { - background: colors.$bg-tertiary; + & .notifications { + & .notification { + background: colors.$bg-translucent-primary; + padding: 3px; } } - &.revealed { - padding-top: 12px; + & .button-row { + margin-top: 12px; + + & button { + @include mixins.button-reactive-secondary; + padding: 7px; + + & label { + font-size: 12px; + font-weight: 600; + } + } } } diff --git a/ags/style/_float-notifications.scss b/resources/styles/_float-notifications.scss similarity index 100% rename from ags/style/_float-notifications.scss rename to resources/styles/_float-notifications.scss diff --git a/ags/style/_logout-menu.scss b/resources/styles/_logout-menu.scss similarity index 55% rename from ags/style/_logout-menu.scss rename to resources/styles/_logout-menu.scss index 8d57aaee..d894a9e0 100644 --- a/ags/style/_logout-menu.scss +++ b/resources/styles/_logout-menu.scss @@ -1,6 +1,6 @@ @use "./colors"; -.logout-menu { +.logout-menu-container { background: rgba($color: colors.$bg-translucent-primary, $alpha: .4); .top { @@ -17,31 +17,35 @@ } } .button-row { + $radius: 32px; + + all: unset; margin: 0 150px; & > button { - & icon { - font-size: 128px; + & image { + -gtk-icon-size: 128px; } - &:focus { + &:focus-visible { box-shadow: inset 0 0 0 5px colors.$fg-primary; } - margin: { - left: 4px; - right: 4px; + &:active { + border-radius: calc($radius - 6px); } + + margin: 0 4px; border-radius: 6px; - &:first-child { - border-top-left-radius: 28px; - border-bottom-left-radius: 28px; + &:first-child:not(:active) { + border-top-left-radius: $radius; + border-bottom-left-radius: $radius; } - &:last-child { - border-top-right-radius: 28px; - border-bottom-right-radius: 28px; + &:last-child:not(:active) { + border-top-right-radius: $radius; + border-bottom-right-radius: $radius; } } } diff --git a/ags/style/_mixins.scss b/resources/styles/_mixins.scss similarity index 51% rename from ags/style/_mixins.scss rename to resources/styles/_mixins.scss index 0f4a2c38..bf3cea93 100644 --- a/ags/style/_mixins.scss +++ b/resources/styles/_mixins.scss @@ -1,7 +1,5 @@ @use "sass:color"; -@use "./wal"; @use "./colors"; -@use "./functions" as funs; @mixin reset-props { all: unset; @@ -14,20 +12,30 @@ color: colors.$fg-primary; } -@mixin hover-shadow { +@mixin button-reactive-primary { + background: colors.$bg-primary; + border-radius: 14px; + padding: 6px; + &:hover { - box-shadow: inset 0 0 0 500px rgba(colors.$fg-primary, .1); + background: colors.$bg-secondary; } -} -@mixin hover-shadow2 { - &:hover { - box-shadow: inset 0 0 0 500px rgba(colors.$fg-primary, .2); + &:active { + border-radius: 10px; } } -@mixin hover-shadow3 { +@mixin button-reactive-secondary { + background: colors.$bg-secondary; + border-radius: 12px; + padding: 6px; + &:hover { - box-shadow: inset 0 0 0 500px rgba(colors.$fg-primary, .3); + background: colors.$bg-tertiary; + } + + &:active { + border-radius: 9px; } } diff --git a/resources/styles/_osd.scss b/resources/styles/_osd.scss new file mode 100644 index 00000000..21dc72c1 --- /dev/null +++ b/resources/styles/_osd.scss @@ -0,0 +1,34 @@ +@use "sass:color"; +@use "colors"; + +.osd { + background: rgba(colors.$bg-translucent-secondary, .6); + padding: 14px 14px; + border-radius: 24px; + min-width: 180px; + + .icon { + margin-right: 10px; + -gtk-icon-size: 24px; + } + + .level { + margin-top: -6px; + + .text { + margin-bottom: 4px; + font-size: 14px; + font-weight: 600; + } + + levelbar trough block { + border-radius: 4px; + background: colors.$bg-primary; + + &.filled { + min-height: 10px; + background: colors.$bg-secondary; + } + } + } +} diff --git a/resources/styles/_runner.scss b/resources/styles/_runner.scss new file mode 100644 index 00000000..ada85594 --- /dev/null +++ b/resources/styles/_runner.scss @@ -0,0 +1,73 @@ +@use "./colors"; + +.runner .popup-window-container { + all: unset; + + $radius: 24px; + + background: rgba($color: colors.$bg-primary, $alpha: .8); + border-radius: $radius; + box-shadow: inset 0 0 0 1px colors.$bg-secondary, + 0 0 8px 1px colors.$bg-translucent; + + padding: 4px; + + & entry { + transition: 80ms ease-in; + min-height: 1.6em; + padding: 14px; + border-radius: inherit; + background: none; + + &:focus { + box-shadow: none; + } + + & image { + margin-right: 6px; + -gtk-icon-size: 16px; + } + } + + & scrolledwindow { + margin: 6px; + } + + & list { + & .result { + padding: 10px; + background: colors.$bg-primary; + margin: 2px 0; + border-radius: 14px; + + & image { + -gtk-icon-size: 28px; + margin-right: 6px; + } + + & .title { + font-weight: 500; + font-size: 16px; + } + + & .description { + font-size: 12px; + color: colors.$fg-disabled; + } + } + + & > *:selected .result, + & > *:active .result, + & > *:hover .result { + background: colors.$bg-secondary; + } + + & > *:first-child { + margin-top: 12px; + } + + &:last-child { + margin-bottom: 0; + } + } +} diff --git a/ags/style.scss b/resources/styles/main.scss similarity index 81% rename from ags/style.scss rename to resources/styles/main.scss index 1eab0543..9b862b34 100644 --- a/ags/style.scss +++ b/resources/styles/main.scss @@ -17,10 +17,6 @@ * { @include mixins.reset-props; - - /*&:focus { - box-shadow: inset 0 0 0 2px colors.$fg-primary; - }*/ } entry { @@ -55,6 +51,7 @@ entry { & .options { & button { + @include mixins.button-reactive-primary; background: colors.$bg-primary; border-radius: 12px; padding: 9px 6px; @@ -68,14 +65,6 @@ entry { left: 4px; right: 4px; }; - - &:hover { - background: colors.$bg-secondary; - } - - &:focus { - box-shadow: inset 0 0 0 2px colors.$fg-primary; - } } } @@ -121,10 +110,6 @@ entry { &:hover { background: colors.$bg-secondary; } - - &:focus { - box-shadow: inset 0 0 0 1px colors.$fg-primary; - } } & icon.close { @@ -162,8 +147,6 @@ entry { padding: 6px; & button.action { - @include mixins.hover-shadow; - border-radius: 4px; background: colors.$bg-secondary; padding: 6px; @@ -173,6 +156,10 @@ entry { font-weight: 600; } + &:hover { + background: colors.$bg-tertiary; + } + &:first-child { border-top-left-radius: 12px; border-bottom-left-radius: 12px; @@ -200,49 +187,66 @@ tooltip > box { } } -menu { - padding: 4px; +popover.menu contents { background: wal.$background; border-radius: 14px; - - & separator { - margin: 0 4px; - color: wal.$background; - } - - & menuitem { - padding: 8px 16px; - border-radius: 10px; - font-size: 12px; - font-weight: 600; - - &:hover, &:focus { - background: wal.$color1; + padding: 4px; + + & viewport > stack > * > * > * { + & > separator { + min-height: .5px; + margin: 3px 2px; + background: rgba(colors.$fg-disabled, .1); + } + + & > *:not(separator) > * { + padding: 6px; + border-radius: 10px; + font-size: 12px; + font-weight: 600; + + &:hover, &:focus { + background: wal.$color1; + } } } } .button-row { & > button { + $active-radius: 8px; + $corner-radius: calc($active-radius + 2px); + background: colors.$bg-secondary; margin: 0 1px; padding: 4px 6px; border-radius: 2px; + transition: 120ms linear; &:hover { background: colors.$bg-tertiary; } + &:active { + border-radius: $active-radius; + } + &:first-child { - border-top-left-radius: 10px; - border-bottom-left-radius: 10px; margin-left: 0; + + &:not(:active) { + border-top-left-radius: $corner-radius; + border-bottom-left-radius: $corner-radius; + } } &:last-child { - border-top-right-radius: 10px; - border-bottom-right-radius: 10px; margin-right: 0; + + &:not(:active) { + border-top-right-radius: $corner-radius; + border-bottom-right-radius: $corner-radius; + } } } } @@ -252,7 +256,7 @@ selection { } trough { - background: functions.toRGB(color.adjust($color: wal.$color1, $lightness: -20%)); + background: color.adjust($color: colors.$bg-primary, $lightness: -5%); border-radius: 8px; margin: 2px 0; } @@ -260,15 +264,12 @@ trough { trough highlight { background: wal.$color1; min-height: .9em; - border-top-left-radius: inherit; - border-bottom-left-radius: inherit; } trough slider { border-radius: 50%; - margin: -2px 0; + margin: -4px 0; background: wal.$foreground; - margin-left: -1px; min-width: 1.2em; min-height: 1.2em; } diff --git a/resources/wallpaper_default.jpg b/resources/wallpaper_default.jpg new file mode 100644 index 00000000..b3f48faf Binary files /dev/null and b/resources/wallpaper_default.jpg differ diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100644 index 00000000..db9790f1 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,57 @@ + +set -e + +output="./build" + +while getopts r:o:bdh args; do + case "$args" in + r) + gresources_target=${OPTARG} + ;; + b) + keep_gresource=true + ;; + o) + output=${OPTARG} + ;; + d) + is_devel=true + ;; + h) + echo "\ +colorshell's build script. +use \`build:release\` for release builds. + +options: + -r \$file: specify gresource's target path (default: \`\$output/resources.gresource\`) + -o \$path: specify the build's output directory (default: \`./build\`) + -b: only target gresource in the build, keeping the file in the output dir + -d: enable developer mode in the build + -h: show this help message" + exit 0 + ;; + esac +done + +if [[ -d "$output" ]] && [[ ! -z "$(ls -A -w1 $output)" ]]; then + echo "[info] cleaning previous build" + rm -rf $output/* +else + mkdir -p $output +fi + +echo "[info] compiling gresource" +gres_target=`[[ "$keep_gresource" ]] && echo -n "$output/resources.gresource" || \ + echo -n "${gresources_target:-$output/resources.gresource}"` +mkdir -p `dirname "$gres_target"` +glib-compile-resources resources.gresource.xml \ + --sourcedir ./resources \ + --target "$gres_target" + +echo "[info] bundling project" +ags --gtk 4 bundle src/app.ts $output/colorshell \ + -r ./src \ + -d "DEVEL=`[[ $is_devel ]] && echo -n true || echo -n false`" \ + -d "COLORSHELL_VERSION='`cat package.json | jq -r .version`'" \ + -d "GRESOURCES_FILE='${gresources_target:-$output/resources.gresource}'" \ +|| rm -rf src/node_modules diff --git a/scripts/clean.sh b/scripts/clean.sh new file mode 100644 index 00000000..aee88ebd --- /dev/null +++ b/scripts/clean.sh @@ -0,0 +1,6 @@ +set -e + +builddir="${1:-./build}" + +echo "[info] cleaning build dir: \"$builddir\"" +rm -r "$builddir" diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100644 index 00000000..506026a5 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,68 @@ +set -e + +socket_support=true + +while getopts o:r:e:hn args; do + case "$args" in + o) + outdir=${OPTARG} + ;; + r) + gresource_file=${OPTARG} + ;; + e) + bin_target=${OPTARG} + ;; + n) + unset socket_support + ;; + ? | h) + echo "\ +colorshell's automated release-build script. + +help: + 'literal': the argument should have environment variables sent as a literal string, + they're replaced at runtime. + 'default': argument's default value, they're used if none are provided. + +options: + -r \$file: gresource's target path (literal; kept in \$output; default: \$XDG_DATA_HOME/colorshell/resources.gresource) + -n: disable socket communication support(use the slower remote instance communication) + -o \$path: build output path (default: \`./build/release\`) + -e: set desktop entry's executable target (literal; default: \$HOME/.local/bin/colorshell) + -h: show this help message" + exit 0 + ;; + esac +done + +# send literal variable name, so it's interpreted at runtime +sh ./scripts/build.sh -o "${outdir:-./build/release}" -b -r "${gresource_file:-\$XDG_DATA_HOME/colorshell/resources.gresource}" + +# add socket-communication support on executable +if [[ $socket_support ]]; then + echo "[info] adding socket communication support" + script="\ +#!/usr/bin/bash + +if gdbus introspect --session \\ + --dest io.github.retrozinndev.colorshell \\ + --object-path /io/github/retrozinndev/colorshell > /dev/null 2>&1; then + + if command -v socat > /dev/null 2>&1; then + echo \"\$@\" | socat - \"\${XDG_RUNTIME_DIR:-/run/user/\$(id -u)}/colorshell.sock\" + exit 0 + else + echo \"[warn] \`socat\` not installed, falling back to remote instance communication\" + fi +fi +`cat "${outdir:-./build/release}/colorshell" | sed -e 's/^#.*//'`" # remove shebang + + echo -en "$script" > "${outdir:-./build/release}/colorshell" + chmod +x "${outdir:-./build/release}/colorshell" +fi + +echo "[info] making desktop entry" +entry=`cat ./resources/colorshell.desktop` +bin_target=${bin_target:-'$HOME/.local/bin/colorshell'} +echo -n "${entry/'$COLORSHELL_BINARY'/${bin_target/'$'/'\\\$'}}" > ${outdir:-./build/release}/colorshell.desktop diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100644 index 00000000..afdfc6d1 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,29 @@ +file="${1:-./build/colorshell}" + +function is_running() { + if gdbus introspect --session \ + --dest io.github.retrozinndev.colorshell \ + --object-path /io/github/retrozinndev/colorshell > /dev/null 2>&1 + then + return 0 + fi + + return 1 +} + +function start() { + if is_running; then + echo "[info] killing previous instance" + colorshell quit || killall gjs + fi + echo "[info] starting" + exec "$file" +} + +if [[ -f $file ]]; then + start +else + echo "[error] can't start project: no executable found on default directory" + echo "[tip] specify the executable path: start \"\$path\"" + exit 1 +fi diff --git a/scripts/sync-config.sh b/scripts/sync-config.sh new file mode 100644 index 00000000..e4ba8a72 --- /dev/null +++ b/scripts/sync-config.sh @@ -0,0 +1,60 @@ +source ./scripts/utils.sh + +config_dirs=( + "hypr/scripts" + "hypr/shell" + "hypr/hyprlock.conf" + "hypr/hyprland.conf" + "hypr/hypridle.conf" + "kitty/kitty.conf" +) +outdir="./config" + +Clean_local() { + Send_log "info" "Cleaning local config..." + for dir in ${config_dirs[@]}; do + rm -rf $outdir/$dir + done +} + +Update_local() { + mkdir -p $outdir + for dir in ${config_dirs[@]}; do + if [[ -d "$XDG_CONFIG_HOME/$dir" ]] || [[ -f "$XDG_CONFIG_HOME/$dir" ]]; then + Send_log "Copying ${dir^}" + mkdir -p `dirname "$outdir/$dir"` + cp -r $XDG_CONFIG_HOME/$dir $outdir/$dir + else + Send_log "warn" "Looks like the ${dir^} dir is in fault! Skipping..." + fi + done +} + +Print_header + +printf "\n" +echo "!!WARNING!! Running this script may override all configuration data in current repo with host ones." +echo "This script is intended to be used only by the repository owner" +printf "\n" + +echo "Please run this script in it's current directory to avoid issues" +echo "Tip: Press [Ctrl] + [C] to stop script at any time" + +printf "\n" + +Ask "Update local repository with host configurations?" +if [[ ! $answer == y ]]; then + Send_log "Exiting" + exit 1 +fi + +printf "\n" + +Clean_local +Update_local + +if command -v git > /dev/null; then + git status +fi + +exit 0 diff --git a/scripts/types.sh b/scripts/types.sh new file mode 100644 index 00000000..27234f16 --- /dev/null +++ b/scripts/types.sh @@ -0,0 +1,8 @@ +if [[ -d "@types" ]] && [[ ! "$1" == "-f" ]]; then + echo "Types skipped(already built). To force-build, run \`types\`" + exit 0 +fi + + +echo "Building types, this can take long..." +pnpx @ts-for-gir/cli generate --ignoreVersionConflicts diff --git a/utils.sh b/scripts/utils.sh similarity index 52% rename from utils.sh rename to scripts/utils.sh index ade010a0..44d34de6 100644 --- a/utils.sh +++ b/scripts/utils.sh @@ -1,27 +1,15 @@ -#!/usr/bin/env bash - # This script contains useful functions to be used -# in other scripts from retrozindev's dotfiles. +# in other scripts from colorshell. # ---------- # Made by retrozinndev (João Dias) -# Licensed under the MIT License +# Licensed under the BSD 3-Clause License # From: https://github.com/retrozinndev/colorshell # ------------- -# Array containing directory names to be used by -# retrozinndev/colorshell install and update -# scripts. +# The repository's api url # ------------- -config_dirs=( - "hypr/scripts" - "hypr/shell" - "hypr/hyprlock.conf" - "hypr/hyprland.conf" - "hypr/hypridle.conf" - "kitty/kitty.conf" - "ags" -) +repo_api_url=https://api.github.com/repos/retrozinndev/colorshell # ------------- # Sends stdout log with type and message provided @@ -30,29 +18,22 @@ config_dirs=( # param $2 log message # ------------- function Send_log() { - log_message=$2 + log_message=`[[ -z $2 ]] && echo $1 || echo $2` + color="\e[34m" + log_type="info" - case ${1,,} in - "^warn(ing)$") + case "${1,,}" in + warn) color="\e[33m" log_type="warning" ;; - "^err(or)$") + err) color="\e[31m" log_type="error" ;; - - *) - color="\e[34m" - log_type="info" - ;; esac - if [[ -z $2 ]]; then - log_message=$1 - fi - echo -e "${color}[$log_type]\e[0m $log_message" } @@ -67,3 +48,28 @@ function Print_header() { echo "#############################" printf "\n" } + +# ------------- +# Ask a yes/no question to user +# Input answer is exported as $answer +# ------------- +function Ask() { + read -n 1 -p "$@ [y/n] " answer + printf '\n' + if [[ ! $answer =~ [yn] ]]; then + Ask "$@" # restart if different from accepted chars + fi + + export answer +} + +# ------------- +# Ask the user to choose a number from the provided list +# Input answer is exported as $answer +# (this function is not done yet) +# ------------- +function Choose() { + read -n 1 -p "$1 [y/n] " r + printf '\n' + export answer=$r +} diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 00000000..30dbc618 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,336 @@ +// thanks Aylur!! +import "../node_modules/ags/lib/overrides"; +import "./config"; +import { + PluginApps, + PluginClipboard, + PluginMedia, + PluginShell, + PluginWallpapers, + PluginWebSearch, + PluginKill +} from "./runner/plugins"; +import { handleArguments } from "./modules/arg-handler"; +import { Runner } from "./runner/Runner"; +import { Windows } from "./windows"; +import { Notifications } from "./modules/notifications"; +import { Wallpaper } from "./modules/wallpaper"; +import { Stylesheet } from "./modules/stylesheet"; +import { Clipboard } from "./modules/clipboard"; +import { Gdk, Gtk } from "ags/gtk4"; +import { createBinding, createComputed, createRoot, getScope, Scope } from "ags"; +import { OSDModes, triggerOSD } from "./window/osd"; +import { programArgs, programInvocationName } from "system"; +import { setConsoleLogDomain } from "console"; +import { createSubscription, encoder, secureBaseBinding } from "./modules/utils"; +import { exec } from "ags/process"; +import { NightLight } from "./modules/nightlight"; +import { Backlights } from "./modules/backlight"; +import GObject, { register } from "ags/gobject"; + +import Media from "./modules/media"; +import GLib from "gi://GLib?version=2.0"; +import Gio from "gi://Gio?version=2.0"; +import Adw from "gi://Adw?version=1"; +import AstalWp from "gi://AstalWp"; + + +const runnerPlugins: Array = [ + PluginApps, + PluginShell, + PluginWebSearch, + PluginKill, + PluginMedia, + PluginWallpapers, + PluginClipboard +]; + +const defaultWindows: Array = [ "bar" ]; + +GLib.unsetenv("LD_PRELOAD"); + + +@register({ GTypeName: "Shell" }) +export class Shell extends Adw.Application { + private static instance: Shell; + + #scope!: Scope; + #connections = new Map | number>(); + #providers: Array = []; + #gresource: Gio.Resource|null = null; + #socketService!: Gio.SocketService; + #socketFile!: Gio.File; + + get scope() { return this.#scope; } + + constructor() { + super({ + applicationId: "io.github.retrozinndev.colorshell", + flags: Gio.ApplicationFlags.HANDLES_COMMAND_LINE, + version: COLORSHELL_VERSION ?? "0.0.0-unknown", + }); + + setConsoleLogDomain("colorshell"); + GLib.set_application_name("colorshell"); + } + + public static getDefault(): Shell { + if(!this.instance) + this.instance = new Shell(); + + return this.instance; + } + + public resetStyle(): void { + this.#providers.forEach(provider => + Gtk.StyleContext.remove_provider_for_display( + Gdk.Display.get_default()!, + provider + ) + ); + } + + public removeProvider(provider: Gtk.CssProvider): void { + if(!this.#providers.includes(provider)) { + console.warn("Colorshell: Couldn't find the provided GtkCssProvider to remove. Was it added before?"); + return; + } + + for(let i = 0; i < this.#providers.length; i++) { + const prov = this.#providers[i]; + if(prov === provider) { + this.#providers.splice(i, 1); + Gtk.StyleContext.remove_provider_for_display( + Gdk.Display.get_default()!, + provider + ); + break; + } + } + } + + public applyStyle(stylesheet: string): void { + try { + const provider = Gtk.CssProvider.new(); + provider.load_from_string(stylesheet) + this.#providers.push(provider); + + Gtk.StyleContext.add_provider_for_display( + Gdk.Display.get_default()!, + provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ); + } catch(e) { + console.error(`Colorshell: Couldn't apply style. Stderr: ${e}`); + return; + } + } + + vfunc_command_line(cmd: Gio.ApplicationCommandLine): number { + const args = cmd.get_arguments().toSpliced(0, 1); // remove executable + + if(cmd.isRemote) { + try { + // warn user that this method is pretty slow + cmd.print_literal("\nColorshell: !! Using a remote instance to communicate is pretty slow, \ +you should use the socket in the XDG_RUNTIME_DIR/colorshell.sock for a faster response.\n\n"); + + const res = handleArguments(cmd, args); + + cmd.done(); + cmd.set_exit_status(res); + return res; + } catch(_e) { + const e = _e as Error; + cmd.printerr_literal(`Error: something went wrong! Stderr: ${e.message}\n${e.stack}`); + cmd.done(); + return 1; + } + } else { + if(args.length > 0) { + cmd.printerr_literal("Error: colorshell not running. Try to clean-run before using arguments"); + cmd.done(); + return 1; + } + + this.activate(); + } + + return 0; + } + + vfunc_activate(): void { + super.vfunc_activate(); + this.hold(); + this.main(); + } + + private init(): void { + // load gresource from build-defined path + try { + this.#gresource = Gio.Resource.load(GRESOURCES_FILE.split('/').filter(s => + s !== "" + ).map(path => { + // support environment variables at runtime + if(/^\$/.test(path)) { + const env = GLib.getenv(path.replace(/^\$/, "")); + if(env === null) + throw new Error(`Couldn't get environment variable: ${path}`); + + return env; + } + + return path; + }).join('/')); + Gio.resources_register(this.#gresource); + + // add icons + Gtk.IconTheme.get_for_display(Gdk.Display.get_default()!) + .add_resource_path("/io/github/retrozinndev/colorshell/icons") + } catch(_e) { + const e = _e as Error; + console.error(`Error: couldn't load gresource! Stderr: ${e.message}\n${e.stack}`); + } + + this.#socketFile = Gio.File.new_for_path(`${GLib.get_user_runtime_dir() ?? + `/run/user/${exec("id -u").trim()}`}/colorshell.sock`); + + if(this.#socketFile.query_exists(null)) { + console.log(`Colorshell: Deleting previous instance's socket`); + this.#socketFile.delete(null); + } + + this.#socketService = Gio.SocketService.new(); + this.#socketService.add_address( + Gio.UnixSocketAddress.new(this.#socketFile.get_path()!), + Gio.SocketType.STREAM, + Gio.SocketProtocol.DEFAULT, + null + ); + + // handle communication via socket + this.#connections.set(this.#socketService, + this.#socketService.connect("incoming", (_, conn) => { + const inputStream = Gio.DataInputStream.new(conn.inputStream); + inputStream.read_upto_async('\x00', -1, GLib.PRIORITY_DEFAULT, null, (_, res) => { + const [args, len] = inputStream.read_upto_finish(res); + inputStream.close(null); + conn.inputStream.close(null); + + if(len < 1) { + console.error(`Colorshell: No args provided via socket call`); + return; + } + + try { + const [success, parsedArgs] = GLib.shell_parse_argv(`colorshell ${args}`); + parsedArgs?.splice(0, 1); // remove the unnecessary `colorshell` part + + if(success) { + handleArguments({ + print_literal: (msg) => conn.outputStream.write_bytes( + encoder.encode(`${msg}\n`), + null + ), + // TODO: support writing to stderr(i don't know how to do that :sob:) + printerr_literal: (msg) => conn.outputStream.write_bytes( + encoder.encode(`${msg}\n`), + null + ) + }, parsedArgs!); + + conn.outputStream.flush(null); + conn.close(null); + return; + } + + conn.outputStream.write_bytes( + encoder.encode("Error: Unexpected error occurred on argument parsing!"), + null + ); + + conn.outputStream.flush(null); + conn.close(null); + } catch(_e) { + const e = _e as Error; + console.error(`Colorshell: An error occurred while writing to socket output. Stderr:\n${ + e.message}\n${e.stack}`); + } + }); + + return false; + }) + ); + } + + private main(): void { + Gtk.init(); + Adw.init(); + this.init(); + + createRoot((dispose) => { + console.log(`Colorshell: Initializing things`); + this.#connections.set(this, this.connect("shutdown", () => dispose())); + this.#scope = getScope(); + + NightLight.getDefault(); + + Media.getDefault(); + Clipboard.getDefault(); + + console.log("Colorshell: Initializing Wallpaper and Stylesheet modules"); + Wallpaper.getDefault(); + Stylesheet.getDefault(); + + console.log("Adding runner plugins"); + runnerPlugins.forEach(plugin => Runner.addPlugin(plugin)); + + createSubscription( + createComputed([ + secureBaseBinding(createBinding( + AstalWp.get_default(), "defaultSpeaker" + ), "volume", null), + secureBaseBinding(createBinding( + AstalWp.get_default(), "defaultSpeaker" + ), "mute", null) + ]), + () => !Windows.getDefault().isOpen("control-center") && + triggerOSD(OSDModes.sink) + ); + + createSubscription( + secureBaseBinding( + createBinding(Backlights.getDefault(), "default"), + "brightness", + 100 + ), + () => !Windows.getDefault().isOpen("control-center") && + triggerOSD(OSDModes.brightness) + ); + + this.#connections.set(Notifications.getDefault(), [ + Notifications.getDefault().connect("notification-added", () => { + Windows.getDefault().open("floating-notifications"); + }), + Notifications.getDefault().connect("notification-removed", (self) => { + self.notifications.length === 0 && Windows.getDefault().close("floating-notifications"); + }) + ]); + + defaultWindows.forEach(w => Windows.getDefault().open(w)); + }); + + this.#scope.onCleanup(() => { + console.log("Colorshell: disposing connections and quitting because of ::shutdown"); + this.#connections.forEach((ids, obj) => Array.isArray(ids) ? + ids.forEach(id => obj.disconnect(id)) + : obj.disconnect(ids)); + }); + } + + quit(): void { + this.release(); + } +} + +Shell.getDefault().runAsync([ programInvocationName, ...programArgs ]); diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 00000000..5748bb59 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,264 @@ +import { Scope } from "ags"; +import { createScopedConnection, decoder, encoder } from "../modules/utils"; +import { showWorkspaceNumber } from "../window/bar/widgets/Workspaces"; + +import windows from "./modules/windows"; +import volume from "./modules/volume"; +import devel from "./modules/devel"; +import media from "./modules/media"; +import Gio from "gi://Gio?version=2.0"; +import GLib from "gi://GLib?version=2.0"; + + +/** cli implementation for colorshell */ +export namespace Cli { + let rootScope: Scope; + let initialized: boolean = false; + const modules: Array = [ + // main module, no need for prefix + { + help: "manage colorshell windows and do more cool stuff.", + commands: [ + ...windows, + // others + { + name: "runner", + onCalled: (_, data) => { + return { + content: `Opening runner${data ? ` with "${data}"` : ""}...`, + type: "out" + }; + } + }, + { + name: "peek-workspace-num", + help: "peek the workspace numbers in the workspace indicator. (optional: time in millis)", + onCalled: () => { + showWorkspaceNumber(true); + return "Peeking workspace IDs..."; + } + } + ], + arguments: [ + { + name: "version", + alias: "v", + help: "print the current colorshell version", + onCalled: () => `colorshell by retrozinndev, version ${COLORSHELL_VERSION + }${DEVEL ? "(devel)" : ""}` + } + ] + }, + volume, + media + ]; + + export type Output = { + type: "err"|"out"; + content: string|Uint8Array; + } | string; + + /** argument passed to the command / module. + * output of onCalled is passed to */ + export type Argument = { + /** kebab-cased name for the argument(without the `--` prefix) + * @example help (turns into `--help` internally)*/ + name: string; + /** alias for the name (without the `-` prefix). + * @example help -> h */ + alias?: string; + /** whether the argument needs a value attribute. + * @example --file ~/a_nice_home_file.txt */ + hasValue?: boolean; + /** runtime-set value for the argument(if enabled) */ + value?: string; + /** help message for the argument */ + help?: string; + onCalled?: (value?: string) => void; + }; + + export type ArgumentData = { + argument: Argument; + data?: string; + }; + + export type Command = { + /** the command name to be called. + * @example `colorshell ${prefix?} ${command.name}`*/ + name: string; + help?: string; + /** data passed to the command. (only works when arguments are disabled) */ + data?: string; + arguments?: Array; + onCalled: (args: Array, data?: string) => Output; + }; + + export type Module = { + /** command to come after the cli call. + * @example `colorshell ${prefix?} ${command}`*/ + prefix?: string; + commands?: Array; + arguments?: Array; + help?: string; + /** called everytime the prefix is used, even when using module commands */ + onPrefixCalled?: () => void; + }; + + /** initialize the cli */ + export function init(scope: Scope, communicationMethod: Gio.SocketService|Gio.ApplicationCommandLine, app?: Gio.Application): void { + if(initialized) return; + + initialized = true; + rootScope = scope; + DEVEL && modules.push(devel); + + scope.run(() => { + if(communicationMethod instanceof Gio.SocketService) { + createScopedConnection( + communicationMethod, "incoming", (conn) => { + try { + return handleIncoming(conn); + } catch(_) {} + + return false; + } + ); + + return; + } + + if(!app) + throw new Error("GApplication not specified for GApplicationCommandLine communication method") + if(app.flags !& Gio.ApplicationFlags.HANDLES_COMMAND_LINE) + throw new Error("GApplication does not have the HANDLES_COMMAND_LINE flag or doesn't implement it") + + createScopedConnection( + app, + "command-line", + (cmd) => { + let hasError: boolean = false; + try { + handleArgs( + cmd.get_arguments().toSpliced(0, 1), + (str, type) => { + if(type === "err") { + cmd.printerr_literal(str); + hasError = true; + return; + } + + cmd.print_literal(str); + } + ); + } catch(_) { + // TODO better error message + hasError = true; + } + + return hasError ? 1 : 0; + } + ); + }); + } + + /** handle incoming socket calls */ + function handleIncoming(conn: Gio.SocketConnection): void { + const inputStream = Gio.DataInputStream.new(conn.inputStream); + + inputStream.read_upto_async('\x00', -1, GLib.PRIORITY_DEFAULT, null, (_, res) => { + const [args, len] = inputStream.read_upto_finish(res); + inputStream.close(null); + conn.inputStream.close(null); + + if(len < 1) { + console.error(`Colorshell: No args provided via socket call`); + return; + } + + try { + const [success, parsedArgs] = GLib.shell_parse_argv(`colorshell ${args}`); + parsedArgs?.splice(0, 1); // remove the unnecessary `colorshell` part + + if(success) { + handleArgs(parsedArgs!, conn.outputStream); + + conn.outputStream.flush(null); + conn.close(null); + return; + } + + conn.outputStream.write_bytes( + encoder.encode("Error: Unexpected syntax error occurred"), + null + ); + + conn.outputStream.flush(null); + conn.close(null); + } catch(_e) { + const e = _e as Error; + console.error(`Colorshell: An error occurred while writing to socket output. Stderr:\n${ + e.message}\n${e.stack}`); + } + }); + } + + /** translate app arguments to modules/commands + * order: module ?arg -> command ?arg */ + function handleArgs(args: Array, writeTo: Gio.OutputStream|((str: string, type: "out"|"err") => void)): void { + let mod: Module; + let command: Command|undefined; + const modArgs: Array = []; + const cmdArgs: Array = []; + + function print(out: Output): void { + const content = `${outputToString(out)}\n`; + const type: "out"|"err" = typeof out === "object" ? + out.type + : "out"; + + typeof writeTo === "function" ? + writeTo(content, type) + : writeTo.write_bytes( + encoder.encode(`${outputToString(out)}\n`), + null + ); + } + + function handleCommandArguments(cmd: Module|Command, args: Array, index: number, printFun: (out: Output) => void): void { + const argNameRegEx = /^--/, argAliasRegEx = /^-/; + let argName: string; + + if(args[index].startsWith("--")) { + + } + } + + const firstFoundMod = modules.filter(mod => mod.prefix === args[0])[0]; + mod = firstFoundMod ?? modules[0]; + + if(!mod) { + print({ + content: `No command module found with the name ${args[0]}!`, + type: "err" + }); + return; + } + + for(let i = 1; i < args.length; i++) { + const arg = args[i]; + + if(/^-/.test(arg)) { + handleCommandArguments(command ?? mod, args, i, print); + continue; + } + } + + function outputToString(out: Output): string { + if(typeof out === "object") + return out.content instanceof Uint8Array ? + decoder.decode(out.content) + : out.content; + + return out; + } +} diff --git a/src/cli/modules/devel.ts b/src/cli/modules/devel.ts new file mode 100644 index 00000000..f2d5e4c3 --- /dev/null +++ b/src/cli/modules/devel.ts @@ -0,0 +1,16 @@ +import { Gtk } from "ags/gtk4"; +import { Cli } from ".."; + + +export default { + prefix: "dev", + help: "development tools to help debugging colorshell", + commands: [{ + name: "inspector", + help: "open the gtk's visual inspector", + onCalled: () => { + Gtk.Window.set_interactive_debugging(true); + return "Opening GTK Inspector..." + } + }] +} satisfies Cli.Module; diff --git a/src/cli/modules/media.ts b/src/cli/modules/media.ts new file mode 100644 index 00000000..c05bbef9 --- /dev/null +++ b/src/cli/modules/media.ts @@ -0,0 +1,46 @@ +import { Cli } from ".."; + + +export default { + prefix: "media", + help: "manage colorshell's active player", + commands: [ + { + name: "play", + help: "resume/start active player's media", + onCalled: () => "TODO" + }, { + name: "pause", + help: "pause the active player", + onCalled: () => "TODO" + }, { + name: "play-pause", + help: "toggle pause/resume the active player", + onCalled: () => "TODO" + }, { + name: "stop", + help: "stop the active player (if compatible)", + onCalled: () => "TODO" + }, { + name: "previous", + help: "go back to previous media in the active player", + onCalled: () => "TODO" + }, { + name: "next", + help: "jump to the next media in active player", + onCalled: () => "TODO" + }, { + name: "bus-name", + help: "retrieve the active player's mpris bus name", + onCalled: () => "TODO" + }, { + name: "list", + help: "list available players implementing mpris", + onCalled: () => "TODO" + }, { + name: "select", + help: "resume/start active player's media", + onCalled: (_, busName) => "TODO" + } + ] +} satisfies Cli.Module; diff --git a/src/cli/modules/volume.ts b/src/cli/modules/volume.ts new file mode 100644 index 00000000..73773a1a --- /dev/null +++ b/src/cli/modules/volume.ts @@ -0,0 +1,27 @@ +import { Cli } from ".."; + + +export default { + prefix: "volume", + help: "manage audio device volume/sensitivity. available devices are sink(speaker) and source(microphone).\ +example usage: `colorshell volume increase sink 5%`", + commands: [ + { + name: "increase", + help: "increase volume/sensitivity of a sink/source", + onCalled: () => "TODO" + }, { + name: "decrease", + help: "decrease volume/sensitivity of a sink/source", + onCalled: () => "TODO" + }, { + name: "set", + help: "set the volume/sensitivity of a sink/source", + onCalled: () => "TODO" + }, { + name: "mute", + help: "toggle-mute a sink/source's audio", + onCalled: () => "TODO" + } + ] +} satisfies Cli.Module; diff --git a/src/cli/modules/windows.ts b/src/cli/modules/windows.ts new file mode 100644 index 00000000..e6cf02f0 --- /dev/null +++ b/src/cli/modules/windows.ts @@ -0,0 +1,46 @@ +import { Cli } from ".."; + + +export default [ + { + name: "open", + onCalled: (_, data) => { + return { + type: "out", + content: "TODO" + } + } + }, { + name: "toggle", + onCalled: (_, data) => { + return { + type: "out", + content: "TODO" + } + } + }, { + name: "close", + onCalled: (_, data) => { + return { + type: "out", + content: "TODO" + } + } + }, { + name: "windows", + onCalled: () => { + return { + type: "out", + content: "TODO" + } + } + }, { + name: "reopen", + onCalled: () => { + return { + type: "out", + content: "TODO" + } + } + } +] satisfies Array; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 00000000..86535cf8 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,80 @@ +import { Config } from "./modules/config"; + +import GLib from "gi://GLib?version=2.0"; + + +const generalConfigDefaults = { + notifications: { + timeout_low: 4000, + timeout_normal: 6000, + timeout_critical: 0, + /** notification popup horizontal position. can be "left" or "right" + * @default "right" */ + position_h: "right", + /** vertical notification popup position. can be "top" or "bottom" + * @default "top" */ + position_v: "top" + }, + + night_light: { + /** whether to save night light values to disk */ + save_on_shutdown: true + }, + + workspaces: { + /** breaks `enable_helper`, makes all workspaces show their respective ID + * by default */ + always_show_id: false, + /** this is the function that shows the Workspace's IDs + * around the current workspace if one breaks the crescent order. + * It basically helps keyboard navigation between workspaces. + * --- + * Example: 1(empty, current, shows ID), 2(empty, does not appear(makes + * the previous not to be in a crescent order)), 3(not empty, shows ID) */ + enable_helper: true, + /** hide workspace indicator if there's only one active workspace */ + hide_if_single: false + }, + + clock: { + /** use the same format as gnu's `date` command */ + date_format: "%A %d, %H:%M" + }, + + misc: { + play_bell_on_volume_change: true + } +}; + +const userDataDefaults = { + /** last default adapter */ + bluetooth_default_adapter: undefined as unknown as string, + + control_center: { + /** last default backlight */ + default_backlight: undefined as unknown as string + }, + + night_light: { + /** last blue light filter temperature */ + temperature: 6000, + /** last gamma filter value */ + gamma: 100, + /** wheter to enable identity filters("disables" the filters) */ + identity: true + } +}; + +export const userData = new Config< + keyof typeof userDataDefaults, + (typeof userDataDefaults)[keyof typeof userDataDefaults] +>( + `${GLib.get_user_data_dir()}/colorshell/data.json`, + userDataDefaults +); + +export const generalConfig = new Config( + `${GLib.get_user_config_dir()}/colorshell/config.json`, + generalConfigDefaults +); diff --git a/ags/env.d.ts b/src/env.d.ts similarity index 76% rename from ags/env.d.ts rename to src/env.d.ts index 467c0a41..9a476ac5 100644 --- a/ags/env.d.ts +++ b/src/env.d.ts @@ -1,4 +1,7 @@ declare const SRC: string +declare const DEVEL: boolean; +declare const GRESOURCES_FILE: string; +declare const COLORSHELL_VERSION: string; declare module "inline:*" { const content: string diff --git a/ags/i18n/intl.ts b/src/i18n/intl.ts similarity index 74% rename from ags/i18n/intl.ts rename to src/i18n/intl.ts index 32f96c60..9324c36d 100644 --- a/ags/i18n/intl.ts +++ b/src/i18n/intl.ts @@ -1,4 +1,4 @@ -import { GLib } from "astal"; +import GLib from "gi://GLib?version=2.0"; const i18nKeys = { @@ -11,16 +11,15 @@ const languages: Array = Object.keys(i18nKeys); let language: string = getSystemLanguage(); export function getSystemLanguage(): string { - const sysLanguage: (string|null|undefined) = GLib.getenv("LANG") || GLib.getenv("LANGUAGE"); - - if(!sysLanguage) { - console.log(`[WARNING] Couldn't get system language, fallback to default ${languages[0]}`); - console.log("[TIP] Please set the LANG or LANGUAGE environment variable"); + const sysLanguage: (string|null|undefined) = GLib.getenv("LANG") ?? GLib.getenv("LANGUAGE"), + splitted: Array|undefined = sysLanguage?.split('.'); + if(!splitted || !languages.includes(splitted![0])) { + console.warn(`Intl: Falling back to default \`${languages[0]}\``); return languages[0]; } - return sysLanguage.split('.')[0]; + return splitted![0]; } export function setLanguage(lang: string): string { @@ -31,8 +30,8 @@ export function setLanguage(lang: string): string { } }); - throw new Error(`(i18n/intl) Couldn't set language: ${lang}`, { - cause: `Language ${lang} not found in languages of type ${typeof languages}` + throw new Error(`Intl: couldn't set language: ${lang}`, { + cause: `language ${lang} not found in languages of type ${typeof languages}` }); } diff --git a/ags/i18n/lang/en_US.ts b/src/i18n/lang/en_US.ts similarity index 80% rename from ags/i18n/lang/en_US.ts rename to src/i18n/lang/en_US.ts index 7486f2ad..1d987324 100644 --- a/ags/i18n/lang/en_US.ts +++ b/src/i18n/lang/en_US.ts @@ -20,7 +20,21 @@ export default { connect: "Connect", disconnect: "Disconnect", + copy_to_clipboard: "Copy to clipboard", + media: { + play: "Play", + pause: "Pause", + next: "Next", + previous: "Previous", + loop: "Loop", + no_loop: "No loop", + song_loop: "Loop song", + shuffle_order: "Shuffle", + follow_order: "Follow order", + no_artist: "No artist", + no_title: "No title" + }, control_center: { tiles: { enabled: "Enabled", @@ -61,6 +75,11 @@ export default { gamma: "Gamma", temperature: "Temperature" }, + backlight: { + title: "Backlight", + description: "Control the brightness of your screens", + refresh: "Refresh backlights" + }, bluetooth: { title: "Bluetooth", description: "Manage Bluetooth devices", @@ -83,4 +102,4 @@ export default { ask_popup: { title: "Question" } -} as i18nStruct; +} satisfies i18nStruct; diff --git a/ags/i18n/lang/pt_BR.ts b/src/i18n/lang/pt_BR.ts similarity index 79% rename from ags/i18n/lang/pt_BR.ts rename to src/i18n/lang/pt_BR.ts index f9d1f309..67805a3a 100644 --- a/ags/i18n/lang/pt_BR.ts +++ b/src/i18n/lang/pt_BR.ts @@ -20,7 +20,21 @@ export default { apps: "Aplicativos", clear: "Limpar", + copy_to_clipboard: "Copiar para a Área de Transferência", + media: { + next: "Próxima faixa", + pause: "Pausar", + play: "Tocar", + previous: "Faixa anterior", + loop: "Repetir", + no_loop: "Não repetir", + song_loop: "Repetir faixa", + follow_order: "Seguir ordem", + shuffle_order: "Ordem aleatória", + no_title: "Sem título", + no_artist: "Sem artista" + }, control_center: { tiles: { enabled: "Ligado", @@ -61,6 +75,11 @@ export default { temperature: "Temperatura", gamma: "Gama" }, + backlight: { + title: "Brilho", + description: "Controle o nível de brilho das suas telas", + refresh: "Recarregar" + }, bluetooth: { title: "Bluetooth", description: "Gerencie dispositivos Bluetooth", @@ -83,4 +102,4 @@ export default { ask_popup: { title: "Pergunta" } -} as i18nStruct; +} satisfies i18nStruct; diff --git a/ags/i18n/lang/ru_RU.ts b/src/i18n/lang/ru_RU.ts similarity index 100% rename from ags/i18n/lang/ru_RU.ts rename to src/i18n/lang/ru_RU.ts diff --git a/ags/i18n/struct.ts b/src/i18n/struct.ts similarity index 79% rename from ags/i18n/struct.ts rename to src/i18n/struct.ts index b8568989..5a60843e 100644 --- a/ags/i18n/struct.ts +++ b/src/i18n/struct.ts @@ -17,9 +17,23 @@ export type i18nStruct = { disconnect: string, connect: string, - apps: string; - clear: string; + apps: string, + clear: string, + copy_to_clipboard: string, + media: { + loop: string, + song_loop: string, + no_loop: string, + shuffle_order: string, + follow_order: string, + pause: string, + play: string, + next: string, + previous: string, + no_artist: string, + no_title: string + }, control_center: { tiles: { enabled: string, @@ -59,6 +73,11 @@ export type i18nStruct = { title: string, interface: string }, + backlight: { + title: string, + description: string, + refresh: string + }, bluetooth: { title: string, description: string, diff --git a/ags/scripts/apps.ts b/src/modules/apps.ts similarity index 71% rename from ags/scripts/apps.ts rename to src/modules/apps.ts index 5b9890de..86b868ed 100644 --- a/ags/scripts/apps.ts +++ b/src/modules/apps.ts @@ -1,12 +1,12 @@ -import { Astal } from "astal/gtk3"; +import { Gdk, Gtk } from "ags/gtk4"; +import { execAsync } from "ags/process"; import AstalApps from "gi://AstalApps"; import AstalHyprland from "gi://AstalHyprland"; -import { execAsync } from "astal"; export const uwsmIsActive: boolean = await execAsync( - "uwsm check is-active hyprland-uwsm.desktop" + "uwsm check is-active" ).then(() => true).catch(() => false); const astalApps: AstalApps.Apps = new AstalApps.Apps(); @@ -28,13 +28,17 @@ export function getAstalApps(): AstalApps.Apps { /** handles running with uwsm if it's installed */ export function execApp(app: AstalApps.Application|string, dispatchExecArgs?: string) { const executable = (typeof app === "string") ? app - : app.executable.replace(/(%f|%F|%u|%U|%i|%c|%k)/g, ""); + : app.executable.replace(/%[fFcuUik]/g, ""); AstalHyprland.get_default().dispatch("exec", - `${dispatchExecArgs ? `${dispatchExecArgs} ` : ""}${uwsmIsActive ? "uwsm app -- " : ""}${executable}` + `${dispatchExecArgs ? `${dispatchExecArgs} ` : ""}${uwsmIsActive ? "uwsm-app -- " : ""}${executable}` ); } +export function lookupIcon(name: string): boolean { + return Gtk.IconTheme.get_for_display(Gdk.Display.get_default()!)?.has_icon(name); +} + export function getAppsByName(appName: string): (Array|undefined) { let found: Array = []; @@ -50,14 +54,23 @@ export function getAppsByName(appName: string): (Array|un export function getIconByAppName(appName: string): (string|undefined) { if(!appName) return undefined; - if(Astal.Icon.lookup_icon(appName)) + if(lookupIcon(appName)) return appName; - if(Astal.Icon.lookup_icon(appName.toLowerCase())) + if(lookupIcon(appName.toLowerCase())) return appName.toLowerCase(); const nameReverseDNS = appName.split('.'); - if(Astal.Icon.lookup_icon(nameReverseDNS[nameReverseDNS.length - 1])) + const lastItem = nameReverseDNS[nameReverseDNS.length - 1]; + const lastPretty = `${lastItem.charAt(0).toUpperCase()}${lastItem.substring(1, lastItem.length)}`; + + const uppercaseRDNS = nameReverseDNS.slice(0, nameReverseDNS.length - 1) + .concat(lastPretty).join('.'); + + if(lookupIcon(uppercaseRDNS)) + return uppercaseRDNS; + + if(lookupIcon(nameReverseDNS[nameReverseDNS.length - 1])) return nameReverseDNS[nameReverseDNS.length - 1]; const found: (AstalApps.Application|undefined) = getAppsByName(appName)?.[0]; @@ -73,7 +86,7 @@ export function getAppIcon(app: (string|AstalApps.Application)): (string|undefin if(typeof app === "string") return getIconByAppName(app); - if(app.iconName && Astal.Icon.lookup_icon(app.iconName)) + if(app.iconName && lookupIcon(app.iconName)) return app.iconName; if(app.wmClass) @@ -85,7 +98,7 @@ export function getAppIcon(app: (string|AstalApps.Application)): (string|undefin export function getSymbolicIcon(app: (string|AstalApps.Application)): (string|undefined) { const icon = getAppIcon(app); - return (icon && Astal.Icon.lookup_icon(`${icon}-symbolic`)) ? + return (icon && lookupIcon(`${icon}-symbolic`)) ? `${icon}-symbolic` : undefined; } diff --git a/src/modules/arg-handler.ts b/src/modules/arg-handler.ts new file mode 100644 index 00000000..7ba59db9 --- /dev/null +++ b/src/modules/arg-handler.ts @@ -0,0 +1,406 @@ +import { Gtk } from "ags/gtk4"; +import { Wireplumber } from "./volume"; +import { Windows } from "../windows"; +import { restartInstance } from "./reload-handler"; +import { timeout } from "ags/time"; +import { Runner } from "../runner/Runner"; +import { showWorkspaceNumber } from "../window/bar/widgets/Workspaces"; +import { playSystemBell } from "./utils"; +import { Shell } from "../app"; +import { generalConfig } from "../config"; + +import Media from "./media"; +import AstalIO from "gi://AstalIO"; +import AstalMpris from "gi://AstalMpris"; + + +export type RemoteCaller = { + printerr_literal: (message: string) => void, + print_literal: (message: string) => void +}; + +let wsTimeout: AstalIO.Time|undefined; +const help = `Manage Astal Windows and do more stuff. From retrozinndev's colorshell, \ +made using GTK4, AGS, Gnim and Astal libraries by Aylur. + +Window Management: + open [window]: opens the specified window. + close [window]: closes all instances of specified window. + toggle [window]: toggle-open/close the specified window. + windows: list shell windows and their respective status. + reload: quit this instance and start a new one. + reopen: restart all open-windows. + quit: exit the main instance of the shell. + +Audio Controls: + volume: speaker and microphone volume controller, see "volume help". + +Media Controls: + media: manage colorshell's active player, see "media help". +${DEVEL ? ` +Development Tools: + dev: tools to help debugging colorshell +` : ""} +Other options: + runner [initial_text]: open the application runner, optionally add an initial search. + peek-workspace-num [millis]: peek the workspace numbers on bar window. + v, version: display current colorshell version. + h, help: shows this help message. + +2025 (c) retrozinndev's colorshell, licensed under the BSD 3-Clause License. +https://github.com/retrozinndev/colorshell +`.trim(); + +export function handleArguments(cmd: RemoteCaller, args: Array): number { + switch(args[0]) { + case "help": + case "h": + cmd.print_literal(help); + return 0; + + case "version": + case "v": + cmd.print_literal(`colorshell by retrozinndev, version ${COLORSHELL_VERSION + }${DEVEL ? " (devel)" : ""}\nhttps://github.com/retrozinndev/colorshell`); + return 0; + + case "dev": + return handleDevArgs(cmd, args); + + case "open": + case "close": + case "toggle": + case "windows": + case "reopen": + return handleWindowArgs(cmd, args); + + case "volume": + return handleVolumeArgs(cmd, args); + + case "media": + return handleMediaArgs(cmd, args); + + case "reload": + restartInstance(); + cmd.print_literal("Restarting instance..."); + return 0; + + case "quit": + try { + Shell.getDefault().quit(); + cmd.print_literal("Quitting main instance..."); + } catch(_e) { + const e = _e as Error; + cmd.printerr_literal(`Error: couldn't quit instance. Stderr: ${e.message}\n${e.stack}`); + return 1; + } + return 0; + + case "runner": + !Runner.instance ? + Runner.openDefault(args[1] || undefined) + : Runner.close(); + + cmd.print_literal(`Opening runner${args[1] ? ` with predefined text: "${args[1]}"` : ""}`); + return 0; + + case "peek-workspace-num": + if(wsTimeout) { + cmd.print_literal("Workspace numbers are already showing"); + return 0; + } + + showWorkspaceNumber(true); + wsTimeout = timeout(Number.parseInt(args[1]) || 2200, () => { + showWorkspaceNumber(false); + wsTimeout = undefined; + }); + cmd.print_literal("Toggled workspace numbers"); + return 0; + } + + cmd.printerr_literal("Error: command not found! try checking help"); + return 1; +} + +function handleDevArgs(cmd: RemoteCaller, args: Array): number { + if(/h|help/.test(args[1])) { + cmd.print_literal(` +Debugging tools for colorshell. + +Options: + inspector: open GTK's visual debugger +`.trim()); + return 0; + } + + switch(args[1]) { + case "inspector": + cmd.print_literal("Opening inspector..."); + Gtk.Window.set_interactive_debugging(true); + return 0; + } + + cmd.printerr_literal("Error: command not found! try checking `dev help`"); + return 1; +} + +function handleMediaArgs(cmd: RemoteCaller, args: Array): number { + if(/h|help/.test(args[1])) { + const mediaHelp = ` +Manage colorshell's active player + +Options: + play: resume/start active player's media. + pause: pause the active player. + play-pause: toggle play/pause on active player. + stop: stop the active player's media. + previous: go back to previous media if player supports it. + next: jump to next media if player supports it. + bus-name: get active player's mpris bus name. + list: show available players with their bus name. + select bus_name: change the active player, where bus_name is + the desired player's mpris bus name(with the mediaplayer2 prefix). +`.trim(); + cmd.print_literal(mediaHelp); + return 0; + } + + const activePlayer: AstalMpris.Player|undefined = Media.getDefault().player.available ? + Media.getDefault().player + : undefined; + const players = AstalMpris.get_default().players.filter(pl => pl.available); + + if(!activePlayer) { + cmd.printerr_literal(`Error: no active player found! try playing some media first`); + return 1; + } + + switch(args[1]) { + case "play": + activePlayer.play(); + cmd.print_literal("Playing"); + return 0; + + case "list": + cmd.print_literal(`Available players:\n${players.map(pl => { + let playbackStatusStr: string; + switch(pl.playbackStatus) { + case AstalMpris.PlaybackStatus.PAUSED: + playbackStatusStr = "paused"; + break; + case AstalMpris.PlaybackStatus.PLAYING: + playbackStatusStr = "playing"; + break; + default: + playbackStatusStr = "stopped"; + break; + } + + return ` ${pl.busName}: ${playbackStatusStr}`; + }).join('\n')}`); + return 0; + + case "pause": + activePlayer.pause(); + cmd.print_literal("Paused"); + return 0; + + case "play-pause": + activePlayer.play_pause(); + cmd.print_literal( + activePlayer?.playbackStatus === AstalMpris.PlaybackStatus.PAUSED ? + "Toggled play" + : "Toggled pause" + ); + return 0; + + case "stop": + activePlayer.stop(); + cmd.print_literal("Stopped!"); + return 0; + + case "previous": + activePlayer.canGoPrevious && activePlayer.previous(); + cmd.print_literal( + activePlayer.canGoPrevious ? + "Back to previous" + : "Player does not support this command" + ); + return 0; + + case "next": + activePlayer.canGoNext && activePlayer.next(); + cmd.print_literal( + activePlayer.canGoNext ? + "Jump to next" + : "Player does not support this command" + ); + return 0; + + case "bus-name": + cmd.print_literal(activePlayer.busName); + return 0; + + case "select": + if(!args[2] || !players.filter(pl => pl.busName == args[2])?.[0]) { + cmd.printerr_literal(`Error: either no player was specified or the player with \ +specified bus name does not exist/is not available!`); + + return 1; + } + + Media.getDefault().player = players.filter(pl => pl.busName === args[2])[0]; + cmd.print_literal(`Done setting player to \`${args[2]}\`!`); + return 0; + } + + cmd.printerr_literal("Error: couldn't handle media arguments, try checking `media help`"); + return 1; +} + +function handleWindowArgs(cmd: RemoteCaller, args: Array): number { + switch(args[0]) { + case "reopen": + Windows.getDefault().reopen(); + cmd.print_literal("Reopening all open windows"); + return 0; + + case "windows": + cmd.print_literal( + Object.keys(Windows.getDefault().windows).map(name => + `${name}: ${Windows.getDefault().isOpen(name) ? + "open" + : "closed"}` + ).join('\n') + ); + return 0; + } + + const specifiedWindow: string = args[1]; + + if(!specifiedWindow) { + cmd.printerr_literal("Error: window argument not specified!"); + return 1; + } + + if(!Windows.getDefault().hasWindow(specifiedWindow)) { + cmd.printerr_literal( + `Error: "${specifiedWindow}" not found on window list! Make sure to add new windows to the system before using them` + ); + return 1; + } + + switch(args[0]) { + case "open": + if(!Windows.getDefault().isOpen(specifiedWindow)) { + Windows.getDefault().open(specifiedWindow); + cmd.print_literal(`Opening window with name "${args[1]}"`); + return 0; + } + + cmd.print_literal(`Window is already open, ignored`); + return 0; + + case "close": + if(Windows.getDefault().isOpen(specifiedWindow)) { + Windows.getDefault().close(specifiedWindow); + cmd.print_literal(`Closing window with name "${args[1]}"`); + return 0; + } + + cmd.print_literal(`Window is already closed, ignored`); + return 0; + + case "toggle": + if(!Windows.getDefault().isOpen(specifiedWindow)) { + Windows.getDefault().open(specifiedWindow); + cmd.print_literal(`Toggle opening window "${args[1]}"`); + return 0; + } + + Windows.getDefault().close(specifiedWindow); + cmd.print_literal(`Toggle closing window "${args[1]}"`); + return 0; + } + + cmd.printerr_literal("Couldn't handle window management arguments"); + return 1; +} + +function handleVolumeArgs(cmd: RemoteCaller, args: Array): number { + if(!args[1]) { + cmd.printerr_literal(`Error: please specify what to do! see \`volume help\``); + return 1; + } + + if(/^(sink|source)[-](increase|decrease|set)$/.test(args[1]) && !args[2]) { + cmd.printerr_literal(`Error: you forgot to set a value`); + return 1; + } + + const command: Array = args[1].split('-'); + + if(/h|help/.test(args[1])) { + cmd.print_literal(` +Control speaker and microphone volumes +Options: + (sink|source)-set [number]: set speaker/microphone volume. + (sink|source)-mute: toggle mute for the speaker/microphone device. + (sink|source)-increase [number]: increases speaker/microphone volume. + (sink|source)-decrease [number]: decreases speaker/microphone volume. +`.trim()); + + return 0; + } + + if(command[1] === "mute") { + command[0] === "sink" ? + Wireplumber.getDefault().toggleMuteSink() + : Wireplumber.getDefault().toggleMuteSource() + + cmd.print_literal(`Done toggling mute!`); + return 0; + } + + if(Number.isNaN(Number.parseFloat(args[2]))) { + cmd.printerr_literal(`Error: argument "${args[2]} is not a valid number! Please use integers"`); + return 1; + } + + switch(command[1]) { + case "set": + command[0] === "sink" ? + Wireplumber.getDefault().setSinkVolume(Number.parseInt(args[2])) + : Wireplumber.getDefault().setSourceVolume(Number.parseInt(args[2])) + cmd.print_literal(`Done! Set ${command[0]} volume to ${args[2]}`); + return 0; + + case "increase": + command[0] === "sink" ? + Wireplumber.getDefault().increaseSinkVolume(Number.parseInt(args[2])) + : Wireplumber.getDefault().increaseSourceVolume(Number.parseInt(args[2])) + + generalConfig.getProperty("misc.play_bell_on_volume_change", "boolean") === true && + playSystemBell(); + + cmd.print_literal(`Done increasing volume by ${args[2]}`); + return 0; + + case "decrease": + command[0] === "sink" ? + Wireplumber.getDefault().decreaseSinkVolume(Number.parseInt(args[2])) + : Wireplumber.getDefault().decreaseSourceVolume(Number.parseInt(args[2])) + + generalConfig.getProperty("misc.play_bell_on_volume_change", "boolean") === true && + playSystemBell(); + + cmd.print_literal(`Done decreasing volume to ${args[2]}`); + return 0; + } + + cmd.printerr_literal(`Error: couldn't resolve arguments! "${args.join(' ') + .replace(new RegExp(`^${args[0]}`), "")}"`); + + return 1; +} diff --git a/src/modules/auth.ts b/src/modules/auth.ts new file mode 100644 index 00000000..3332a706 --- /dev/null +++ b/src/modules/auth.ts @@ -0,0 +1,111 @@ +import { exec, execAsync } from "ags/process"; +import { register } from "ags/gobject"; +import { AuthPopup } from "../widget/AuthPopup"; + +import Polkit from "gi://Polkit?version=1.0"; +import PolkitAgent from "gi://PolkitAgent?version=1.0"; +import Gio from "gi://Gio?version=2.0"; +import GLib from "gi://GLib?version=2.0"; +import AstalAuth from "gi://AstalAuth?version=0.1"; + + +@register({ GTypeName: "AuthAgent" }) +export class Auth extends PolkitAgent.Listener { + private static instance: Auth; + #handle: any; + #user: Polkit.UnixUser; + #pam: AstalAuth.Pam; + + constructor() { + super(); + this.#user = Polkit.UnixUser.new(Number.parseInt(exec("id -u"))) as Polkit.UnixUser; + this.#pam = new AstalAuth.Pam; + + this.register( + PolkitAgent.RegisterFlags.RUN_IN_THREAD, + Polkit.UnixSession.new(this.#user.get_uid().toString()), + "/io/github/retrozinndev/colorshell/AuthAgent", + null + ); + } + + vfunc_dispose() { + PolkitAgent.Listener.unregister(this.#handle); + } + + public static initiate_authentication(action_id: string, message: string, icon_name: string, details: Polkit.Details, cookie: string, identities: Array, cancellable: Gio.Cancellable|null, callback: Gio.AsyncReadyCallback|null): void { + const task = Gio.Task.new( + this.getDefault(), + cancellable, + callback as Gio.AsyncReadyCallback|null + ); + + AuthPopup({ + text: message, + iconName: icon_name, + onContinue: (data, reject, approve) => { + this.getDefault().validateAuth(data.passwd, data.user).then((success) => { + approve(); + task.return_boolean(success); + }).catch((error: GLib.Error) => { + // TODO implement a number of tries (usually it's 3) + reject(`Authentication failed: ${error.message}`); + task.return_error(error); + }); + } + }); + + } + + initiate_authentication_finish(res: Gio.AsyncResult): boolean { + + } + + // TODO: support fingerprint/facial auth + /** @returns true if data are correct, rejects promise otherwise */ + public validateAuth(passwd: string, user?: string): Promise { + if(user !== undefined) + this.#pam.username = user; + + return new Promise((resolve, reject) => { + const connections: Array = []; + connections.push( + this.#pam.connect("fail", () => { + reject( + `Auth: Authentication has failed for user ${this.#pam.username}` + ); + connections.forEach(id => this.#pam.disconnect(id)); + }), + this.#pam.connect("success", () => { + resolve(true); + connections.forEach(id => this.#pam.disconnect(id)); + }) + ); + + this.#pam.start_authenticate(); + this.#pam.supply_secret(passwd); + }); + } + + /** @returns true if successful */ + public async polkitExecute(cmd: string | Array): Promise { + let success: boolean = true; + await execAsync([ + "pkexec", + "--", + ...(Array.isArray(cmd) ? cmd : [ cmd ]) ] + ).catch((r) => { + success = false; + console.error(`Polkit: Couldn't authenticate. Stderr: ${r}`); + }); + + return success; + } + + public static getDefault(): Auth { + if(!this.instance) + this.instance = new Auth(); + + return this.instance; + } +} diff --git a/src/modules/backlight.ts b/src/modules/backlight.ts new file mode 100644 index 00000000..2fa160b3 --- /dev/null +++ b/src/modules/backlight.ts @@ -0,0 +1,209 @@ +import { monitorFile, readFile } from "ags/file"; +import { exec } from "ags/process"; +import GObject, { getter, ParamSpec, register, setter, signal } from "ags/gobject"; + +import Gio from "gi://Gio?version=2.0"; + + +export namespace Backlights { + + const BacklightParamSpec = (name: string, flags: GObject.ParamFlags) => + GObject.ParamSpec.jsobject(name, null, null, flags) as ParamSpec; + + let instance: Backlights; + + export function getDefault(): Backlights { + if(!instance) + instance = new Backlights(); + + return instance; + } + + @register({ GTypeName: "Backlights" }) + class _Backlights extends GObject.Object { + + #backlights: Array = []; + #default: Backlight|null = null; + #available: boolean = false; + + + @getter(Array as unknown as ParamSpec>) + get backlights() { return this.#backlights; } + + @getter(BacklightParamSpec) + get default() { return this.#default!; } + + /** true if there are any backlights available */ + @getter(Boolean) + get available() { return this.#available; } + + public scan(): Array { + const dir = Gio.File.new_for_path(`/sys/class/backlight`), + backlights: Array = []; + + let fileEnum: Gio.FileEnumerator; + + try { + fileEnum = dir.enumerate_children("standard::*", Gio.FileQueryInfoFlags.NONE, null); + for(const backlight of fileEnum) { + try { + backlights.push(new Backlight(backlight.get_name())); + } catch(_) {} + } + } catch(_) { + return []; + } + + if(backlights.length < 1) { + if(this.#available) { + this.#available = false; + this.notify("available"); + } + + this.#default = null; + this.notify("default"); + } + + if(backlights.length > 0) { + if(this.#backlights.length < 1) { + this.#available = true; + this.notify("available"); + } + + if(!this.#default || !backlights.filter(bk => bk.path === this.#default?.path)[0]) { + this.#default = backlights[0]; + this.notify("default"); + } + } + + this.#backlights = backlights; + this.notify("backlights"); + + return backlights; + } + + public setDefault(bk: Backlight): void { + this.#default = bk; + this.notify("default"); + } + + constructor(scan: boolean = true) { + super(); + scan && this.scan(); + } + } + + @register({ GTypeName: "Backlight" }) + class _Backlight extends GObject.Object { + + declare $signals: GObject.Object.SignalSignatures & { + "brightness-changed": (value: number) => void + }; + + readonly #name: string; + #path: string; + #maxBrightness: number; + #brightness: number; + #monitor: Gio.FileMonitor; + #conn: number; + + @signal(Number) brightnessChanged(_: number): void {}; + + @getter(String) + get name() { return this.#name; } + + @getter(String) + get path() { return this.#path; } + + @getter(Boolean) + get isDefault() { return this.path === getDefault().default?.path; } + + @getter(Number) + get brightness() { return this.#brightness; }; + @setter(Number) + set brightness(level: number) { + if(!this.writeBrightness(level)) return; + + this.#brightness = level; + this.notify("brightness"); + this.emit("brightness-changed", level); + } + + @getter(Number) + get maxBrightness() { return this.#maxBrightness;}; + + + // intel_backlight is mostly the default on laptops + constructor(name: string = "intel_backlight") { + super(); + + // check if backlight exists + if(!Gio.File.new_for_path(`/sys/class/backlight/${name}/brightness`).query_exists(null)) + throw new Error(`Brightness: Couldn't find brightness for "${name}"`); + + // notify :is-default on default backlight change + this.#conn = getDefault().connect("notify::default", () => + this.notify("is-default")); + + this.#name = name; + this.#path = `/sys/class/backlight/${name}`; + this.notify("path"); + this.#maxBrightness = Number.parseInt(readFile(`${this.#path}/max_brightness`)); + this.notify("max-brightness"); + this.#brightness = Number.parseInt(readFile(`${this.#path}/brightness`)) + + + this.#monitor = monitorFile(`/sys/class/backlight/${name}/brightness`, () => { + this.#brightness = this.readBrightness(); + this.notify("brightness"); + this.emit("brightness-changed", this.brightness); + }); + } + + private readBrightness(): number { + try { + const brightness = Number.parseInt(readFile(`${this.#path}/brightness`)); + return brightness; + } catch(e) { + console.error(`Backlight: An error occurred while reading brightness from "${this.#name}"`); + } + + return this.#brightness ?? this.#maxBrightness ?? 0; + } + + private writeBrightness(level: number): boolean { + try { + exec(`brightnessctl -d ${this.#name} s ${level}`); + return true; + } catch(e) { + console.error(`Backlight: Couldn't set brightness for "${this.#name}". Stderr: ${e}`); + } + + return false; + } + + vfunc_dispose(): void { + this.#monitor.cancel(); + getDefault().disconnect(this.#conn); + } + + public emit( + signal: Signal, + ...args: Parameters<(typeof this.$signals)[Signal]> + ): void { + super.emit(signal, ...args); + } + + public connect( + signal: Signal, + callback: (self: typeof this, ...args: Parameters<(typeof this.$signals)[Signal]>) => ReturnType<(typeof this.$signals)[Signal]> + ): number { + return super.connect(signal, callback); + } + } + + export const Backlights = _Backlights; + export const Backlight = _Backlight; + export type Backlight = InstanceType; + export type Backlights = InstanceType; +} diff --git a/src/modules/bluetooth.ts b/src/modules/bluetooth.ts new file mode 100644 index 00000000..c96baeae --- /dev/null +++ b/src/modules/bluetooth.ts @@ -0,0 +1,162 @@ +import { createRoot, getScope, Scope } from "ags"; +import { execAsync } from "ags/process"; +import { userData } from "../config"; +import GObject, { getter, gtype, property, register, setter } from "ags/gobject"; + +import AstalBluetooth from "gi://AstalBluetooth"; + + +/** AstalBluetooth helper (implements the default adapter feature) */ +@register({ GTypeName: "Bluetooth" }) +export class Bluetooth extends GObject.Object { + declare $signals: { + "notify": () => void; + "notify::adapter": (adapter: AstalBluetooth.Adapter|null) => void; + "notify::is-available": (available: boolean) => void; + "notify::save-default-adapter": (save: boolean) => void; + "notify::last-device": (device: AstalBluetooth.Device|null) => void; + }; + + private static instance: Bluetooth; + private astalBl = AstalBluetooth.get_default(); + + #connections: Map|number> = new Map(); + #adapter: AstalBluetooth.Adapter|null = this.astalBl.adapter ?? null; + #scope!: Scope; + #isAvailable: boolean = false; + #lastDevice: AstalBluetooth.Device|null = null; + + @property(Boolean) + saveDefaultAdapter: boolean = true; + + @getter(Boolean) + get isAvailable() { return this.#isAvailable; } + + /** last connected device, can be null */ + @getter(AstalBluetooth.Device) + get lastDevice() { return this.#lastDevice!; } + + @getter(gtype(AstalBluetooth.Adapter)) + get adapter() { return this.#adapter; } + + @setter(gtype(AstalBluetooth.Adapter)) + set adapter(newAdapter: AstalBluetooth.Adapter|null) { + this.#adapter = newAdapter; + this.notify("adapter"); + + if(!newAdapter) return; + + AstalBluetooth.get_default().adapters.filter(ad => { + if(ad.address !== newAdapter.address) + return true; + + ad.set_powered(true); + return false; + }).forEach(ad => ad.set_powered(false)); + + execAsync(`bluetoothctl select ${newAdapter.address}`).then(() => { + userData.setProperty("bluetooth_default_adapter", newAdapter.address, true); + }).catch(e => console.error(`Bluetooth: Couldn't select adapter. Stderr: ${e}`)); + } + + constructor() { + super(); + + createRoot((_) => { + this.#scope = getScope(); + + if(this.astalBl.adapters.length > 0) { + this.#isAvailable = true; + this.notify("is-available"); + } + + // load previous default adapter + const dataDefaultAdapter = userData.getProperty("bluetooth_default_adapter", "string"); + const foundAdapter = this.astalBl.adapters.filter(a => a.address === dataDefaultAdapter)[0]; + + if(dataDefaultAdapter !== undefined && foundAdapter !== undefined) + this.adapter = foundAdapter; + + this.#connections.set( + AstalBluetooth.get_default(), [ + AstalBluetooth.get_default().connect("adapter-added", (self, adapter) => { + if(self.adapters.length === 1) // adapter was just added + this.adapter = adapter; + }), + AstalBluetooth.get_default().connect("adapter-removed", (self, adapter) => { + if(self.adapters.length < 1) { + this.adapter = null; + this.#isAvailable = false; + this.notify("is-available"); + } + + if(this.#adapter?.address !== adapter.address) + return; + + // the removed adapter was the default + + if(self.adapters.length < 1) { + this.adapter = null; + this.#isAvailable = false; + this.notify("is-available"); + + return; + } + + this.#adapter = self.adapters[0]; + }) + ] + ); + + // async to prevent slow start + setTimeout(() => { + this.#lastDevice = this.getLastConnectedDevice(); + this.notify("last-device"); + }, 1200); + + this.#connections.set(AstalBluetooth.get_default(), [ + AstalBluetooth.get_default().connect("device-added", (_) => { + this.#lastDevice = this.getLastConnectedDevice(); + this.notify("last-device"); + }), + AstalBluetooth.get_default().connect("device-removed", (_) => { + this.#lastDevice = this.getLastConnectedDevice(); + this.notify("last-device"); + }) + ]); + + this.#scope.onCleanup(() => this.#connections.forEach((ids, gobj) => + Array.isArray(ids) ? + ids.forEach(id => gobj.disconnect(id)) + : gobj.disconnect(ids) + )); + }); + } + + public static getDefault(): Bluetooth { + if(!this.instance) + this.instance = new Bluetooth(); + + return this.instance; + } + + vfunc_dispose(): void { + this.#scope.dispose(); + } + + private getLastConnectedDevice(): AstalBluetooth.Device|null { + + const connectedDevices = AstalBluetooth.get_default().devices + .filter(d => d.connected); + + const lastDevice = connectedDevices[connectedDevices.length - 1]; + + return lastDevice ?? null; + } + + connect( + signal: Signal, callback: (typeof this["$signals"])[Signal] + ): number { + return super.connect(signal as string, callback as () => void); + } +} diff --git a/ags/scripts/clipboard.ts b/src/modules/clipboard.ts similarity index 81% rename from ags/scripts/clipboard.ts rename to src/modules/clipboard.ts index b4a98040..bd292b21 100644 --- a/ags/scripts/clipboard.ts +++ b/src/modules/clipboard.ts @@ -1,4 +1,11 @@ -import { AstalIO, execAsync, Gio, GLib, GObject, monitorFile, property, readFile, register, signal, timeout } from "astal"; +import { timeout } from "ags/time"; +import { monitorFile, readFile } from "ags/file"; +import { execAsync } from "ags/process"; +import GObject, { getter, register, signal } from "ags/gobject"; + +import AstalIO from "gi://AstalIO"; +import GLib from "gi://GLib?version=2.0"; +import Gio from "gi://Gio?version=2.0"; export enum ClipboardItemType { @@ -6,10 +13,16 @@ export enum ClipboardItemType { IMAGE = 1 } -export type ClipboardItem = { +export class ClipboardItem { id: number; type: ClipboardItemType; preview: string; + + constructor(id: number, type: ClipboardItemType, preview: string) { + this.id = id; + this.type = type; + this.preview = preview; + } } export { Clipboard }; @@ -20,6 +33,11 @@ export { Clipboard }; class Clipboard extends GObject.Object { private static instance: Clipboard; + declare $signals: GObject.Object.SignalSignatures & { + "copied": Clipboard["copied"]; + "wiped": Clipboard["wiped"]; + }; + #dbFile: Gio.File; #dbMonitor: Gio.FileMonitor; #updateDone: boolean = false; @@ -27,14 +45,10 @@ class Clipboard extends GObject.Object { #changesTimeout: (AstalIO.Time|undefined); #ignoreChanges: boolean = false; - @signal(Object) - declare copied: () => ClipboardItem; + @signal(GObject.TYPE_JSOBJECT) copied(_item: object) {} + @signal() wiped() {}; - @signal() - declare wiped: () => void; - - - @property() + @getter(Array) public get history() { return this.#history; } @@ -80,11 +94,25 @@ class Clipboard extends GObject.Object { ); } - public async copyAsync(content: string): Promise { - await execAsync(`wl-copy "${content}"`).catch((err: Gio.IOErrorEnum) => { - console.error(`Clipboard: Couldn't copy text using wl-copy. Stderr:\n\t${err.message - } | Stack:\n\t\t${err.stack}`); - }); + public async copyAsync(content: string): Promise { + const proc = Gio.Subprocess.new( + ["wl-copy", content], + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE + ); + + const stderr = Gio.DataInputStream.new(proc.get_stderr_pipe()!); + + if(!proc.wait_check(null)) { + try { + const [err, ] = stderr.read_upto('\x00', -1); + console.error(`Clipboard: An error occurred while copying text. Stderr: ${err}`); + } catch(_) { + console.error(`Clipboard: An error occurred while copying text and shell couldn't read \ +stderr for more info.`); + } + } + + return proc.get_exit_status() === 0; } public async selectItem(itemToSelect: number|ClipboardItem): Promise { diff --git a/src/modules/compositors/hyprland.ts b/src/modules/compositors/hyprland.ts new file mode 100644 index 00000000..c2a3e088 --- /dev/null +++ b/src/modules/compositors/hyprland.ts @@ -0,0 +1,34 @@ +import { register } from "ags/gobject"; +import { Compositors } from "."; +import { createRoot } from "ags"; +import { createScopedConnection } from "../utils"; + +import AstalHyprland from "gi://AstalHyprland"; + + +@register({ GTypeName: "CompositorHyprland" }) +export class CompositorHyprland extends Compositors.Compositor { + hyprland: AstalHyprland.Hyprland; + + constructor() { + super(); + + try { + this.hyprland = AstalHyprland.get_default(); + } catch(e) { + throw new Error(`Couldn't initialize CompositorHyprland: ${e}`); + } + + createRoot(() => { + createScopedConnection( + this.hyprland, "workspace-added", (hws) => { + // check workspace existance + if(this._workspaces.filter(w => w.id === hws.id)[0]) + return; + + // TODO + } + ); + }); + } +} diff --git a/src/modules/compositors/index.ts b/src/modules/compositors/index.ts new file mode 100644 index 00000000..26be8023 --- /dev/null +++ b/src/modules/compositors/index.ts @@ -0,0 +1,164 @@ +import { CompositorHyprland } from "./hyprland"; +import GObject, { getter, gtype, property, register } from "ags/gobject"; + +import GLib from "gi://GLib?version=2.0"; + + +export default Compositors; + +/** WIP modular implementation of a system that supports implementing +* a variety of Wayland Compositors +* @todo implement more general compositor info + a lot of stuff +* */ +export namespace Compositors { + let compositor: Compositor|null = null; + + @register({ GTypeName: "CompositorMonitor" }) + export class Monitor extends GObject.Object { + #width: number; + #height: number; + + @getter(Number) + get width() { return this.#width; } + + @getter(Number) + get height() { return this.#height; } + + @property(Number) + scaling: number; + + constructor(width: number, height: number, scaling: number = 1) { + super(); + + this.#width = width; + this.#height = height; + this.scaling = scaling; + } + } + + @register({ GTypeName: "CompositorWorkspace" }) + export class Workspace extends GObject.Object { + #id: number; + #monitor: Monitor; + + @getter(Number) + get id() { return this.#id; } + + @getter(Monitor) + get monitor() { return this.#monitor; } + + constructor(monitor: Monitor, id: number = 0) { + super(); + + this.#monitor = monitor; + this.#id = id; + } + } + + @register({ GTypeName: "CompositorClient" }) + export class Client extends GObject.Object { + readonly #address: string|null = null; + #initialClass: string; + #class: string; + #title: string = ""; + #mapped: boolean = true; + #position: [number, number] = [0, 0]; + #xwayland: boolean = false; + + @getter(gtype(String)) + get address() { return this.#address; } + + @getter(String) + get title() { return this.#title; } + + @getter(String) + get class() { return this.#class; } + + @getter(String) + get initialClass() { return this.#initialClass; } + + @getter(gtype<[number, number]>(Array)) + get position() { return this.#position; } + + @getter(Boolean) + get xwayland() { return this.#xwayland; } + + @getter(Boolean) + get mapped() { return this.#mapped; } + + constructor(props: { + address?: string; + title?: string; + mapped?: boolean; + class: string; + initialClass?: string; + /** [x, y] */ + position?: [number, number]; + }) { + super(); + + this.#class = props.class; + + if(props.title !== undefined) + this.#title = props.title; + + if(props.mapped !== undefined) + this.#mapped = props.mapped; + + if(props.address !== undefined) + this.#address = props.address; + + if(props.position !== undefined) + this.#position = props.position; + + this.#initialClass = props.initialClass !== undefined ? + props.initialClass + : props.class; + } + } + + @register({ GTypeName: "Compositor" }) + export class Compositor extends GObject.Object { + protected _workspaces: Array = []; + protected _focusedClient: Client|null = null; + + @getter(Array) + get workspaces() { return this._workspaces; } + + @getter(gtype(Client)) + get focusedClient() { return this._focusedClient; } + + constructor() { + super(); + } + }; + + + export function getDefault(): Compositor { + if(!compositor) + throw new Error("Compositors haven't been initialized correctly, please call `Compositors.init()` before calling any method in `Compositors`"); + + return compositor; + } + + + /** Uses the XDG_CURRENT_DESKTOP variable to detect running compositor's name. + * --- + * @returns running wayland compositor's name (lowercase) or `undefined` if variable's not set */ + export function getName(): string|undefined { + return GLib.getenv("XDG_CURRENT_DESKTOP")?.toLowerCase() ?? undefined; + } + + /** initialize colorshell's wayland compositor implementation abstraction. + * when called, and if it's implemented, sets the default compositor to an equivalent implementation for the current desktop(checks from XDG_CURRENT_DESKTOP) */ + export function init(): void { + switch(Compositors.getName()) { + case "hyprland": + compositor = new CompositorHyprland(); + break; + + default: + console.error(`This compositor(${Compositors.getName()}) is not yet implemented to colorshell. Please contribute by implementing it if you can! :)`); + } + } +} diff --git a/src/modules/config.ts b/src/modules/config.ts new file mode 100644 index 00000000..e79fa7b8 --- /dev/null +++ b/src/modules/config.ts @@ -0,0 +1,219 @@ +import { timeout } from "ags/time"; +import { monitorFile, readFileAsync, writeFileAsync } from "ags/file"; +import { Notifications } from "./notifications"; +import { Accessor } from "ags"; +import GObject, { getter, gtype, register } from "ags/gobject"; + +import Gio from "gi://Gio?version=2.0"; +import AstalIO from "gi://AstalIO"; +import AstalNotifd from "gi://AstalNotifd"; + + +export { Config }; +type ValueTypes = "string" | "boolean" | "object" | "number" | "any"; + +@register({ GTypeName: "Config" }) +class Config extends GObject.Object { + declare $signals: GObject.Object.SignalSignatures & { + "notify::entries": (entries: Record) => void; + }; + + /** unmodified object with default entries. User-values are stored + * in the `entries` field */ + public readonly defaults: Record; + + @getter(gtype>(Object)) + public get entries() { return this.#entries; } + + #file: Gio.File; + #entries: Record; + + private timeout: (AstalIO.Time|boolean|undefined); + public get file() { return this.#file; }; + + constructor(filePath: Gio.File|string, defaults?: Record) { + super(); + + this.defaults = (defaults ?? {}) as Record; + this.#entries = { ...defaults } as Record; + + this.#file = (typeof filePath === "string") ? + Gio.File.new_for_path(filePath) + : filePath; + + if(!this.#file.query_exists(null)) { + this.#file.make_directory_with_parents(null); + this.#file.delete(null); + + this.writeFile().catch(e => Notifications.getDefault().sendNotification({ + appName: "colorshell", + summary: "Write error", + body: `Couldn't write default configuration file to "${this.#file.get_path()! + }".\nStderr: ${e}` + })); + } + + monitorFile(this.#file.get_path()!, + () => { + if(this.timeout) return; + this.timeout = timeout(500, () => this.timeout = undefined); + + if(this.#file.query_exists(null)) { + this.timeout?.cancel(); + this.timeout = true; + + this.readFile().finally(() => + this.timeout = undefined); + + return; + } + + Notifications.getDefault().sendNotification({ + appName: "colorshell", + summary: "Config error", + body: `Could not hot-reload configuration: config file not found in \`${this.#file.get_path()!}\`, last valid configuration is being used. Maybe it got deleted?` + }); + } + ); + + this.readFile().catch(e => console.error( + `Config: An error occurred while read the configuration file. Stderr: ${e}` + )); + } + + private async writeFile(): Promise { + this.timeout = true; + await writeFileAsync( + this.#file.get_path()!, JSON.stringify(this.entries, undefined, 4) + ).finally(() => this.timeout = false); + } + + private async readFile(): Promise { + await readFileAsync(this.#file.get_path()!).then((content) => { + let config: (Record|undefined); + + try { + config = JSON.parse(content) as Record; + } catch(e) { + Notifications.getDefault().sendNotification({ + urgency: AstalNotifd.Urgency.NORMAL, + appName: "colorshell", + summary: "Config parsing error", + body: `An error occurred while parsing colorshell's config file: \nFile: ${ + this.#file.get_path()!}\n${ + (e as SyntaxError).message}` + }); + } + + if(!config) return; + + + // only change valid entries that are available in the defaults (with 1 of depth) + for(const k of Object.keys(this.entries)) { + if(config[k as keyof typeof config] === undefined) + return; + + // TODO needs more work, like object-recursive(infinite depth) entry attributions + this.#entries[k as keyof Record] = config[k as keyof typeof config]; + } + + this.notify("entries"); + }).catch((e: Gio.IOErrorEnum) => { + Notifications.getDefault().sendNotification({ + urgency: AstalNotifd.Urgency.NORMAL, + appName: "colorshell", + summary: "Config read error", + body: `An error occurred while reading colorshell's config file: ${this.#file.get_path()! + }\n${e.message}`.replace(/[<>]/g, "\\&") + }); + }); + } + + public bindProperty(path: string, expectType: "boolean"): Accessor; + public bindProperty(path: string, expectType: "number"): Accessor; + public bindProperty(path: string, expectType: "string"): Accessor; + public bindProperty(path: string, expectType: "object"): Accessor; + public bindProperty(path: string, expectType: "any"): Accessor; + public bindProperty(path: string, expectType: undefined): Accessor; + + public bindProperty(propertyPath: string, expectType?: ValueTypes): Accessor { + return new Accessor(() => this.getProperty(propertyPath, expectType as never), (callback: () => void) => { + const id = this.connect("notify::entries", () => callback()); + return () => this.disconnect(id); + }); + } + + public getProperty(path: string, expectType: "boolean"): boolean; + public getProperty(path: string, expectType: "number"): number; + public getProperty(path: string, expectType: "string"): string; + public getProperty(path: string, expectType: "object"): object; + public getProperty(path: string, expectType: "any"): any; + public getProperty(path: string, expectType: undefined): any; + + public getProperty(path: string, expectType?: ValueTypes): boolean|number|string|object|any { + return this._getProperty(path, this.#entries, expectType); + } + + public getPropertyDefault(path: string, expectType: "boolean"): boolean; + public getPropertyDefault(path: string, expectType: "number"): number; + public getPropertyDefault(path: string, expectType: "string"): string; + public getPropertyDefault(path: string, expectType: "object"): object; + public getPropertyDefault(path: string, expectType: "any"): any; + public getPropertyDefault(path: string, expectType: undefined): any; + + public getPropertyDefault(path: string, expectType?: ValueTypes): boolean|number|string|object|any { + return this._getProperty(path, this.defaults, expectType); + } + + public setProperty(path: string, value: any, write?: boolean): void { + let property: any = this.#entries, + obj: typeof this.entries = property; + const pathArray = path.split('.').filter(str => str); + + for(let i = 0; i < pathArray.length; i++) { + const currentPath = pathArray[i]; + + property = property[currentPath as keyof typeof property]; + if(typeof property === "object") { + obj = property; + } else { + obj[pathArray[pathArray.length - 1] as keyof typeof obj] = value; + break; + } + } + + this.notify("entries"); + write && this.writeFile().catch(e => console.error( + `Config: Couldn't save file. Stderr: ${e}` + )); + } + + private _getProperty(path: string, entries: Record, expectType?: ValueTypes): (any|undefined) { + let property: any = entries; + const pathArray = path.split('.').filter(str => str); + + for(let i = 0; i < pathArray.length; i++) { + const currentPath = pathArray[i]; + + property = property[currentPath as keyof typeof property]; + } + + if(expectType !== "any" && typeof property !== expectType) { + // return default value if not defined by user + property = this.defaults; + + for(let i = 0; i < pathArray.length; i++) { + const currentPath = pathArray[i]; + + property = property[currentPath as keyof typeof property]; + } + } + + if(expectType !== "any" && typeof property !== expectType) { + console.error(`Config: property with path \`${path}\` not found in defaults/user-entries, returning \`undefined\``); + property = undefined; + } + + return property; + } +} diff --git a/src/modules/media.ts b/src/modules/media.ts new file mode 100644 index 00000000..dd4c8add --- /dev/null +++ b/src/modules/media.ts @@ -0,0 +1,86 @@ +import { Accessor, createConnection, getScope, Scope } from "ags"; +import { createScopedConnection, decoder } from "./utils"; + +import AstalMpris from "gi://AstalMpris"; +import GObject from "gi://GObject?version=2.0"; +import { property, register } from "ags/gobject"; + + +@register({ GTypeName: "Media" }) +export default class Media extends GObject.Object { + private static instance: Media; + public static readonly dummyPlayer = { + available: false, + busName: "dummy_player", + bus_name: "dummy_player" + } as AstalMpris.Player; + + @property(AstalMpris.Player) + player: AstalMpris.Player = Media.dummyPlayer; + + constructor(scope: Scope) { + super(); + + scope.run(() => { + const firstPlayer = AstalMpris.get_default().players[0]; + if(firstPlayer) + this.player = firstPlayer; + + createScopedConnection( + AstalMpris.get_default(), + "player-added", + (player) => { + if(player.available) + this.player = player; + } + ); + + createScopedConnection( + AstalMpris.get_default(), + "player-closed", (closedPlayer) => { + const players = AstalMpris.get_default().players.filter(pl => pl?.available && + pl.busName !== closedPlayer.busName); + + // go back to first player(if available) when the active player is closed + if(players.length > 0 && players[0]) { + this.player = players[0]; + return; + } + + this.player = Media.dummyPlayer; + } + ); + }); + } + + public static getDefault(): Media { + if(!this.instance) + this.instance = new Media(getScope()); + + return this.instance; + } + + public static accessMediaUrl(player: AstalMpris.Player): Accessor { + return createConnection(player.get_meta("xesam:url"), + [player, "notify::metadata", () => player.get_meta("xesam:url")] + ).as(url => { + const byteString = url?.get_data_as_bytes(); + + return byteString ? + decoder.decode(byteString.toArray()) + : undefined; + }) + } + + + public static getMediaUrl(player: AstalMpris.Player): string|undefined { + if(!player.available) return; + + const meta = player.get_meta("xesam:url"); + const byteString = meta?.get_data_as_bytes(); + + return byteString ? + decoder.decode(byteString.toArray()) + : undefined; + } +} diff --git a/src/modules/nightlight.ts b/src/modules/nightlight.ts new file mode 100644 index 00000000..0fe41ce9 --- /dev/null +++ b/src/modules/nightlight.ts @@ -0,0 +1,181 @@ +import { execAsync, exec } from "ags/process"; +import { userData } from "../config"; +import GObject, { getter, register, setter } from "ags/gobject"; + +import GLib from "gi://GLib?version=2.0"; + + +@register({ GTypeName: "NightLight" }) +export class NightLight extends GObject.Object { + private static instance: NightLight; + + public readonly maxTemperature = 20000; + public readonly minTemperature = 1000; + public readonly identityTemperature = 6000; + public readonly maxGamma = 100; + + #watchInterval: GLib.Source; + #temperature: number = this.identityTemperature; + #gamma: number = this.maxGamma; + #identity: boolean = false; + + @getter(Number) + public get temperature() { return this.#temperature; } + public set temperature(newValue: number) { this.setTemperature(newValue); } + + @getter(Number) + public get gamma() { return this.#gamma; } + public set gamma(newValue: number) { this.setGamma(newValue); } + + @getter(Boolean) + public get identity() { return this.#identity; } + + @setter(Boolean) + public set identity(val: boolean) { + val ? this.applyIdentity() : this.filter(); + this.#identity = val; + this.notify("identity"); + } + + constructor() { + super(); + + this.loadData(); + this.#watchInterval = setInterval(() => { + execAsync("hyprctl hyprsunset temperature").then(t => { + if(t.trim() !== "" && t.trim().length <= 5) { + const val = Number.parseInt(t.trim()); + + if(this.#temperature !== val) { + this.identity = this.#temperature === this.identityTemperature; + this.#temperature = val; + this.notify("temperature"); + } + } + }).catch((r: Error) => console.error(`Night Light: Couldn't sync temperature. Stderr: ${ + r.message}\n${r.stack}`)); + + execAsync("hyprctl hyprsunset gamma").then(g => { + if(g.trim() !== "" && g.trim().length <= 5) { + const val = Number.parseInt(g.trim()); + + if(this.#gamma !== val) { + this.identity = this.#gamma === this.maxGamma; + this.#gamma = val; + this.notify("gamma"); + } + } + }).catch((r: Error) => console.error(`Night Light: Couldn't sync. Stderr: ${ + r.message}\n${r.stack}`)); + }, 10000); + } + + vfunc_dispose(): void { + this.#watchInterval?.destroy(); + } + + public static getDefault(): NightLight { + if(!this.instance) + this.instance = new NightLight(); + + return this.instance; + } + + private setTemperature(value: number): void { + if(value === this.temperature && !this.identity) return; + + if(value > this.maxTemperature || value < 1000) { + console.error(`Night Light: provided temperatue ${value + } is out of bounds (min: 1000; max: ${this.maxTemperature})`); + return; + } + + this.dispatchAsync("temperature", value).then(() => { + this.#temperature = value; + this.notify("temperature"); + + this.identity = false; + }).catch((r: Error) => console.error( + `Night Light: Couldn't set temperature. Stderr: ${r.message}\n${r.stack}` + )); + } + + private setGamma(value: number): void { + if(value === this.gamma && !this.identity) return; + + if(value > this.maxGamma || value < 0) { + console.error(`Night Light: provided gamma ${value + } is out of bounds (min: 0; max: ${this.maxTemperature})`); + return; + } + + this.dispatchAsync("gamma", value).then(() => { + this.#gamma = value; + this.notify("gamma"); + + this.identity = false; + }).catch((r: Error) => console.error( + `Night Light: Couldn't set gamma. Stderr: ${r.message}\n${r.stack}` + )); + } + + public applyIdentity(): void { + this.dispatch("identity"); + + if(!this.#identity) { + this.#identity = true; + this.notify("identity"); + } + } + + private dispatch(call: "temperature", val: number): string; + private dispatch(call: "gamma", val: number): string; + private dispatch(call: "identity"): string; + + private dispatch(call: "temperature"|"gamma"|"identity", val?: number): string { + return exec(`hyprctl hyprsunset ${call}${val != null ? ` ${val}` : ""}`); + } + + private async dispatchAsync(call: "temperature", val: number): Promise; + private async dispatchAsync(call: "gamma", val: number): Promise; + private async dispatchAsync(call: "identity"): Promise; + + private async dispatchAsync(call: "temperature"|"gamma"|"identity", val?: number): Promise { + return await execAsync(`hyprctl hyprsunset ${call}${val != null ? ` ${val}` : ""}`); + } + + public filter(): void { + this.setTemperature(this.temperature); + this.setGamma(this.gamma); + + if(this.#identity) { + this.#identity = false; + this.notify("identity"); + } + } + + public saveData(): void { + userData.setProperty("night_light.temperature", this.#temperature); + userData.setProperty("night_light.gamma", this.#gamma); + userData.setProperty("night_light.identity", this.#identity, true); + } + + /** load temperature, gamma and identity(off/on) properties from the user configuration */ + public loadData(): void { + const identity = userData.getProperty("night_light.identity", "boolean"); + const temperature = userData.getProperty("night_light.temperature", "number"); + const gamma = userData.getProperty("night_light.gamma", "number"); + + if(identity) { + this.#temperature = temperature; + this.notify("temperature"); + this.#gamma = gamma; + this.notify("gamma"); + } else { + this.temperature = temperature; + this.gamma = gamma; + } + + this.identity = identity; + } +} diff --git a/src/modules/notifications.ts b/src/modules/notifications.ts new file mode 100644 index 00000000..e414e3eb --- /dev/null +++ b/src/modules/notifications.ts @@ -0,0 +1,361 @@ +import { execAsync } from "ags/process"; +import { generalConfig } from "../config"; +import { onCleanup } from "ags"; +import GObject, { getter, ParamSpec, property, register, signal } from "ags/gobject"; + +import AstalNotifd from "gi://AstalNotifd"; +import GLib from "gi://GLib?version=2.0"; + + +export type HistoryNotification = { + id: number; + appName: string; + body: string; + summary: string; + urgency: AstalNotifd.Urgency; + appIcon?: string; + time: number; + image?: string; +} + +export class NotificationTimeout { + #source?: GLib.Source; + #args?: Array; + #millis: number; + #lastRemained!: number; + + readonly callback: () => void; + get millis(): number { return this.#millis; } + get remaining(): number { return this.source!.get_time() } + get lastRemained(): number { return this.#lastRemained; } + get running(): boolean { return Boolean(this.source?.is_destroyed()); } + get source(): GLib.Source|undefined { return this.#source; } + + constructor(millis: number, callback: () => void, start: boolean = true, ...args: Array) { + this.#millis = millis; + this.callback = callback; + this.#args = args; + + if(!start) return; + this.start(); + } + + cancel(): void { + // use lastRemained to calculate on what time the user hold the notification, so it + // can be released by the remaining time (works like a timeout "pause") + this.#lastRemained = Math.floor(Math.max(this.#source!.get_ready_time() - GLib.get_monotonic_time()) / 1000); + this.#source?.destroy(); + this.#source?.unref(); + this.#source = undefined; + } + + start(newMillis?: number): GLib.Source { + if(this.running) + throw new Error("Notifications: Can't start a new counter if it's already running!"); + + if(newMillis !== undefined) + this.#millis = newMillis; + + this.#source = setTimeout( + this.callback, + this.#millis, + this.#args + ); + + this.#lastRemained = Math.floor(Math.max(this.#source!.get_ready_time() - GLib.get_monotonic_time()) / 1000); + + return this.#source; + } +}; + +@register({ GTypeName: "Notifications" }) +export class Notifications extends GObject.Object { + private static instance: (Notifications|null) = null; + + declare $signals: GObject.Object.SignalSignatures & { + "history-added": (notification: HistoryNotification) => void; + "history-removed": (notificationId: number) => void; + "history-cleared": () => void; + "notification-added": (notification: AstalNotifd.Notification) => void; + "notification-removed": (notificationId: number) => void; + "notification-replaced": (notificationId: number) => void; + }; + + #notifications = new Map(); + #history: Array = []; + #connections: Array = []; + + @getter(Array) + public get notifications() { + return [...this.#notifications.values()].map(([n]) => n); + }; + + @getter(Array) + public get history() { return this.#history }; + + @getter(Array) + public get notificationsOnHold() { + return [...this.#notifications.values()].filter(([_, s]) => + typeof s === "undefined" + ).map(([n]) => n); + } + + @property(Number) + public historyLimit: number = 10; + + /** skip notifications directly to notification history */ + @property(Boolean) + public ignoreNotifications: boolean = false; + + + @signal(AstalNotifd.Notification) notificationAdded(_notification: AstalNotifd.Notification) {}; + @signal(Number) notificationRemoved(_id: number) {}; + @signal(Object as unknown as ParamSpec) historyAdded(_notification: Object) {}; + @signal() historyCleared() {}; + @signal(Number) historyRemoved(_id: number) {}; + @signal(Number) notificationReplaced(_id: number) {}; + + constructor() { + super(); + + this.#connections.push( + AstalNotifd.get_default().connect("notified", (notifd, id) => { + const notification = notifd.get_notification(id); + + if(this.getNotifd().dontDisturb || this.ignoreNotifications) { + this.addHistory(notification, () => notification.dismiss()); + return; + } + + this.addNotification(notification, this.getNotificationTimeout(notification) > 0); + }), + + AstalNotifd.get_default().connect("resolved", (notifd, id, _reason) => { + this.removeNotification(id); + this.addHistory(notifd.get_notification(id)); + }) + ); + + onCleanup(() => { + this.#connections.map(id => + AstalNotifd.get_default().disconnect(id)); + }); + } + + public static getDefault(): Notifications { + if(!this.instance) + this.instance = new Notifications(); + + return this.instance; + } + + public async sendNotification(props: { + urgency?: AstalNotifd.Urgency; + appName?: string; + image?: string; + summary: string; + body?: string; + replaceId?: number; + actions?: Array<{ + id?: (string|number); + text: string; + onAction?: () => void + }> + }): Promise<{ + id?: (string|number); + text: string; + onAction?: () => void + }|null|void> { + + return await execAsync([ + "notify-send", + ...(props.urgency ? [ + "-u", this.getUrgencyString(props.urgency) + ] : []), ...(props.appName ? [ + "-a", props.appName + ] : []), ...(props.image ? [ + "-i", props.image + ] : []), ...(props.actions ? props.actions.map((action) => + [ "-A", action.text ] + ).flat(2) : []), ...(props.replaceId ? [ + "-r", props.replaceId.toString() + ] : []), props.summary, props.body ? props.body : "" + ]).then((stdout) => { + stdout = stdout.trim(); + if(!stdout) { + if(props.actions && props.actions.length > 0) + return null; + + return; + } + + if(props.actions && props.actions.length > 0) { + const action = props.actions[Number.parseInt(stdout)]; + action?.onAction?.(); + + return action ?? undefined; + } + }).catch((err: Error) => { + console.error(`Notifications: Couldn't send notification! Is the daemon running? Stderr:\n${ + err.message ? `${err.message}\n` : ""}Stack: ${err.stack}`); + }); + } + + public getUrgencyString(urgency: AstalNotifd.Notification|AstalNotifd.Urgency) { + switch((urgency instanceof AstalNotifd.Notification) ? + urgency.urgency : urgency) { + + case AstalNotifd.Urgency.LOW: + return "low"; + case AstalNotifd.Urgency.CRITICAL: + return "critical"; + } + + return "normal"; + } + + private addHistory(notif: AstalNotifd.Notification, onAdded?: (notif: AstalNotifd.Notification) => void): void { + if(!notif) return; + + this.#history.length === this.historyLimit && + this.removeHistory(this.#history[this.#history.length - 1]); + + this.#history.map((notifb, i) => + notifb.id === notif.id && this.#history.splice(i, 1)); + + this.#history.unshift({ + id: notif.id, + appName: notif.app_name, + body: notif.body, + summary: notif.summary, + urgency: notif.urgency, + appIcon: notif.app_icon, + time: notif.time, + image: notif.image ? notif.image : undefined + } as HistoryNotification); + + this.notify("history"); + this.emit("history-added", this.#history[0]); + onAdded?.(notif); + } + + public async clearHistory(): Promise { + this.#history.reverse().map((notif) => { + this.#history = this.history.filter((n) => n.id !== notif.id); + this.emit("history-removed", notif.id); + }); + + this.emit("history-cleared"); + this.notify("history"); + } + + public removeHistory(notif: (HistoryNotification|number)): void { + const notifId = (typeof notif === "number") ? notif : notif.id; + this.#history = this.#history.filter((item: HistoryNotification) => + item.id !== notifId); + + this.notify("history"); + this.emit("history-removed", notifId); + } + + private addNotification( + notif: AstalNotifd.Notification, + removeOnTimeout: boolean = true, + onTimeoutEnd?: () => void + ): void { + + const replaced = this.#notifications.has(notif.id); + const notifTimeout = this.getNotificationTimeout(notif); + const onEnd = () => { + removeOnTimeout && this.removeNotification(notif); + onTimeoutEnd?.(); + } + + // destroy timer of replaced notification(if there's any) + if(replaced) { + const data = this.#notifications.get(notif.id)!; + (data?.[1] instanceof NotificationTimeout) && + data[1].cancel(); + } + + this.#notifications.set(notif.id, [ + notif, + new NotificationTimeout(notifTimeout, onEnd, notifTimeout > 0) + ]); + + replaced && this.emit("notification-replaced", notif.id); + + this.notify("notifications"); + this.emit("notification-added", notif); + + if(notifTimeout <= 0) onEnd?.(); + } + + public getNotificationTimeout(notif: AstalNotifd.Notification): number { + return generalConfig.getProperty( + `notifications.timeout_${this.getUrgencyString(notif.urgency)}`, + "number" + ); + } + + public removeNotification(notif: (AstalNotifd.Notification|number), addToHistory: boolean = true): void { + notif = typeof notif === "number" ? + this.#notifications.get(notif)?.[0]! + : notif; + + if(!notif) return; + + const timeout = this.#notifications.get(notif.id)![1]; + timeout.running && timeout.cancel(); + + this.#notifications.delete(notif.id); + addToHistory && this.addHistory(notif); + + notif.dismiss(); + this.notify("notifications"); + this.emit("notification-removed", notif.id); + } + + public holdNotification(notif: AstalNotifd.Notification|number): void { + const id = typeof notif === "number" ? notif : notif.id; + const data = this.#notifications.get(id); + + if(!data) return; + + data[1].cancel(); + this.notify("notifications-on-hold"); + } + + public releaseNotification(notif: AstalNotifd.Notification|number): void { + const id = typeof notif === "number" ? notif : notif.id; + const data = this.#notifications.get(id); + + if(!data) return; + data[1].start(data[1].lastRemained); + + this.notify("notifications-on-hold"); + } + + public toggleDoNotDisturb(value?: boolean): boolean { + value = value ?? !AstalNotifd.get_default().dontDisturb; + AstalNotifd.get_default().dontDisturb = value; + + return value; + } + + public getNotifd(): AstalNotifd.Notifd { return AstalNotifd.get_default(); } + + public emit( + signal: Signal, ...args: Parameters<(typeof this.$signals)[Signal]> + ): void { + super.emit(signal, ...args); + } + + public connect( + signal: Signal, + callback: (self: typeof this, ...params: Parameters<(typeof this.$signals)[Signal]>) => + ReturnType<(typeof this.$signals)[Signal]> + ): number { + return super.connect(signal, callback); + } +} diff --git a/ags/scripts/recording.ts b/src/modules/recording.ts similarity index 50% rename from ags/scripts/recording.ts rename to src/modules/recording.ts index 46edc113..ddb82ebb 100644 --- a/ags/scripts/recording.ts +++ b/src/modules/recording.ts @@ -1,37 +1,41 @@ -import { execAsync, Gio, GLib, GObject } from "astal"; -import { property, register, signal } from "astal/gobject"; -import { Gdk } from "astal/gtk3"; -import { getDateTime } from "./time"; +import { execAsync } from "ags/process"; +import { getter, register, signal } from "ags/gobject"; +import { Gdk } from "ags/gtk4"; +import { createRoot, getScope, Scope } from "ags"; import { makeDirectory } from "./utils"; import { Notifications } from "./notifications"; +import { time } from "./utils"; + +import GObject from "ags/gobject"; +import GLib from "gi://GLib?version=2.0"; +import Gio from "gi://Gio?version=2.0"; -export { Recording }; @register({ GTypeName: "Recording" }) -class Recording extends GObject.Object { +export class Recording extends GObject.Object { private static instance: Recording; - @signal() - declare started: () => void; - @signal() - declare stopped: () => void; + @signal() started() {}; + @signal() stopped() {}; #recording: boolean = false; #path: string = "~/Recordings"; + #recordingScope?: Scope; /** Default extension: mp4(h264) */ #extension: string = "mp4"; #recordAudio: boolean = false; #area: (Gdk.Rectangle|null) = null; - #startedAt: (GLib.DateTime|null) = null; + #startedAt: number = -1; #process: (Gio.Subprocess|null) = null; #output: (string|null) = null; - @property() - /** GLib.DateTime of when recording started */ + /** GLib.DateTime of when recording started + * its value can be `-1` if undefined(no recording is happening) */ + @getter(Number) public get startedAt() { return this.#startedAt; } - @property(Boolean) + @getter(Boolean) public get recording() { return this.#recording; } private set recording(newValue: boolean) { (!newValue && this.#recording) ? @@ -42,7 +46,7 @@ class Recording extends GObject.Object { this.notify("recording"); } - @property(String) + @getter(String) public get path() { return this.#path; } public set path(newPath: string) { if(this.recording) return; @@ -51,7 +55,7 @@ class Recording extends GObject.Object { this.notify("path"); } - @property(String) + @getter(String) public get extension() { return this.#extension; } public set extension(newExt: string) { if(this.recording) return; @@ -60,7 +64,22 @@ class Recording extends GObject.Object { this.notify("extension"); } - /** Recording output file name. %NULL if screen is not being recorded */ + @getter(String) + public get recordingTime() { + if(!this.#recording || !this.#startedAt) + return "not recording"; + + const startedAtSeconds = time.get().to_unix() - Recording.getDefault().startedAt!; + if(startedAtSeconds <= 0) return "00:00"; + + const seconds = Math.floor(startedAtSeconds % 60); + const minutes = Math.floor(startedAtSeconds / 60); + const hours = Math.floor(minutes / 60); + + return `${hours > 0 ? `${hours < 10 ? '0' : ""}${hours}` : ""}${ minutes < 10 ? `0${minutes}` : minutes }:${ seconds < 10 ? `0${seconds}` : seconds }`; + } + + /** Recording output file name. null if screen is not being recorded */ public get output() { return this.#output; } /** Currently unsupported property */ @@ -86,47 +105,57 @@ class Recording extends GObject.Object { } public startRecording(area?: Gdk.Rectangle) { - if(this.recording) + if(this.#recording) throw new Error("Screen Recording is already running!"); - this.#output = `${getDateTime().get().format("%Y-%m-%d-%H%M%S")}_rec.${this.extension || "mp4"}`; - this.#recording = true; - this.notify("recording"); - this.emit("started"); - makeDirectory(this.path); + createRoot(() => { + this.#recordingScope = getScope(); - const cancellable = Gio.Cancellable.new(); - cancellable.cancel = () => {}; + this.#output = `${time.get().format("%Y-%m-%d-%H%M%S")}_rec.${this.extension || "mp4"}`; + this.#recording = true; + this.notify("recording"); + this.emit("started"); + makeDirectory(this.path); - const areaString = `${area?.x ?? 0},${area?.y ?? 0} ${area?.width ?? 1}x${area?.height ?? 1}`; + const areaString = `${area?.x ?? 0},${area?.y ?? 0} ${area?.width ?? 1}x${area?.height ?? 1}`; - this.#process = Gio.Subprocess.new([ - "wf-recorder", - ...(area ? [ `-g`, areaString ] : []), - "-f", - `${this.path}/${this.output!}` - ], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE); + this.#process = Gio.Subprocess.new([ + "wf-recorder", + ...(area ? [ `-g`, areaString ] : []), + "-f", + `${this.path}/${this.output!}` + ], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE); - this.#process.wait_async(cancellable, () => { - this.stopRecording(); - }); + this.#process.wait_async(null, () => { + this.stopRecording(); + }); - this.#startedAt = getDateTime().get(); + this.#startedAt = time.get().to_unix(); + this.notify("started-at"); + + const timeSub = time.subscribe(() => { + this.notify("recording-time"); + }); + + this.#recordingScope.onCleanup(timeSub); + }); } public stopRecording() { - if(!this.#process) return; + if(!this.#process || !this.#recording) return; !this.#process.get_if_exited() && execAsync([ "kill", "-s", "SIGTERM", this.#process.get_identifier()! ]); + this.#recordingScope?.dispose(); + const path = this.#path; const output = this.#output; this.#process = null; this.#recording = false; - this.#startedAt = null; + this.#startedAt = -1; this.#output = null; this.notify("recording"); this.emit("stopped"); @@ -134,13 +163,8 @@ class Recording extends GObject.Object { Notifications.getDefault().sendNotification({ actions: [ { - text: "View", - onAction: () => { - execAsync(["nautilus", "-s", output!, path]); - } - }, - { - text: "Open", + text: "View", // will be hidden(can be triggered by clicking in the notification) + id: "view", onAction: () => { execAsync(["xdg-open", `${path}/${output}`]); } diff --git a/src/modules/reload-handler.ts b/src/modules/reload-handler.ts new file mode 100644 index 00000000..18161f25 --- /dev/null +++ b/src/modules/reload-handler.ts @@ -0,0 +1,15 @@ +import { uwsmIsActive } from "./apps"; + +import Gio from "gi://Gio?version=2.0"; +import { Shell } from "../app"; + + +export function restartInstance(): void { + Gio.Subprocess.new( + ( uwsmIsActive ? + [ "uwsm", "app", "--", "colorshell" ] + : [ "colorshell" ]), + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE + ); + Shell.getDefault().quit(); +} diff --git a/src/modules/stylesheet.ts b/src/modules/stylesheet.ts new file mode 100644 index 00000000..8203c2d0 --- /dev/null +++ b/src/modules/stylesheet.ts @@ -0,0 +1,147 @@ +import { monitorFile, readFile, writeFileAsync } from "ags/file"; +import { decoder } from "./utils"; +import { execAsync } from "ags/process"; +import { Wallpaper } from "./wallpaper"; +import { Shell } from "../app"; + +import Gio from "gi://Gio?version=2.0"; +import GLib from "gi://GLib?version=2.0"; + + +/** handles stylesheet compiling and reloading */ +export class Stylesheet { + private static instance: Stylesheet; + #outputPath = Gio.File.new_for_path(`${GLib.get_user_cache_dir()}/colorshell/style`); + #stylesPaths: Array; + readonly #sassStyles = { + modules: ["sass:color"].map(mod => `@use "${mod}";`).join('\n'), + colors: "", + mixins: "", + rules: "" + }; + public get stylePath() { return this.#outputPath.get_path()!; } + + + public static getDefault(): Stylesheet { + if(!this.instance) + this.instance = new Stylesheet(); + + return this.instance; + } + + private bundle(): string { + return `${this.#sassStyles.modules}\n\n${this.#sassStyles.colors + }\n${this.#sassStyles.mixins}\n${this.#sassStyles.rules}`.trim(); + } + + private async compile(): Promise { + const sass = this.bundle(); + await writeFileAsync(`${this.stylePath}/sass.scss`, sass).catch(_e => { + const e = _e as Error; + console.error(`Stylesheet: Couldn't write Sass to cache. Stderr: ${ + e.message}\n${e.stack}`); + }); + await execAsync( + `bash -c "sass ${this.stylePath}/sass.scss ${this.stylePath}/style.css"` + ).catch(_e => { + const e = _e as Error; + console.error(`Stylesheet: An error occurred on compile-time! Stderr: ${ + e.message}\n${e.stack}`); + }); + } + + public getStyleSheet(): string { + return readFile(`${this.stylePath}/style.css`); + } + + public getColorDefinitions(): string { + const data = Wallpaper.getDefault().getData(); + const colors = { + ...data.special, + ...data.colors + }; + + return Object.keys(colors).map(name => + `$${name}: ${colors[name as keyof typeof colors]};` + ).join('\n'); + } + + private organizeModuleImports(sass: string) { + return sass.replaceAll( + /[@](use|forward|import) ["'](.*)["']?[;]?\n/gi, + (_, impType, imp) => { + imp = (imp as string).replace(/["';]/g, ""); + + // add sass modules on top + if(!this.#sassStyles.modules.includes(imp) && /^(sass|.*http|.*https)/.test(imp)) + this.#sassStyles.modules = this.#sassStyles.modules.concat(`\n@${impType} "${imp}";`); + + return ""; + } + ).replace(/(colors|mixins|wal)\./g, ""); + } + + public compileApply(): void { + this.compile().then(() => { + Shell.getDefault().resetStyle(); + Shell.getDefault().applyStyle(this.getStyleSheet()); + }).catch(_e => { + const e = _e as Error; + console.error(`Stylesheet: An error occurred at compile-time. Stderr: ${ + e.message}\n${e.stack}`); + }); + } + + private getStyleData(path: string): string { + return decoder.decode(Gio.resources_lookup_data(path, null).get_data()!); + } + + constructor() { + if(!this.#outputPath.query_exists(null)) + this.#outputPath.make_directory_with_parents(null); + + this.#stylesPaths = Gio.resources_enumerate_children( + "/io/github/retrozinndev/colorshell/styles", null + ).map(name => + `/io/github/retrozinndev/colorshell/styles/${name}` + ); + + // Rules won't change at runtime in a common build, + // so no need to worry about this. + // But in a development build, there should be support + // hot-reloading the gresource, this is a TODO + this.#stylesPaths.forEach(path => { + const name = path.split('/')[path.split('/').length - 1]; + + switch(name) { + case "colors": + this.#sassStyles.colors = `${this.getColorDefinitions()}\n${ + this.organizeModuleImports(this.getStyleData(path)) + }`; + break; + case "mixins": + this.#sassStyles.mixins = `${this.organizeModuleImports( + this.getStyleData(path) + )}`; + break; + + default: + this.#sassStyles.rules = `${this.#sassStyles.rules}\n${ + this.organizeModuleImports(this.getStyleData(path)) + }`; + break; + } + }); + + this.compileApply(); + + monitorFile(`${GLib.get_user_cache_dir()}/wal/colors`, () => { + this.#sassStyles.colors = `${this.getColorDefinitions()}\n${ + this.organizeModuleImports(this.getStyleData( + "/io/github/retrozinndev/colorshell/styles/colors" + )) + }`; + this.compileApply(); + }); + } +} diff --git a/src/modules/utils.ts b/src/modules/utils.ts new file mode 100644 index 00000000..c3aef5ef --- /dev/null +++ b/src/modules/utils.ts @@ -0,0 +1,177 @@ +import { createPoll } from "ags/time"; +import { exec, execAsync } from "ags/process"; +import { Astal, Gtk } from "ags/gtk4"; +import { getSymbolicIcon } from "./apps"; + +import GLib from "gi://GLib?version=2.0"; +import Gio from "gi://Gio?version=2.0"; + +export { + type JSXNode as WidgetNodeType, + toBoolean as variableToBoolean, + construct, + transform, + transformWidget, + createSubscription, + createAccessorBinding as baseBinding, + createScopedConnection, + createSecureBinding as secureBinding, + createSecureAccessorBinding as secureBaseBinding, +} from "gnim-utils"; + + +export const decoder = new TextDecoder("utf-8"), + encoder = new TextEncoder(); +export const time = createPoll(GLib.DateTime.new_now_local(), 500, () => + GLib.DateTime.new_now_local()); + +export function getHyprlandInstanceSig(): (string|null) { + return GLib.getenv("HYPRLAND_INSTANCE_SIGNATURE"); +} + +export function getHyprlandVersion(): string { + return exec(`${GLib.getenv("HYPRLAND_CMD") ?? "Hyprland"} --version | head -n1`).split(" ")[1]; +} + +export function getPlayerIconFromBusName(busName: string): string { + const splitName = busName.split('.').filter(str => str !== "" && + !str.toLowerCase().includes('instance')); + + return getSymbolicIcon(splitName[splitName.length - 1]) ? + getSymbolicIcon(splitName[splitName.length - 1])! + : "folder-music-symbolic"; +} + +export function escapeUnintendedMarkup(input: string): string { + return input.replace(/<[^>]*>|[<>&"]/g, (s) => { + if(s.startsWith('<') && s.endsWith('>')) + return s; + + switch(s) { + case "<": return "<"; + case ">": return ">"; + case "&": return "&"; + case "\"": return """; + } + + return s; + }); +} + +export function escapeSpecialCharacters(str: string): string { + return str.replace(/[\\^$.*?()[\]{}|]/g, "\\$&"); +} + +export function getChildren(widget: Gtk.Widget): Array { + const firstChild = widget.get_first_child(), + children: Array = []; + if(!firstChild) return []; + + let currentChild = firstChild.get_next_sibling(); + while(currentChild != null) { + children.push(currentChild); + currentChild = currentChild.get_next_sibling(); + } + + return children; +} + +export function omitObjectKeys(obj: ObjT, keys: keyof ObjT|Array): object { + const finalObject = { ...obj }; + + for(const objKey of Object.keys(finalObject as object)) { + if(!Array.isArray(keys)) { + if(objKey === keys) { + delete finalObject[keys as keyof typeof finalObject]; + break; + } + + continue; + } + + for(const omitKey of keys) { + if(objKey === omitKey) { + delete finalObject[objKey as keyof typeof finalObject]; + break; + } + } + } + + return finalObject as object; +} + +export function pickObjectKeys(obj: ObjT, keys: Array): object { + const finalObject = {} as Record; + + for(const key of keys) { + for(const objKey of Object.keys(obj as object)) { + if(key === objKey) { + finalObject[key as keyof ObjT] = obj[objKey as keyof ObjT]; + break; + } + } + } + + return finalObject; +} + +export function pathToURI(path: string): string { + switch(true) { + case (/^[/]/).test(path): + return `file://${path}`; + + case (/^[~]/).test(path): + case (/^file:\/\/[~]/i).test(path): + return `file://${GLib.get_home_dir()}/${path.replace(/^(file\:\/\/|[~]|file\:\/\[~])/i, "")}`; + } + + return path; +} + +export function makeDirectory(dir: string): void { + execAsync([ "mkdir", "-p", dir ]); +} + +export function deleteFile(path: string): void { + execAsync([ "rm", "-r", path ]); +} + +export function playSystemBell(): void { + execAsync("canberra-gtk-play -i bell").catch((e: Error) => { + console.error(`Couldn't play system bell. Stderr: ${e.message}\n${e.stack}`); + }); +} + +export function isInstalled(commandName: string): boolean { + const proc = Gio.Subprocess.new(["bash", "-c", `command -v ${commandName}`], + Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE); + + const [ , stdout, stderr ] = proc.communicate_utf8(null, null); + if(stdout && !stderr) + return true; + + return false; +} + +export function addSliderMarksFromMinMax(slider: Astal.Slider, amountOfMarks: number = 2, markup?: (string | null)) { + if(markup && !markup.includes("{}")) + markup = `${markup}{}` + + slider.add_mark(slider.min, Gtk.PositionType.BOTTOM, markup ? + markup.replaceAll("{}", `${slider.min}`) : null); + + const num = (amountOfMarks - 1); + for(let i = 1; i <= num; i++) { + const part = (slider.max / num) | 0; + + if(i > num) { + slider.add_mark(slider.max, Gtk.PositionType.BOTTOM, `${slider.max}K`); + break; + } + + slider.add_mark(part*i, Gtk.PositionType.BOTTOM, markup ? + markup.replaceAll("{}", `${part*i}`) : null); + } + + return slider; +} diff --git a/ags/scripts/volume.ts b/src/modules/volume.ts similarity index 94% rename from ags/scripts/volume.ts rename to src/modules/volume.ts index 860868d4..c247e6ca 100644 --- a/ags/scripts/volume.ts +++ b/src/modules/volume.ts @@ -1,11 +1,8 @@ -import { GObject, register } from "astal"; import AstalWp from "gi://AstalWp"; -export { Wireplumber }; -@register({ GTypeName: "Wireplumber" }) -class Wireplumber extends GObject.Object { - private static astalWireplumber: (AstalWp.Wp|null) = AstalWp.get_default(); +export class Wireplumber { + private static astalWireplumber: AstalWp.Wp|null = AstalWp.get_default(); private static inst: Wireplumber; private defaultSink: AstalWp.Endpoint = Wireplumber.astalWireplumber!.get_default_speaker()!; @@ -15,8 +12,6 @@ class Wireplumber extends GObject.Object { private maxSourceVolume: number = 100; constructor() { - super(); - if(!Wireplumber.astalWireplumber) throw new Error("Audio features will not work correctly! Please install wireplumber first", { cause: "Wireplumber library not found" diff --git a/ags/scripts/wallpaper.ts b/src/modules/wallpaper.ts similarity index 78% rename from ags/scripts/wallpaper.ts rename to src/modules/wallpaper.ts index 020fb37d..8960d3b6 100644 --- a/ags/scripts/wallpaper.ts +++ b/src/modules/wallpaper.ts @@ -1,7 +1,45 @@ -import { AstalIO, execAsync, Gio, GLib, GObject, monitorFile, property, register, timeout } from "astal"; +import { execAsync } from "ags/process"; +import { timeout } from "ags/time"; +import { monitorFile, readFile } from "ags/file"; +import GObject, { register, getter } from "ags/gobject"; + +import AstalIO from "gi://AstalIO"; +import Gio from "gi://Gio?version=2.0"; +import GLib from "gi://GLib?version=2.0"; +import { decoder, encoder } from "./utils"; + export { Wallpaper }; +type WalData = { + checksum: string; + wallpaper: string; + alpha: number; + special: { + background: string; + foreground: string; + cursor: string; + }; + colors: { + color0: string; + color1: string; + color2: string; + color3: string; + color4: string; + color5: string; + color6: string; + color7: string; + color8: string; + color9: string; + color10: string; + color11: string; + color12: string; + color13: string; + color14: string; + color15: string; + }; +}; + @register({ GTypeName: "Wallpaper" }) class Wallpaper extends GObject.Object { private static instance: Wallpaper; @@ -12,15 +50,17 @@ class Wallpaper extends GObject.Object { #wallpapersPath: string; #ignoreWatch: boolean = false; - @property(Boolean) + @getter(Boolean) public get splash() { return this.#splash; } public set splash(showSplash: boolean) { this.#splash = showSplash; this.notify("splash"); } - @property(String) - public get wallpaper(): (string|undefined) { return this.#wallpaper; } + /** current wallpaper's complete path + * can be an empty string if undefined */ + @getter(String) + public get wallpaper() { return this.#wallpaper ?? ""; } public set wallpaper(newValue: string) { this.setWallpaper(newValue); } public get wallpapersPath() { return this.#wallpapersPath; } @@ -28,8 +68,12 @@ class Wallpaper extends GObject.Object { constructor() { super(); - this.#wallpapersPath = GLib.getenv("WALLPAPERS") ?? `${GLib.get_home_dir()}/wallpapers`; - this.#hyprpaperFile = Gio.File.new_for_path(`${GLib.get_user_config_dir()}/hypr/hyprpaper.conf`); + this.#wallpapersPath = GLib.getenv("WALLPAPERS") ?? + `${GLib.get_home_dir()}/wallpapers`; + + this.#hyprpaperFile = Gio.File.new_for_path(`${ + GLib.get_user_config_dir()}/hypr/hyprpaper.conf`); + this.getWallpaper().then((wall) => { if(wall?.trim()) this.#wallpaper = wall.trim(); }); @@ -53,7 +97,7 @@ class Wallpaper extends GObject.Object { if(!loaded) console.error("Wallpaper: Couldn't read changes inside the hyprpaper file!"); - const content = new TextDecoder().decode(text); + const content = decoder.decode(text); if(content) { let setWall: boolean = true; @@ -104,7 +148,7 @@ class Wallpaper extends GObject.Object { if(res) { // success this.#ignoreWatch = true; // tell monitor to ignore this change - res.write_bytes_async(new TextEncoder().encode(`# This file was automatically generated by color-shell + res.write_bytes_async(encoder.encode(`# This file was automatically generated by color-shell preload = ${this.#wallpaper} splash = ${this.#splash} @@ -123,6 +167,11 @@ class Wallpaper extends GObject.Object { ); } + public getData(): WalData { + const content = readFile(`${GLib.getenv("XDG_CACHE_HOME")}/wal/colors.json`); + return JSON.parse(content) as WalData; + } + public async getWallpaper(): Promise { return await execAsync("sh -c \"hyprctl hyprpaper listactive | tail -n 1\"").then(stdout => { const loaded: (string|undefined) = stdout.split('=')[1]?.trim(); diff --git a/src/runner/Runner.tsx b/src/runner/Runner.tsx new file mode 100644 index 00000000..3c8c9c91 --- /dev/null +++ b/src/runner/Runner.tsx @@ -0,0 +1,335 @@ +import { Astal, Gdk, Gtk } from "ags/gtk4"; +import { getPopupWindowContainer, PopupWindow } from "../widget/PopupWindow"; +import { updateApps } from "../modules/apps"; +import { ResultWidget, ResultWidgetProps } from "./widgets/ResultWidget"; +import { Windows } from "../windows"; +import { timeout } from "ags/time"; + +import AstalHyprland from "gi://AstalHyprland"; +import AstalIO from "gi://AstalIO"; + + +export namespace Runner { +export type RunnerProps = { + halign?: Gtk.Align; + valign?: Gtk.Align; + width?: number; + height?: number; + entryPlaceHolder?: string; + initialText?: string; + resultsLimit?: number; + showResultsPlaceHolderOnStartup?: boolean; +}; + +export type Result = ResultWidgetProps; + +export interface Plugin { + /** prefix to call the plugin. if undefined, will be triggered like applications plugin */ + readonly prefix?: string; + /** name of the plugin. e.g.: websearch, shell */ + readonly name?: string; + /** runs when runner opens */ + readonly init?: () => void; + /** handle the user input to return results (does not include plugin's prefix) */ + readonly handle: (inputText: string) => (Result|Array|null|undefined); + /** runs when runner closes */ + readonly onClose?: () => void; + /** prioritize this plugin's results over other results. + * (hides other results that aren't from this plugin on list) */ + prioritize?: boolean; +} + +export let instance: (Astal.Window|null) = null; + +let gtkEntry: (Gtk.SearchEntry|null) = null; +const plugins = new Set(); +const ignoredKeys = [ + Gdk.KEY_space, + Gdk.KEY_Shift_L, + Gdk.KEY_Shift_R, + Gdk.KEY_Shift_Lock, + Gdk.KEY_Return, + Gdk.KEY_Tab, + Gdk.KEY_Control_L, + Gdk.KEY_Control_R, + Gdk.KEY_Alt_L, + Gdk.KEY_Alt_R, + Gdk.KEY_Option, + Gdk.KEY_Super_L, + Gdk.KEY_Super_R,, + Gdk.KEY_F5, + Gdk.KEY_Up, + Gdk.KEY_Down, + Gdk.KEY_Left, + Gdk.KEY_Right +]; + + +export function close() { instance?.close(); } +export function regExMatch(search: string, item: (string|number)): boolean { + search = search.replace(/[\\^$.*?()[\]{}|]/g, "\\$&"); + + if(typeof item === "number") + return new RegExp(`${search.split('').map(c => + `[${c}]`).join('')}`, + "g").test(item.toString()); + + return new RegExp(`${search.split('').map(c => + `${c}`).join('')}`, + "gi").test(item); +} + + +export function addPlugin(plugin: Runner.Plugin, force?: boolean) { + if(!force && plugin.prefix && plugins.has(plugin)) + throw new Error(`Runner plugin with prefix ${plugin.prefix} already exists`); + + plugins.delete(plugin); + plugins.add(plugin); +} + +export function getPlugins(): Array { + return [...plugins.values()]; +} + +/** Removes a plugin from the runner plugins list + * @returns true if plugin was removed or false if plugin wasn't found + */ +export function removePlugin(plugin: Plugin): boolean { + return plugins.delete(plugin); +} + +export function setEntryText(text: string): void { + gtkEntry?.set_text(text); + gtkEntry?.set_position(gtkEntry.text.length); + + gtkEntry?.grab_focus(); +} + +export function openDefault(initialText?: string) { + return Runner.openRunner({ + entryPlaceHolder: "Start typing...", + initialText, + showResultsPlaceHolderOnStartup: false, + resultsLimit: 24 + } as Runner.RunnerProps, [ + { + icon: "application-x-executable-symbolic", + title: "Use your applications", + description: "Search for any app installed in your computer", + closeOnClick: false, + actionClick: () => gtkEntry?.grab_focus() + }, + { + icon: "edit-paste-symbolic", + title: "See your clipboard history", + description: "Start your search with '>' to go through your clipboard history", + closeOnClick: false, + actionClick: () => setEntryText('>') + }, + { + icon: "image-x-generic-symbolic", + title: "Change your wallpaper", + description: "Add '#' at the start to search through the wallpapers folder!", + closeOnClick: false, + actionClick: () => setEntryText('#'), + }, + { + icon: "utilities-terminal-symbolic", + title: "Run shell commands", + description: "Add '!' before your command to run it (pro tip: add a second '!' to show command output)", + closeOnClick: false, + actionClick: () => setEntryText('!!') + }, + { + icon: "media-playback-start-symbolic", + title: "Control media", + description: "Type ':' to control playing media", + closeOnClick: false, + actionClick: () => setEntryText(':') + }, + { + icon: "applications-internet-symbolic", + title: "Search the Web", + description: "Start typing with '?' prefix to search the web", + closeOnClick: false, + actionClick: () => setEntryText('?') + } + ]); +} + +function getPluginResults(input: string, limit?: number): Array { + let calledPlugins: Array = getPlugins().filter((plugin) => + plugin.prefix ? (input.startsWith(plugin.prefix) ? true : false) : true + ).sort((plugin) => plugin.prefix != null ? 0 : 1); + + for(const plugin of calledPlugins) { + if(plugin.prioritize) { + calledPlugins = [ plugin ]; + break; + } + } + + const results = calledPlugins.map(plugin => plugin.handle( + plugin.prefix ? input.replace(plugin.prefix, "") : input) + ).filter(value => value !== undefined && value !== null).flat(1); + + return limit != null && limit > 0 ? + results.splice(0, limit) + : results; +} + +function updateResultsList(listbox: Gtk.ListBox, input: string, limit?: number, placeholders?: Array) { + const newResults: Array = [], + scrolledWindow = listbox.parent.parent as Gtk.ScrolledWindow; + + listbox.remove_all(); + getPluginResults(input, limit).forEach((result) => { + listbox.insert( as ResultWidget, -1); + newResults.push(result); + }); + + // Insert placeholder if there are no results + if(placeholders && newResults.length < 1) + placeholders.forEach(phdlr => listbox.insert( + as ResultWidget, -1 + )); + + newResults.length > 0 ? + (!scrolledWindow.visible && scrolledWindow.show()) + : scrolledWindow.hide(); +} + +function selectPreviousItem(listbox: Gtk.ListBox) { + const selectedRow = listbox.get_selected_row(); + const prevRow = selectedRow?.get_prev_sibling(); + + if(!prevRow || selectedRow === listbox.get_first_child()) + return; + + const viewport = listbox.parent as Gtk.Viewport; + const vadjustment = (viewport.parent as Gtk.ScrolledWindow).get_vadjustment(); + const [, , prevRowY] = prevRow.translate_coordinates(viewport, + prevRow.get_allocation().x, prevRow.get_allocation().y); + + listbox.select_row(prevRow as Gtk.ListBoxRow); + if(prevRowY < vadjustment.get_value()) + vadjustment.set_value(prevRowY); +} + +function selectNextItem(listbox: Gtk.ListBox) { + const selectedRow = listbox.get_selected_row(); + const nextRow = selectedRow?.get_next_sibling(); + + if(!nextRow || selectedRow === listbox.get_last_child()) + return; + + const viewport = listbox.parent as Gtk.Viewport; + const vadjustment = (viewport.parent as Gtk.ScrolledWindow).get_vadjustment(); + const nextRowVAllocation = (nextRow.get_allocation().y + nextRow.get_allocation().height); + + listbox.select_row(nextRow as Gtk.ListBoxRow); + if(nextRowVAllocation > viewport.get_allocation().height) + vadjustment.set_value(nextRow.get_allocation().y - viewport.get_allocation().height + nextRow.get_allocation().height);} + +export function openRunner(props: RunnerProps, placeholders?: Array): Astal.Window { + props.width ??= 780; + props.height ??= 420; + + let clickTimeout: AstalIO.Time|undefined; + + if(!instance) + instance = Windows.getDefault().createWindowForFocusedMonitor((mon, root) => + { + plugins.forEach(plugin => + plugin.init?.()); + + props.initialText && + Runner.setEntryText(props.initialText); + }} actionKeyPressed={(self, keyval) => { + const listbox = ((getPopupWindowContainer(self).get_last_child() as Gtk.ScrolledWindow) + .get_child() as Gtk.Viewport).get_child() as Gtk.ListBox; + + switch(keyval) { + case Gdk.KEY_F5: + updateApps(); + return; + + case Gdk.KEY_Left: + case Gdk.KEY_Up: + selectPreviousItem(listbox); + gtkEntry?.grab_focus(); + return; + + case Gdk.KEY_Right: + case Gdk.KEY_Down: + selectNextItem(listbox); + gtkEntry?.grab_focus(); + return; + } + + for(const key of ignoredKeys) { + if(keyval === key) + return; + } + + if(!gtkEntry?.hasFocus) { + gtkEntry?.grab_focus(); + listbox.grab_focus(); + } + }} actionClosed={() => { + [...plugins.values()].forEach(plugin => plugin?.onClose?.()); + root.dispose(); + + instance = null; + gtkEntry = null; + }}> + gtkEntry = self} searchDelay={0} onSearchChanged={(self) => { + const listbox = ((self.get_next_sibling()! as Gtk.ScrolledWindow) + .get_child() as Gtk.Viewport).get_child() as Gtk.ListBox; + updateResultsList(listbox, self.text, props.resultsLimit, placeholders); + + listbox.get_row_at_index(0) && + listbox.select_row(listbox.get_row_at_index(0)); + }} onActivate={(self) => { + const listbox = ((self.get_next_sibling() as Gtk.ScrolledWindow) + .get_child() as Gtk.Viewport).get_child() as Gtk.ListBox; + const resultWidget = listbox.get_selected_row()?.get_child(); + + if(resultWidget instanceof ResultWidget && !clickTimeout) { + clickTimeout = timeout(250, () => clickTimeout = undefined); + resultWidget.actionClick(); + resultWidget.closeOnClick && + Runner.close(); + } + + }} onStopSearch={() => Runner.close()} // close Runner on Escape + /> + + + { + const child = row.get_child()!; + + if(child instanceof ResultWidget && !clickTimeout) { + clickTimeout = timeout(250, () => clickTimeout = undefined); + child.actionClick?.(); + child.closeOnClick && + Runner.close(); + } + }} + /> + + as Astal.Window + )(); + + return instance!; +} +} diff --git a/src/runner/plugins/apps.ts b/src/runner/plugins/apps.ts new file mode 100644 index 00000000..87cc22cb --- /dev/null +++ b/src/runner/plugins/apps.ts @@ -0,0 +1,18 @@ +import { execApp, getAstalApps, lookupIcon, updateApps } from "../../modules/apps"; +import { Runner } from "../Runner"; + +export const PluginApps = { + // Do not provide prefix, so it always runs. + name: "Apps", + // asynchronously-refresh apps list on init + init: async () => updateApps(), + handle: (text: string) => { + return getAstalApps().fuzzy_query(text).map(app => ({ + title: app.get_name(), + description: app.get_description(), + icon: lookupIcon(app.iconName) ? app.iconName : "application-x-executable-symbolic", + actionClick: () => execApp(app) + }) + ); + } +} as Runner.Plugin; diff --git a/src/runner/plugins/clipboard.ts b/src/runner/plugins/clipboard.ts new file mode 100644 index 00000000..c2fa24ed --- /dev/null +++ b/src/runner/plugins/clipboard.ts @@ -0,0 +1,33 @@ +import { Gtk } from "ags/gtk4"; +import { Clipboard } from "../../modules/clipboard"; +import { Runner } from "../Runner"; +import { jsx } from "ags/gtk4/jsx-runtime"; + + +export const PluginClipboard = { + prefix: '>', + prioritize: true, + handle: (search) => { + if(Clipboard.getDefault().history.length < 1) + return { + icon: "edit-paste-symbolic", + title: "Clipboard is empty", + description: "Copy something and it will be shown right here!" + }; + + return Clipboard.getDefault().history.filter(item => + // not the best way to search, but it works + Runner.regExMatch(search, item.id) || Runner.regExMatch(search, item.preview)).map((item) => ({ + icon: jsx(Gtk.Label, { + label: `${item.id}`, + css: "font-size: 16px; margin-right: 8px; font-weight: 600;" + }), + title: item.preview, + actionClick: () => Clipboard.getDefault().selectItem(item).catch((err: Error) => { + console.error(`Runner(Plugin/Clipboard): An error occurred while selecting clipboard item. Stderr:\n${ + err.message ? `${err.message}\n` : ""}Stack: ${err.stack}` + ); + }) + })); + } +} as Runner.Plugin; diff --git a/src/runner/plugins/index.ts b/src/runner/plugins/index.ts new file mode 100644 index 00000000..edf759ac --- /dev/null +++ b/src/runner/plugins/index.ts @@ -0,0 +1,18 @@ +import { PluginApps } from "./apps" +import { PluginClipboard } from "./clipboard" +import { PluginMedia } from "./media" +import { PluginShell } from "./shell" +import { PluginWallpapers } from "./wallpapers" +import { PluginWebSearch } from "./websearch" +import { PluginKill } from "./kill" + + +export { + PluginApps, + PluginWebSearch, + PluginClipboard, + PluginShell, + PluginMedia, + PluginWallpapers, + PluginKill +}; diff --git a/src/runner/plugins/kill.ts b/src/runner/plugins/kill.ts new file mode 100644 index 00000000..2a790082 --- /dev/null +++ b/src/runner/plugins/kill.ts @@ -0,0 +1,18 @@ +import { execAsync } from "ags/process"; +import { Runner } from "../Runner"; +import { Notifications } from "../../modules/notifications"; + +export const PluginKill = { + prefix: ":k", + handle: () => ({ + title: "Select a client to kill", + closeOnClick: true, + icon: "window-close-symbolic", + actionClick: () => execAsync("hyprctl kill").catch((e) => + Notifications.getDefault().sendNotification({ + summary: "Couldn't kill client", + body: `An error occurred while trying to kill a client! Stderr: ${e}` + }) + ) + } satisfies Runner.Result) +} satisfies Runner.Plugin; diff --git a/src/runner/plugins/media.ts b/src/runner/plugins/media.ts new file mode 100644 index 00000000..a5e6f6f2 --- /dev/null +++ b/src/runner/plugins/media.ts @@ -0,0 +1,99 @@ +import { createBinding, createComputed } from "ags"; +import { Runner } from "../Runner"; +import { secureBaseBinding } from "../../modules/utils"; +import { tr } from "../../i18n/intl"; + +import Media from "../../modules/media"; +import AstalMpris from "gi://AstalMpris"; + + +export const PluginMedia = { + prefix: ":", + handle: () => !Media.getDefault().player.available ? { + icon: "folder-music-symbolic", + title: "Couldn't find any players", + closeOnClick: false, + description: "No media / player found with mpris" + } : [ + { + icon: secureBaseBinding( + createBinding(Media.getDefault(), "player"), + "playbackStatus", + AstalMpris.PlaybackStatus.PAUSED + ).as((status) => status === AstalMpris.PlaybackStatus.PLAYING ? + "media-playback-pause-symbolic" + : "media-playback-start-symbolic"), + closeOnClick: false, + title: createComputed([ + secureBaseBinding( + createBinding(Media.getDefault(), "player"), + "title", + null + ).as(t => t ?? tr("media.no_title")), + secureBaseBinding( + createBinding(Media.getDefault(), "player"), + "artist", + null + ).as(t => t ?? tr("media.no_artist")), + secureBaseBinding( + createBinding(Media.getDefault(), "player"), + "playbackStatus", + AstalMpris.PlaybackStatus.PAUSED + ) + ], (title, artist, status) => `${status === AstalMpris.PlaybackStatus.PLAYING ? + "Pause" : "Play" + } ${title} | ${artist}`), + actionClick: () => Media.getDefault().player.play_pause() + }, + { + icon: "media-skip-backward-symbolic", + closeOnClick: false, + title: createComputed([ + secureBaseBinding( + createBinding(Media.getDefault(), "player"), + "title", + null + ).as(t => t ?? tr("media.no_title")), + secureBaseBinding( + createBinding(Media.getDefault(), "player"), + "artist", + null + ).as(t => t ?? tr("media.no_artist")), + secureBaseBinding( + createBinding(Media.getDefault(), "player"), + "identity", + "Music Player" + ) + ], (title, artist, identity) => + `Go Previous ${title ? title : identity}${artist ? ` | ${artist}` : ""}` + ), + actionClick: () => Media.getDefault().player.canGoPrevious && + Media.getDefault().player.previous() + }, + { + icon: "media-skip-forward-symbolic", + closeOnClick: false, + title: createComputed([ + secureBaseBinding( + createBinding(Media.getDefault(), "player"), + "title", + null + ).as(t => t ?? tr("media.no_title")), + secureBaseBinding( + createBinding(Media.getDefault(), "player"), + "artist", + null + ).as(t => t ?? tr("media.no_artist")), + secureBaseBinding( + createBinding(Media.getDefault(), "player"), + "identity", + "Music Player" + ) + ], (title, artist, identity) => + `Go Next ${title ? title : identity}${artist ? ` | ${artist}` : ""}` + ), + actionClick: () => Media.getDefault().player.canGoNext && + Media.getDefault().player.next() + } + ] +} as Runner.Plugin; diff --git a/ags/runner/plugins/shell.ts b/src/runner/plugins/shell.ts similarity index 74% rename from ags/runner/plugins/shell.ts rename to src/runner/plugins/shell.ts index 89a6a63a..82d1844d 100644 --- a/ags/runner/plugins/shell.ts +++ b/src/runner/plugins/shell.ts @@ -1,7 +1,9 @@ -import { ResultWidget, ResultWidgetProps } from "../../widget/runner/ResultWidget"; -import { Gio, GLib } from "astal"; import { Runner } from "../Runner"; -import { Notifications } from "../../scripts/notifications"; +import { Notifications } from "../../modules/notifications"; + +import GLib from "gi://GLib?version=2.0"; +import Gio from "gi://Gio?version=2.0"; + export const PluginShell = (() => { @@ -14,7 +16,7 @@ export const PluginShell = (() => { return { prefix: '!', prioritize: true, - handle: (input: string): ResultWidget => { + handle: (input) => { let showOutputNotif: boolean = false; if(input.startsWith('!')) { input = input.replace('!', ""); @@ -22,10 +24,12 @@ export const PluginShell = (() => { } const command = input ? GLib.shell_parse_argv(input) : undefined; + const shellSplit = shell.split('/'), + shellName = shellSplit[shellSplit.length-1]; - return new ResultWidget({ - onClick: () => { - if(!command || !command[0]) return; + return { + actionClick: () => { + if(!command?.[0] || !command[1]) return; const proc = procLauncher.spawnv([ shell, "-c", `${input}` ]); proc.communicate_utf8_async(null, null, (_, asyncResult) => { @@ -50,10 +54,12 @@ export const PluginShell = (() => { }); }); }, - title: `Run ${input ? ` \`${input}\`` : `with ${shell.split('/')[shell.split('/').length-1]}`}`, - description: (input || showOutputNotif) && `${input ? `${shell}\t` : ""}${ showOutputNotif ? "(showing output on notification)" : "" }`, + title: `Run ${input ? ` \`${input}\`` : `with ${shellName}`}`, + description: input || showOutputNotif ? `${input ? `${shell}\t` : ""}${ + showOutputNotif ? "(showing output on notification)" : "" }` + : "", icon: "utilities-terminal-symbolic" - } as ResultWidgetProps) + }; } } as Runner.Plugin })(); diff --git a/ags/runner/plugins/wallpapers.ts b/src/runner/plugins/wallpapers.ts similarity index 65% rename from ags/runner/plugins/wallpapers.ts rename to src/runner/plugins/wallpapers.ts index 09b21fd8..7e3696ca 100644 --- a/ags/runner/plugins/wallpapers.ts +++ b/src/runner/plugins/wallpapers.ts @@ -1,10 +1,10 @@ -import { Gio } from "astal"; -import { Wallpaper } from "../../scripts/wallpaper"; +import { Wallpaper } from "../../modules/wallpaper"; import { Runner } from "../Runner"; -import { ResultWidget, ResultWidgetProps } from "../../widget/runner/ResultWidget"; +import Gio from "gi://Gio?version=2.0"; -export class PluginWallpapers implements Runner.Plugin { + +class _PluginWallpapers implements Runner.Plugin { prefix = "#"; prioritize = true; #files: (Array|undefined); @@ -25,17 +25,20 @@ export class PluginWallpapers implements Runner.Plugin { handle(search: string) { if(this.#files!.length > 0) - return this.#files!.filter(file => // not the best way to search, but it works + return this.#files!.filter(file => + // also not the best way to search, but it works Runner.regExMatch(search, file.split('/')[file.split('/').length-1]) - ).map(path => new ResultWidget({ + ).map(path => ({ title: path.split('/')[path.split('/').length-1].replace(/\..*$/, ""), - onClick: () => Wallpaper.getDefault().setWallpaper(path) - } as ResultWidgetProps)); + actionClick: () => Wallpaper.getDefault().setWallpaper(path) + })); - return new ResultWidget({ + return { title: "No wallpapers found!", description: "Define the $WALLPAPERS variable on Hyprland or create a ~/wallpapers directory", icon: "image-missing-symbolic" - } as ResultWidgetProps); + }; } } + +export const PluginWallpapers = new _PluginWallpapers(); diff --git a/src/runner/plugins/websearch.ts b/src/runner/plugins/websearch.ts new file mode 100644 index 00000000..710b7079 --- /dev/null +++ b/src/runner/plugins/websearch.ts @@ -0,0 +1,27 @@ +import AstalHyprland from "gi://AstalHyprland"; +import { Runner } from "../Runner"; + + +const searchEngines = { + duckduckgo: "https://duckduckgo.com/?q=", + google: "https://google.com/search?q=", + yahoo: "https://search.yahoo.com/search?p=" +}; + +let engine: string = searchEngines.google; + +export const PluginWebSearch = { + prefix: '?', + name: "Web Search", + prioritize: true, + + handle: (search) => ({ + icon: "system-search-symbolic", + title: search || "Type your search...", + description: `Search the Web`, + actionClick: () => AstalHyprland.get_default().dispatch( + "exec", + `xdg-open \"${engine + search}\"` + ) + }) +} as Runner.Plugin; diff --git a/src/runner/widgets/ResultWidget.tsx b/src/runner/widgets/ResultWidget.tsx new file mode 100644 index 00000000..409c0c26 --- /dev/null +++ b/src/runner/widgets/ResultWidget.tsx @@ -0,0 +1,71 @@ +import { Accessor, With } from "ags"; +import { register } from "ags/gobject"; +import { Gtk } from "ags/gtk4"; +import { variableToBoolean } from "../../modules/utils"; + +import Pango from "gi://Pango?version=1.0"; + + +export { ResultWidget, ResultWidgetProps }; + +type ResultWidgetProps = { + icon?: string | Accessor | JSX.Element | Accessor; + title: string | Accessor; + description?: string | Accessor; + closeOnClick?: boolean; + setup?: () => void; + actionClick?: () => void; + visible?: boolean; +}; + +@register({ GTypeName: "ResultWidget" }) +class ResultWidget extends Gtk.Box { + + public readonly actionClick: () => void; + public readonly setup?: () => void; + public icon?: (string | Accessor | JSX.Element | Accessor); + public closeOnClick: boolean = true; + + + constructor(props: ResultWidgetProps) { + super(); + + this.add_css_class("result"); + + this.visible = props.visible ?? true; + this.hexpand = true; + this.icon = props.icon; + this.setup = props.setup; + this.closeOnClick = props.closeOnClick ?? true; + this.actionClick = () => props.actionClick?.(); + + if(this.icon !== undefined) { + if(this.icon instanceof Accessor) { + if(typeof this.icon.get() === "string") { + this.prepend( + } /> as Gtk.Image); + } else { + this.prepend( + }> + {(widget) => widget} + + as Gtk.Box); + } + } else { + if(typeof this.icon === "string") + this.prepend( as Gtk.Image); + else + this.prepend(this.icon as Gtk.Widget); + } + } + + this.append( + + + + as Gtk.Box); + } +} diff --git a/src/widget/AskPopup.tsx b/src/widget/AskPopup.tsx new file mode 100644 index 00000000..0f2bb66f --- /dev/null +++ b/src/widget/AskPopup.tsx @@ -0,0 +1,38 @@ +import { Accessor } from "ags"; +import { tr } from "../i18n/intl"; +import { CustomDialog } from "./CustomDialog"; +import { Astal } from "ags/gtk4"; + + +export type AskPopupProps = { + title?: string | Accessor; + text: string | Accessor; + cancelText?: string; + acceptText?: string; + onAccept?: () => void; + onCancel?: () => void; +}; + +/** + * A Popup Widget that asks yes or no to a defined promt. + * Runs onAccept() when user accepts, or else onDecline() when + * user doesn't accept / closes window. + * This window isn't usually registered in this shell windowing + * system. + */ +export function AskPopup(props: AskPopupProps): Astal.Window { + let accepted: boolean = false; + + return !accepted && props.onCancel?.()} options={[ + { text: props.cancelText ?? tr("cancel") }, + { + text: props.acceptText ?? tr("accept"), + onClick: () => { + accepted = true; + props.onAccept?.(); + } + } + ]} /> as Astal.Window; +} diff --git a/src/widget/AuthPopup.tsx b/src/widget/AuthPopup.tsx new file mode 100644 index 00000000..0a325f99 --- /dev/null +++ b/src/widget/AuthPopup.tsx @@ -0,0 +1,73 @@ +import { Astal, Gtk } from "ags/gtk4"; +import { CustomDialog, getContainerCustomDialog } from "./CustomDialog"; + +import GLib from "gi://GLib?version=2.0"; + + +export type AuthPopupData = { + user: string; + hidePassword: boolean; + passwd: string; +}; + +export function AuthPopup(props: { + /** hide password on showup. @default true */ + hidePassword?: boolean; + /** icon name of the application that's requesting this popup */ + iconName?: string; + /** popup body */ + text: string; + /** selected user by default */ + user?: string; + /** approve data after the user clicks the "grant permission" button */ + onContinue: (data: AuthPopupData, reject: (message: string) => void, approve: () => void) => void; +}): Astal.Window { + const data = { + passwd: "", + user: props.user ?? GLib.get_user_name(), + hidePassword: props.hidePassword ?? true + } satisfies AuthPopupData; + const allowUserChange = props.user === undefined; + + const dialog = { + if(allowUserChange) + data.user = userEntry!.text; + + data.passwd = passwordEntry.text; + data.hidePassword = passwordEntry.showPeekIcon; + + props.onContinue(data, + // rejected by checker function + (m) => { + // show error to user + !messageLabel.is_visible && + messageLabel.set_visible(true); + messageLabel.set_label(m); + + // clear password entry + passwordEntry.set_text(""); + }, + // approved by the checker + dialog.close + ); + }, + closeOnClick: false + } + ]}> + + + + + + as Astal.Window; + const messageLabel = getContainerCustomDialog(dialog).get_last_child() as Gtk.Label; + const userEntry = allowUserChange ? getContainerCustomDialog(dialog).get_first_child() as Gtk.Entry : undefined; + const passwordEntry = getContainerCustomDialog(dialog).get_first_child()?.get_next_sibling() as Gtk.PasswordEntry; + + return dialog; +} diff --git a/src/widget/BackgroundWindow.tsx b/src/widget/BackgroundWindow.tsx new file mode 100644 index 00000000..74c3fdb1 --- /dev/null +++ b/src/widget/BackgroundWindow.tsx @@ -0,0 +1,90 @@ +import { Accessor } from "ags"; +import { Astal, Gdk, Gtk } from "ags/gtk4"; +import GObject from "gi://GObject?version=2.0"; + + +const { TOP, LEFT, RIGHT, BOTTOM } = Astal.WindowAnchor; + +export type BackgroundWindowProps = { + /** GtkWindow Layer */ + layer?: Astal.Layer | Accessor; + /** Monitor number where the window should open */ + monitor: number | Accessor; + /** Custom stylesheet used in the window. default: `background: rgba(0, 0, 0, .2)` */ + css?: string | Accessor; + /* Function that is called when the user releases a key in the keyboard on the window + * The `Escape` key is not passed to this function */ + actionKeyPressed?: (window: Astal.Window, keyval: number, keycode: number) => void; + /** Function that is called when the user triggers a mouse-click or escape action on the window */ + actionFired?: (window: Astal.Window) => void; + /** Function that is called when the user clicks on the window with primary mouse button */ + actionClickPrimary?: (window: Astal.Window) => void; + /** Function that is called when the user clicks on the window with secodary mouse button */ + actionClickSecondary?: (window: Astal.Window) => void; + onCloseRequest?: (window: Astal.Window) => void; + keymode?: Astal.Keymode; + exclusivity?: Astal.Exclusivity; + + /** attach this window as a background for another window + * background-window will close when the attached window triggers ::close-request) */ + attach?: Astal.Window; +}; + +/** Creates a fullscreen GtkWindow that is used for making + * the user focus on the content after this window(e.g.: AskPopup, + * Authentication Window(futurely) or any PopupWindow) + * + * @param props Properties for background-window + * + * @returns The generated background-window + */ +export function BackgroundWindow(props: BackgroundWindowProps): Astal.Window { + const conns: Map = new Map(); + + return { + const gestureClick = Gtk.GestureClick.new(), + eventControllerKey = Gtk.EventControllerKey.new(); + + gestureClick.set_button(0); + + self.add_controller(gestureClick); + self.add_controller(eventControllerKey); + + conns.set(eventControllerKey, eventControllerKey.connect("key-released", + (_, keyval, keycode) => { + if(keyval === Gdk.KEY_Escape) { + props.actionFired?.(self); + return; + } + + props.actionKeyPressed?.(self, keyval, keycode); + } + )); + + conns.set(gestureClick, gestureClick.connect("released", (gesture) => { + if(gesture.get_current_button() === Gdk.BUTTON_PRIMARY) { + props.actionClickPrimary?.(self); + return; + } + + if(gesture.get_current_button() === Gdk.BUTTON_SECONDARY) { + props.actionClickSecondary?.(self); + return; + } + + props.actionFired?.(self); + })); + + props.attach && + conns.set(props.attach, (props.attach as Gtk.Widget).connect("close-request", () => + self.close() + )); + + conns.set(self, self.connect("destroy", () => conns.forEach((id, obj) => + obj.disconnect(id)))); + }} /> as Astal.Window; +} diff --git a/src/widget/CustomDialog.tsx b/src/widget/CustomDialog.tsx new file mode 100644 index 00000000..2062d421 --- /dev/null +++ b/src/widget/CustomDialog.tsx @@ -0,0 +1,80 @@ +import { Astal, Gtk } from "ags/gtk4"; +import { Windows } from "../windows"; +import { getPopupWindowContainer, PopupWindow } from "./PopupWindow"; +import { Separator } from "./Separator"; +import { tr } from "../i18n/intl"; +import { Accessor } from "ags"; +import { transformWidget, variableToBoolean, WidgetNodeType } from "../modules/utils"; + + +export type CustomDialogProps = { + namespace?: string | Accessor; + className?: string | Accessor; + cssBackground?: string; + title?: string | Accessor; + text?: string | Accessor; + heightRequest?: number | Accessor; + widthRequest?: number | Accessor; + childOrientation?: Gtk.Orientation | Accessor; + children?: WidgetNodeType; + onFinish?: () => void; + options?: Array | Accessor>; + optionsOrientation?: Gtk.Orientation | Accessor; +}; + +export interface CustomDialogOption { + onClick?: () => void; + text: string | Accessor; + closeOnClick?: boolean | Accessor; +} + +function CustomDialogOption({closeOnClick = true, ...props}: CustomDialogOption & { + dialog: Astal.Window; +}) { + return { + props.onClick?.(); + closeOnClick && + props.dialog?.close(); + }} + /> +} + +export function CustomDialog({ options = [{ text: tr("accept") }], ...props}: CustomDialogProps) { + return Windows.getDefault().createWindowForFocusedMonitor((mon) => { + const popup = props.onFinish?.()} + widthRequest={props.widthRequest ?? 400} heightRequest={props.heightRequest ?? 220}> + + + + + + + {transformWidget(props.children, (child) => child as JSX.Element)} + + 0} + spacing={8} orientation={Gtk.Orientation.VERTICAL} /> + + as Astal.Window; + + (popup.get_child()!.get_first_child()!.get_first_child() as Gtk.Box).append( + + + {transformWidget(options, (props) => )} + as Gtk.Box + ); + + return popup; + })(); +} + +export function getContainerCustomDialog(dialog: Astal.Window): Gtk.Box { + return getPopupWindowContainer(dialog).get_first_child()?.get_last_child()?.get_prev_sibling() as Gtk.Box; +} diff --git a/src/widget/EntryPopup.tsx b/src/widget/EntryPopup.tsx new file mode 100644 index 00000000..7941852b --- /dev/null +++ b/src/widget/EntryPopup.tsx @@ -0,0 +1,61 @@ +import { Accessor } from "ags"; +import { tr } from "../i18n/intl"; +import { CustomDialog } from "./CustomDialog"; +import { Astal, Gtk } from "ags/gtk4"; + +export type EntryPopupProps = { + title: string | Accessor; + text?: string | Accessor; + cancelText?: string | Accessor; + acceptText?: string | Accessor; + closeOnAccept?: boolean; + entryPlaceholder?: string | Accessor; + onAccept: (userInput: string) => void; + onCancel?: () => void; + onFinish?: () => void; + isPassword?: boolean | Accessor; +}; + +export function EntryPopup(props: EntryPopupProps): Astal.Window { + props.closeOnAccept = props.closeOnAccept ?? true; + let entered: boolean = false; + + function onActivate(entry: Gtk.Entry|Gtk.PasswordEntry) { + props.closeOnAccept && window.close(); + entered = true; + props.onAccept(entry.text); + entry.text = ""; + } + + const entry = props.isPassword ? + as Gtk.PasswordEntry + : as Gtk.Entry; + + const window = { + entered = true; + props.onAccept(entry.text); + entry.text = ""; + } + } + ]} onFinish={() => { + !entered && props.onCancel?.() + props.onFinish?.(); + }} + /> as Astal.Window; + + return window; +} diff --git a/src/widget/Notification.tsx b/src/widget/Notification.tsx new file mode 100644 index 00000000..a2cd375d --- /dev/null +++ b/src/widget/Notification.tsx @@ -0,0 +1,122 @@ +import { Gdk, Gtk } from "ags/gtk4"; +import { Separator } from "./Separator"; +import { HistoryNotification, Notifications } from "../modules/notifications"; +import { getAppIcon, getSymbolicIcon } from "../modules/apps"; +import { escapeUnintendedMarkup, pathToURI } from "../modules/utils"; +import { onCleanup } from "ags"; + +import GObject from "ags/gobject"; +import AstalNotifd from "gi://AstalNotifd"; +import Pango from "gi://Pango?version=1.0"; +import GLib from "gi://GLib?version=2.0"; + + +function getNotificationImage(notif: AstalNotifd.Notification|HistoryNotification): (string|undefined) { + const img = notif.image || notif.appIcon; + + if(!img || !img.includes('/')) + return undefined; + + return pathToURI(img); +} + +export function NotificationWidget({ notification, actionClicked, holdOnHover, showTime, actionClose }: { + notification: AstalNotifd.Notification|number|HistoryNotification; + actionClicked?: (notif: AstalNotifd.Notification|HistoryNotification) => void; + actionClose?: (notif: AstalNotifd.Notification|HistoryNotification) => void; + holdOnHover?: boolean; + showTime?: boolean; // It's showTime :speaking_head: :boom: :bangbang: + }): Gtk.Widget { + + notification = (typeof notification === "number") ? + AstalNotifd.get_default().get_notification(notification) + : notification; + + const actions: Array|undefined = ((notification instanceof AstalNotifd.Notification) && + notification.actions && notification.actions.filter(a => Boolean(a)).length > 0) ? + notification.actions?.filter(a => + a?.id?.toLowerCase() !== "view" && a?.label?.toLowerCase() != "view" + ) + : undefined; + + const conns: Map> = new Map(); + + onCleanup(() => conns.forEach((ids, obj) => + ids.forEach(id => obj.disconnect(id)) + )); + + return + + holdOnHover && + Notifications.getDefault().holdNotification(notification.id) + } onLeave={() => holdOnHover && + Notifications.getDefault().releaseNotification(notification.id) + } + /> + + gesture.get_current_button() === Gdk.BUTTON_PRIMARY && + actionClicked?.(notification) + } /> + + { + const icon = getSymbolicIcon(notification.appIcon ?? notification.appName) ?? + getSymbolicIcon(notification.appName) ?? getAppIcon(notification.appName); + + if(icon) { + self.set_from_icon_name(icon); + return; + } + + self.set_visible(false); + }} /> + + + + + actionClose?.(notification)}/> + + + + {getNotificationImage(notification) && + + } + + + + + + + + + {(notification instanceof AstalNotifd.Notification) && actions && actions.length > 0 && + + { + actions.map(action => + { + notification.invoke(action.id); + actionClose?.(notification); + }} + />) + } + + } + as Gtk.Widget; +} diff --git a/src/widget/PopupWindow.tsx b/src/widget/PopupWindow.tsx new file mode 100644 index 00000000..53deeaff --- /dev/null +++ b/src/widget/PopupWindow.tsx @@ -0,0 +1,161 @@ +import { Astal, Gdk, Gtk } from "ags/gtk4"; +import { BackgroundWindow } from "./BackgroundWindow"; +import { Accessor, CCProps, createComputed, createRoot, getScope } from "ags"; +import { omitObjectKeys, WidgetNodeType } from "../modules/utils"; + +import GObject from "ags/gobject"; + + +type PopupWindowSpecificProps = { + $?: (self: Astal.Window) => void; + children?: WidgetNodeType; + /** Stylesheet for the background of the popup-window */ + cssBackgroundWindow?: string; + class?: string | Accessor; + actionClosed?: (self: Astal.Window) => void|boolean; + orientation?: Gtk.Orientation | Accessor; + actionClickedOutside?: (self: Astal.Window) => void; + actionKeyPressed?: (self: Astal.Window, keyval: number, keycode: number) => void; +}; + +export type PopupWindowProps = Pick>, + "monitor" + | "layer" + | "exclusivity" + | "marginLeft" + | "marginTop" + | "marginRight" + | "marginBottom" + | "cursor" + | "canFocus" + | "hasFocus" + | "tooltipMarkup" + | "tooltipText" + | "namespace" + | "visible" + | "widthRequest" + | "heightRequest" + | "halign" + | "valign" + | "anchor" + | "vexpand" + | "hexpand"> & PopupWindowSpecificProps; + + +const { TOP, LEFT, RIGHT, BOTTOM } = Astal.WindowAnchor; + +export function PopupWindow(props: PopupWindowProps): GObject.Object { + props.visible ??= true; + props.layer ??= Astal.Layer.OVERLAY; + props.actionClickedOutside ??= (self: Astal.Window) => self.close(); + + let clickedInside: boolean = false; + + return + `popup-window ${clss} ${namespace}`) + : props.class.as(clss => `popup-window ${clss} ${props.namespace ?? ""}`)) + : `popup-window ${props.class ?? ""} ${props.namespace ?? ""}` + } keymode={Astal.Keymode.EXCLUSIVE} exclusivity={props.exclusivity ?? Astal.Exclusivity.NORMAL} + anchor={TOP | LEFT | BOTTOM | RIGHT} visible={false} + onCloseRequest={(self) => props.actionClosed?.(self)} + $={(self) => { + const scope = getScope(); + const conns: Map = new Map(); + const gestureClick = Gtk.GestureClick.new(); + const keyController = Gtk.EventControllerKey.new(); + + self.add_controller(gestureClick); + self.add_controller(keyController); + + props.cssBackgroundWindow && createRoot((dispose) => + dispose()} + /> + ); + + props.visible && self.show(); + + conns.set(gestureClick, gestureClick.connect("released", () => { + if(clickedInside) { + clickedInside = false; + return; + } + + props.actionClickedOutside!(self); + })); + + conns.set(keyController, keyController.connect("key-pressed", (_, keyval, keycode) => { + if(keyval === Gdk.KEY_Escape) { + conns.forEach((id, obj) => { + obj.disconnect(id); + }); + + props.actionClickedOutside!(self); + return; + } + + props.actionKeyPressed?.(self, keyval, keycode); + })); + + scope.onCleanup(() => conns.forEach((id, obj) => obj.disconnect(id))); + + props.$?.(self); + }}> + + { + const conns = new Map(), + gestureClick = Gtk.GestureClick.new(); + + gestureClick.set_button(0); + + self.add_controller(gestureClick); + conns.set(gestureClick, gestureClick.connect("released", () => + clickedInside = true + )); + + conns.set(self, self.connect("destroy", () => conns.forEach((id, obj) => + obj.disconnect(id)))); + }}> + {props.children} + + + as Astal.Window; +} + +export function getPopupWindowContainer(popupWindow: Astal.Window): Gtk.Box { + return popupWindow.get_child()!.get_first_child() as Gtk.Box; +} diff --git a/src/widget/Separator.tsx b/src/widget/Separator.tsx new file mode 100644 index 00000000..0ef24046 --- /dev/null +++ b/src/widget/Separator.tsx @@ -0,0 +1,48 @@ +import { Accessor } from "ags"; +import { Gtk } from "ags/gtk4"; + + +export interface SeparatorProps { + class?: string; + alpha?: number; + cssColor?: string; + orientation?: Gtk.Orientation; + size?: number; + spacing?: number; + margin?: number; + visible?: boolean | Accessor; +} + +export function Separator(props: SeparatorProps = { + orientation: Gtk.Orientation.HORIZONTAL +}) { + props.alpha = props.alpha ? + (props.alpha > 1 ? + props.alpha / 100 + : props.alpha) + : 1; + + props.orientation = props.orientation ?? Gtk.Orientation.HORIZONTAL; + + return + + + +} diff --git a/src/window/apps-window/index.tsx b/src/window/apps-window/index.tsx new file mode 100644 index 00000000..54a3d6b0 --- /dev/null +++ b/src/window/apps-window/index.tsx @@ -0,0 +1,85 @@ +import { Astal, Gdk, Gtk } from "ags/gtk4"; +import { execApp, getAppIcon, getApps, getAstalApps } from "../../modules/apps"; +import { getPopupWindowContainer, PopupWindow } from "../../widget/PopupWindow"; + +import AstalApps from "gi://AstalApps"; +import Pango from "gi://Pango?version=1.0"; +import { createState, For } from "ags"; +import { escapeUnintendedMarkup } from "../../modules/utils"; + + +const ignoredKeys = [ + Gdk.KEY_Right, + Gdk.KEY_Down, + Gdk.KEY_Up, + Gdk.KEY_Shift_L, + Gdk.KEY_Shift_R, + Gdk.KEY_Shift_Lock, + Gdk.KEY_Left, + Gdk.KEY_Return, + Gdk.KEY_space +]; + +export const AppsWindow = (mon: number) => { + const [results, setResults] = createState(getApps() as Array); + + return { + const entry = getPopupWindowContainer(self).get_first_child()! + .get_first_child()!.get_first_child()! as Gtk.SearchEntry; + + for(const ignoredKey of ignoredKeys) + if(key === ignoredKey) return + + entry.grab_focus(); + }}> + + { + setResults(getAstalApps().fuzzy_query(self.text.trim())); + }} onStopSearch={(self) => (self.get_root() as Astal.Window)?.close()} /> + + + + + + + child.get_child()!.activate() // pass activation to button + }> + + + {(app) => + ${ + escapeUnintendedMarkup(app.description) + }` + : ""}` + } onActivate={(self) => { + execApp(app); + (self.get_root() as Astal.Window)?.close(); + }} onClicked={(self) => { + execApp(app); + (self.get_root() as Astal.Window)?.close(); + }}> + + + + + + + } + + + + + +} diff --git a/src/window/bar/index.tsx b/src/window/bar/index.tsx new file mode 100644 index 00000000..b1030041 --- /dev/null +++ b/src/window/bar/index.tsx @@ -0,0 +1,44 @@ +import { Astal, Gtk } from "ags/gtk4"; +import { Tray } from "./widgets/Tray"; +import { Workspaces } from "./widgets/Workspaces"; +import { FocusedClient } from "./widgets/FocusedClient"; +import { Apps } from "./widgets/Apps"; +import { Clock } from "./widgets/Clock"; +import { Status } from "./widgets/Status"; +import { Media } from "./widgets/Media"; + + +export const Bar = (mon: number) => { + const widgetSpacing = 4; + return + + + + + + + + + + + + + + + + + + + + + +} diff --git a/src/window/bar/widgets/Apps.tsx b/src/window/bar/widgets/Apps.tsx new file mode 100644 index 00000000..fcf8ceaf --- /dev/null +++ b/src/window/bar/widgets/Apps.tsx @@ -0,0 +1,13 @@ +import { Gtk } from "ags/gtk4"; +import { Windows } from "../../../windows"; +import { createBinding } from "ags"; +import { tr } from "../../../i18n/intl"; + + +export const Apps = () => + + `apps ${Object.hasOwn(openWindows, "apps-window") ? "open" : ""}` + )} iconName={"applications-other-symbolic"} halign={Gtk.Align.CENTER} + hexpand tooltipText={tr("apps")} onClicked={() => + Windows.getDefault().open("apps-window")} + />; diff --git a/src/window/bar/widgets/Clock.tsx b/src/window/bar/widgets/Clock.tsx new file mode 100644 index 00000000..34a128bb --- /dev/null +++ b/src/window/bar/widgets/Clock.tsx @@ -0,0 +1,21 @@ +import { Gtk } from "ags/gtk4"; +import { Windows } from "../../../windows"; +import { createBinding } from "ags"; +import { time } from "../../../modules/utils"; +import { generalConfig } from "../../../config"; + + +export const Clock = () => + + `clock ${wins.includes("center-window") ? "open" : ""}`)} + $={(self) => { + const conns: Array = [ + self.connect("clicked", (_) => Windows.getDefault().toggle("center-window")), + self.connect("destroy", (_) => conns.forEach(id => self.disconnect(id))) + ]; + }} + label={time((dt) => dt.format( + generalConfig.getProperty("clock.date_format", "string")) + ?? "An error occurred" + )} + />; diff --git a/src/window/bar/widgets/FocusedClient.tsx b/src/window/bar/widgets/FocusedClient.tsx new file mode 100644 index 00000000..877dded3 --- /dev/null +++ b/src/window/bar/widgets/FocusedClient.tsx @@ -0,0 +1,46 @@ +import { Gtk } from "ags/gtk4"; +import { createBinding, With } from "ags"; +import { variableToBoolean } from "../../../modules/utils"; +import { getAppIcon, getSymbolicIcon } from "../../../modules/apps"; + +import Pango from "gi://Pango?version=1.0"; +import AstalHyprland from "gi://AstalHyprland"; + + +const hyprland = AstalHyprland.get_default(); + +// Fix empty focused-client on opening a window on an empty workspace +hyprland.connect("notify::clients", () => hyprland.notify("focused-client")); + +export const FocusedClient = () => { + const focusedClient = createBinding(hyprland, "focusedClient"); + + return + + {(focusedClient) => focusedClient?.class && + + getSymbolicIcon(clss) ?? getAppIcon(clss) ?? + getAppIcon(focusedClient.initialClass) ?? + "application-x-executable-symbolic" + )} vexpand + /> + + + + + + + + } + + ; +} diff --git a/src/window/bar/widgets/Media.tsx b/src/window/bar/widgets/Media.tsx new file mode 100644 index 00000000..f65c3cc3 --- /dev/null +++ b/src/window/bar/widgets/Media.tsx @@ -0,0 +1,130 @@ +import { createBinding, With } from "ags"; +import { Gtk } from "ags/gtk4"; +import { Separator } from "../../../widget/Separator"; +import { Windows } from "../../../windows"; +import { Clipboard } from "../../../modules/clipboard"; +import { getPlayerIconFromBusName, secureBaseBinding, variableToBoolean } from "../../../modules/utils"; +import { tr } from "../../../i18n/intl"; +import { default as Player } from "../../../modules/media"; + +import AstalMpris from "gi://AstalMpris"; +import Pango from "gi://Pango?version=1.0"; + + +export const Media = () => + p.available)}> + { + self.set_flags(Gtk.EventControllerScrollFlags.VERTICAL) + }} onScroll={(_, __, dy) => { + if(AstalMpris.get_default().players.length === 1 && + Player.getDefault().player.busName === AstalMpris.get_default().players[0].busName) + return true; + + const players = AstalMpris.get_default().players; + + for(let i = 0; i < players.length; i++) { + const pl = players[i]; + + if(pl.busName !== Player.getDefault().player.busName) + continue; + + if(dy > 0 && players[i-1]) { + Player.getDefault().player = players[i-1]; + break; + } + + if(dy < 0 && players[i+1]) { + Player.getDefault().player = players[i+1]; + break; + } + } + + return true; + }} + /> + Windows.getDefault().toggle("center-window")} /> + { + const revealer = self.get_widget()!.get_last_child() as Gtk.Revealer; + revealer.set_reveal_child(true); + }} onLeave={(self) => { + const revealer = self.get_widget()!.get_last_child() as Gtk.Revealer; + revealer.set_reveal_child(false); + }} + /> + p.available)}> + + p.available)}> + + {(available: boolean) => available && + (createBinding( + Player.getDefault(), "player" + ), "busName", "org.MediaPlayer2.folder-music-symbolic").as( + getPlayerIconFromBusName + )} + /> + (createBinding( + Player.getDefault(), "player" + ), "title", "").as(title => title ?? tr("media.no_title"))} + maxWidthChars={20} ellipsize={Pango.EllipsizeMode.END} + /> + + (createBinding( + Player.getDefault(), "player" + ), "artist", "").as(artist => artist ?? tr("media.no_artist"))} + maxWidthChars={18} ellipsize={Pango.EllipsizeMode.END} + /> + } + + + + + p.available)}> + + {(available: boolean) => available && + + { + const url = Player.getMediaUrl(Player.getDefault().player); + url && Clipboard.getDefault().copyAsync(url); + }} + /> + + + + Player.getDefault().player.canGoPrevious && + Player.getDefault().player.previous() + } + /> + ( + createBinding(Player.getDefault(), "player"), + "playbackStatus", + AstalMpris.PlaybackStatus.PAUSED + ).as(status => status === AstalMpris.PlaybackStatus.PAUSED ? + "media-playback-start-symbolic" + : "media-playback-pause-symbolic" + )} + tooltipText={secureBaseBinding( + createBinding(Player.getDefault(), "player"), + "playbackStatus", + AstalMpris.PlaybackStatus.PAUSED + ).as(status => status === AstalMpris.PlaybackStatus.PAUSED ? + tr("media.play") : tr("media.pause") + )} onClicked={() => Player.getDefault().player.play_pause()} + /> + Player.getDefault().player.canGoNext && + Player.getDefault().player.next()} + /> + + } + + + as Gtk.Box; diff --git a/src/window/bar/widgets/Status.tsx b/src/window/bar/widgets/Status.tsx new file mode 100644 index 00000000..554452be --- /dev/null +++ b/src/window/bar/widgets/Status.tsx @@ -0,0 +1,131 @@ +import { Gtk } from "ags/gtk4"; +import { Wireplumber } from "../../../modules/volume"; +import { Notifications } from "../../../modules/notifications"; +import { Windows } from "../../../windows"; +import { Recording } from "../../../modules/recording"; +import { Accessor, createBinding, createComputed, With } from "ags"; +import { variableToBoolean } from "../../../modules/utils"; +import { Bluetooth } from "../../../modules/bluetooth"; + +import GObject from "ags/gobject"; +import AstalBluetooth from "gi://AstalBluetooth"; +import AstalNetwork from "gi://AstalNetwork"; +import AstalWp from "gi://AstalWp"; + + +export const Status = () => + + openWins.includes("control-center") ? "open status" : "status")} + onClicked={() => Windows.getDefault().toggle("control-center")}> + + + + + !Wireplumber.getDefault().isMutedSink() && + Wireplumber.getDefault().getSinkVolume() > 0 ? + icon + : "audio-volume-muted-symbolic") + } /> + + + !Wireplumber.getDefault().isMutedSource() && + Wireplumber.getDefault().getSourceVolume() > 0 ? + icon + : "microphone-sensitivity-muted-symbolic") + } /> + + + + + + + + + + + + as Gtk.Button; + +function VolumeStatus(props: { class?: string, endpoint: AstalWp.Endpoint, icon?: (string|Accessor) }) { + return { + const conns: Map = new Map(); + const controllerScroll = Gtk.EventControllerScroll.new(Gtk.EventControllerScrollFlags.VERTICAL + | Gtk.EventControllerScrollFlags.KINETIC); + + conns.set(controllerScroll, controllerScroll.connect("scroll", (_, _dx, dy) => { + console.log`Scrolled! dx: ${_dx}; dy: ${dy}`; + dy > 0 ? + Wireplumber.getDefault().decreaseEndpointVolume(props.endpoint, 5) + : Wireplumber.getDefault().increaseEndpointVolume(props.endpoint, 5); + + return true; + })); + + conns.set(self, self.connect("destroy", () => conns.forEach((id, obj) => + obj.disconnect(id)))); + }}> + + {props.icon && } + + `${Math.floor(vol * 100)}%`)} /> + as Gtk.Box; +} + +function StatusIcons() { + return + { + return powered ? ( + connected ? + "bluetooth-active-symbolic" + : "bluetooth-symbolic" + ) : "bluetooth-disabled-symbolic" + })} class={"bluetooth state"} visible={ + createBinding(Bluetooth.getDefault(), "adapter").as(Boolean) + } + /> + + + primary !== AstalNetwork.Primary.UNKNOWN)}> + + + {(primary: AstalNetwork.Primary) => { + let device: AstalNetwork.Wifi|AstalNetwork.Wired; + switch(primary) { + case AstalNetwork.Primary.WIRED: + device = AstalNetwork.get_default().wired; + break; + case AstalNetwork.Primary.WIFI: + device = AstalNetwork.get_default().wifi; + break; + + default: + return ; + } + + return ; + }} + + + + + dnd ? + "minus-circle-filled-symbolic" + : "preferences-system-notifications-symbolic") + } + /> + + + +} diff --git a/src/window/bar/widgets/Tray.tsx b/src/window/bar/widgets/Tray.tsx new file mode 100644 index 00000000..16defaef --- /dev/null +++ b/src/window/bar/widgets/Tray.tsx @@ -0,0 +1,58 @@ +import { createBinding, createComputed, For, With } from "ags"; +import { Gdk, Gtk } from "ags/gtk4"; +import { variableToBoolean } from "../../../modules/utils"; + +import GObject from "gi://GObject?version=2.0"; +import AstalTray from "gi://AstalTray" +import Gio from "gi://Gio?version=2.0"; + + +const astalTray = AstalTray.get_default(); + +export const Tray = () => { + const items = createBinding(astalTray, "items").as(items => items.filter(item => item?.gicon)); + + return + + {(item: AstalTray.TrayItem) => + + {([actionGroup, menuModel]: [Gio.ActionGroup, Gio.MenuModel]) => { + const popover = Gtk.PopoverMenu.new_from_model(menuModel); + popover.insert_action_group("dbusmenu", actionGroup); + popover.hasArrow = false; + + return { + const conns: Map = new Map(); + const gestureClick = Gtk.GestureClick.new(); + gestureClick.set_button(0); + + self.add_controller(gestureClick); + + conns.set(gestureClick, gestureClick.connect("released", (gesture, _, x, y) => { + if(gesture.get_current_button() === Gdk.BUTTON_PRIMARY) { + item.activate(x, y); + return; + } + + if(gesture.get_current_button() === Gdk.BUTTON_SECONDARY) { + item.about_to_show(); + popover.popup(); + } + })) + }}> + + {popover} + ; + }} + + } + + +} diff --git a/src/window/bar/widgets/Workspaces.tsx b/src/window/bar/widgets/Workspaces.tsx new file mode 100644 index 00000000..4b2b8598 --- /dev/null +++ b/src/window/bar/widgets/Workspaces.tsx @@ -0,0 +1,144 @@ +import { Gtk } from "ags/gtk4"; +import { getAppIcon, getSymbolicIcon } from "../../../modules/apps"; +import { Separator } from "../../../widget/Separator"; +import { generalConfig } from "../../../config"; +import { createBinding, createComputed, createState, For, With } from "ags"; +import { variableToBoolean } from "../../../modules/utils"; + +import AstalHyprland from "gi://AstalHyprland"; + + +const [showNumbers, setShowNumbers] = createState(false); +export const showWorkspaceNumber = (show: boolean) => + setShowNumbers(show); + + +export const Workspaces = () => { + const workspaces = createBinding(AstalHyprland.get_default(), "workspaces"), + defaultWorkspaces = workspaces.as(wss => + wss.filter(ws => ws.id > 0).sort((a, b) => a.id - b.id)), + specialWorkspaces = workspaces.as(wss => + wss.filter(ws => ws.id < 0).sort((a, b) => a.id - b.id)), + focusedWorkspace = createBinding(AstalHyprland.get_default(), "focusedWorkspace"); + + + return wss.length <= 1), + generalConfig.bindProperty("workspaces.hide_if_single", "boolean") + ], (hideable, enabled) => enabled && hideable ? false : true + )}> + + + {(ws: AstalHyprland.Workspace) => + { + name = name.replace(/^special\:/, ""); + return name.charAt(0).toUpperCase().concat(name.substring(1, name.length)); + })} onClicked={() => AstalHyprland.get_default().dispatch( + "togglespecialworkspace", ws.name.replace(/^special[:]/, "") + )}> + + + {(lastClient: AstalHyprland.Client|null) => lastClient && + + getSymbolicIcon(initialClass) ?? getAppIcon(initialClass) ?? + "application-x-executable-symbolic")} + /> + } + + + } + + + + + + + + self.set_flags(Gtk.EventControllerScrollFlags.VERTICAL)} + onScroll={(_, __, dy) => { + dy > 0 ? + AstalHyprland.get_default().dispatch("workspace", "e-1") + : AstalHyprland.get_default().dispatch("workspace", "e+1"); + + return true; + }} + /> + setShowNumbers(true)} + onLeave={() => setShowNumbers(false)} + /> + + {(ws: AstalHyprland.Workspace, i) => { + const showId = createComputed([ + generalConfig.bindProperty("workspaces.always_show_id", "boolean").as(Boolean), + generalConfig.bindProperty("workspaces.enable_helper", "boolean").as(Boolean), + showNumbers, + i + ], (alwaysShowIds, enableHelper, showIds, i) => { + if(enableHelper && !alwaysShowIds) { + const previousWorkspace = defaultWorkspaces.get()[i-1]; + const nextWorkspace = defaultWorkspaces.get()[i+1]; + + if((defaultWorkspaces.get().filter((_, ii) => ii < i).length > 0 && + previousWorkspace?.id < (ws.id-1)) || + (defaultWorkspaces.get().filter((_, ii) => ii > i).length > 0 && + nextWorkspace?.id > (ws.id+1)) + || (i === 0 && ws.id > 1)) { + + return true; + } + } + + return alwaysShowIds || showIds; + }); + + return + `workspace ${focusedWs.id === ws.id ? "focus" : ""} ${ + showWsNumbers ? "show" : ""}` + )} tooltipText={createComputed([ + createBinding(ws, "lastClient"), + createBinding(AstalHyprland.get_default(), "focusedWorkspace") + ], (lastClient, focusWs) => focusWs.id === ws.id ? "" : + `workspace ${ws.id}${ lastClient ? ` - ${ + !lastClient.title.toLowerCase().includes(lastClient.class) ? + `${lastClient.get_class()}: ` + : "" + } ${lastClient.title}` : "" }` + )} onClicked={() => focusedWorkspace.get()?.id !== ws.id && ws.focus()}> + + + {(lastClient: AstalHyprland.Client) => + + fws.id !== ws.id ? + Gtk.RevealerTransitionType.SLIDE_LEFT + : Gtk.RevealerTransitionType.SLIDE_RIGHT + )}> + + + {lastClient && + getSymbolicIcon(initialClass) ?? getAppIcon(initialClass) ?? + "application-x-executable-symbolic")} + hexpand vexpand visible={createBinding(AstalHyprland.get_default(), "focusedWorkspace") + .as(fws => fws.id !== ws.id)} + />} + + } + + + }} + + + +} diff --git a/src/window/center-window/index.tsx b/src/window/center-window/index.tsx new file mode 100644 index 00000000..0c0be749 --- /dev/null +++ b/src/window/center-window/index.tsx @@ -0,0 +1,45 @@ +import { Gdk, Gtk } from "ags/gtk4"; +import { Separator } from "../../widget/Separator"; +import { PopupWindow } from "../../widget/PopupWindow"; +import { BigMedia } from "./widgets/BigMedia"; +import { time, variableToBoolean } from "../../modules/utils"; +import { createBinding } from "ags"; + +import Media from "../../modules/media"; +import AstalMpris from "gi://AstalMpris"; + + +export const CenterWindow = (mon: number) => + { + if(keyval === Gdk.KEY_space) { + Media.getDefault().player.available && + Media.getDefault().player.play_pause(); + return true; + } + }}> + + + + + + t.format("%H:%M")!)} /> + d.format("%A, %B %d")!)} /> + + + + + + + + + + ; diff --git a/src/window/center-window/widgets/BigMedia.tsx b/src/window/center-window/widgets/BigMedia.tsx new file mode 100644 index 00000000..f2a4a7ad --- /dev/null +++ b/src/window/center-window/widgets/BigMedia.tsx @@ -0,0 +1,235 @@ +import { createBinding, For } from "ags"; +import { register } from "ags/gobject"; +import { Astal, Gtk } from "ags/gtk4"; +import { Clipboard } from "../../../modules/clipboard"; +import { pathToURI, variableToBoolean } from "../../../modules/utils"; +import { tr } from "../../../i18n/intl"; + +import Media from "../../../modules/media"; +import AstalMpris from "gi://AstalMpris"; +import Pango from "gi://Pango?version=1.0"; +import Adw from "gi://Adw?version=1"; +import GLib from "gi://GLib?version=2.0"; + + +export const BigMedia = () => { + const availablePlayers = createBinding(AstalMpris.get_default(), "players").as(pls => + pls.filter(p => p.available)); + + const carousel = { + const page = self.get_nth_page(num); + if(page instanceof PlayerWidget && Media.getDefault().player.busName !== page.player.busName) + Media.getDefault().player = page.player; + }}> + players.sort(pl => + pl.busName === Media.getDefault().player.busName ? -1 : 1))}> + + {(player: AstalMpris.Player) => } + + as Adw.Carousel; + + return + + {carousel} + pls.length > 1)} transitionDuration={300} + transitionType={Gtk.RevealerTransitionType.SLIDE_UP}> + + + + as Gtk.Box; +} + +@register({ GTypeName: "PlayerWidget" }) +class PlayerWidget extends Gtk.Box { + #player!: AstalMpris.Player; + #copyClickTimeout?: GLib.Source; + #dragTimer?: GLib.Source; + + get player() { return this.#player; } + + constructor({ player }: { player: AstalMpris.Player }) { + super(); + + this.setPlayer(player); + this.set_orientation(Gtk.Orientation.VERTICAL); + this.set_hexpand(true); + + this.append( + + + + `background-image: url("${pathToURI(art)}");`)} + hexpand={false} vexpand={false} widthRequest={132} heightRequest={128} + valign={Gtk.Align.START} halign={Gtk.Align.CENTER} + /> + as Gtk.Revealer + ); + + this.append( + + + title ?? tr("media.no_title")) + } label={ + createBinding(player, "title").as(title => title ?? tr("media.no_title")) + } ellipsize={Pango.EllipsizeMode.END} maxWidthChars={25} + /> + artist ?? tr("media.no_artist")) + } label={ + createBinding(player, "artist").as(artist => artist ?? tr("media.no_artist")) + } ellipsize={Pango.EllipsizeMode.END} maxWidthChars={28} + /> + as Gtk.Box + ); + + this.append( + + { + if(type == null) return; + + if(!this.#dragTimer) { + this.#dragTimer = setTimeout(() => + player.position = Math.floor(value) + , 200); + + return; + } + + this.#dragTimer?.destroy(); + this.#dragTimer = setTimeout(() => + player.position = Math.floor(value) + , 200); + }} + /> + as Gtk.Box + ); + + this.append( + + { + const sec = Math.floor(pos % 60); + return pos > 0 && player.length > 0 ? + `${Math.floor(pos / 60)}:${sec < 10 ? "0" : ""}${sec}` + : "0:00"; + })} $type="start" + /> + + + + { + const url = Media.accessMediaUrl(player).get(); + // a widget that supports adding multiple icons and allows switching + // through them would be pretty nice!! (i'll probably do this later) + url && + Clipboard.getDefault().copyAsync(url).then(() => { + if(this.#copyClickTimeout && !this.#copyClickTimeout.is_destroyed()) + this.#copyClickTimeout.destroy(); + + (self.get_child() as Gtk.Stack).set_visible_child_name("done-icon"); + this.#copyClickTimeout = setTimeout(() => { + (self.get_child() as Gtk.Stack).set_visible_child_name("copy-icon"); + this.#copyClickTimeout!.destroy(); + this.#copyClickTimeout = undefined; + }, 1100); + }).catch(() => { + if(this.#copyClickTimeout && !this.#copyClickTimeout.is_destroyed()) + this.#copyClickTimeout.destroy(); + + (self.get_child() as Gtk.Stack).set_visible_child_name("error-icon"); + this.#copyClickTimeout = setTimeout(() => { + (self.get_child() as Gtk.Stack).set_visible_child_name("copy-icon"); + this.#copyClickTimeout!.destroy(); + this.#copyClickTimeout = undefined; + }, 900); + }); + }}> + + + + as Gtk.Widget + } /> + as Gtk.Widget + } /> + as Gtk.Widget + } /> + + + + + + status !== AstalMpris.Shuffle.UNSUPPORTED)} iconName={ + createBinding(player, "shuffleStatus").as(status => status === AstalMpris.Shuffle.ON ? + "media-playlist-shuffle-symbolic" + : "media-playlist-consecutive-symbolic")} tooltipText={ + createBinding(player, "shuffleStatus").as(status => status === AstalMpris.Shuffle.ON ? + tr("media.shuffle") + : tr("media.follow_order"))} onClicked={() => player.shuffle()} + /> + player.canGoPrevious && player.previous()} + /> + + status === AstalMpris.PlaybackStatus.PLAYING ? tr("media.pause") : tr("media.play"))} + iconName={createBinding(player, "playbackStatus").as(status => + status === AstalMpris.PlaybackStatus.PLAYING ? + "media-playback-pause-symbolic" + : "media-playback-start-symbolic")} onClicked={() => player.play_pause()} + /> + player.canGoNext && player.next()} + /> + { + if(status === AstalMpris.Loop.TRACK) + return "media-playlist-repeat-song-symbolic"; + + if(status === AstalMpris.Loop.PLAYLIST) + return "media-playlist-repeat-symbolic"; + + return "loop-arrow-symbolic"; + })} visible={createBinding(player, "loopStatus").as(status => + status !== AstalMpris.Loop.UNSUPPORTED)} + tooltipText={createBinding(player, "loopStatus").as(status => { + if(status === AstalMpris.Loop.TRACK) + return tr("media.song_loop"); + + if(status === AstalMpris.Loop.PLAYLIST) + return tr("media.loop"); + + return tr("media.no_loop"); + })} onClicked={() => player.loop()} + /> + + + { /* bananananananana */ + const sec = Math.floor(len % 60); + return (len > 0 && Number.isFinite(len)) ? + `${Math.floor(len / 60)}:${sec < 10 ? "0" : ""}${sec}` + : "0:00"; + })} $type="end" + /> + as Gtk.CenterBox + ); + } + + setPlayer(player: AstalMpris.Player) { + this.#player = player; + } +} diff --git a/src/window/control-center/index.tsx b/src/window/control-center/index.tsx new file mode 100644 index 00000000..41955d32 --- /dev/null +++ b/src/window/control-center/index.tsx @@ -0,0 +1,25 @@ +import { Astal, Gtk } from "ags/gtk4"; +import { PopupWindow } from "../../widget/PopupWindow"; +import { QuickActions } from "./widgets/QuickActions"; +import { NotifHistory } from "./widgets/NotifHistory"; +import { Tiles } from "./widgets/tiles"; +import { Sliders } from "./widgets/Sliders"; + + +export const ControlCenter = (mon: number) => + + + + + + + + + + + + as Astal.Window; diff --git a/src/window/control-center/widgets/NotifHistory.tsx b/src/window/control-center/widgets/NotifHistory.tsx new file mode 100644 index 00000000..f0478515 --- /dev/null +++ b/src/window/control-center/widgets/NotifHistory.tsx @@ -0,0 +1,50 @@ +import { Gtk } from "ags/gtk4"; +import { HistoryNotification, Notifications } from "../../../modules/notifications"; +import { NotificationWidget } from "../../../widget/Notification"; +import { tr } from "../../../i18n/intl"; +import { createBinding, For } from "ags"; + +import AstalNotifd from "gi://AstalNotifd"; + + +export const NotifHistory = () => + + `notif-history ${history.length < 1 ? "hide" : ""}`)} vexpand={false}> + + { + if(!(self.get_child()! as Gtk.Viewport).get_child()) return; + + self.minContentHeight = + ((self.get_child()! as Gtk.Viewport).get_child() as Gtk.Box + ).get_first_child()!.get_allocation().height + || 0; + }}> + + + + + {(notif: AstalNotifd.Notification|HistoryNotification) => + Notifications.getDefault().removeHistory(n.id)} + actionClicked={(n) => Notifications.getDefault().removeHistory(n.id)} + />} + + + + + + Notifications.getDefault().clearHistory()}> + + + + + + + + as Gtk.Box; diff --git a/src/window/control-center/widgets/Page.tsx b/src/window/control-center/widgets/Page.tsx new file mode 100644 index 00000000..369235fb --- /dev/null +++ b/src/window/control-center/widgets/Page.tsx @@ -0,0 +1,176 @@ +import { Gtk } from "ags/gtk4"; +import { Separator } from "../../../widget/Separator"; +import { Accessor, createRoot } from "ags"; +import { transformWidget, variableToBoolean, WidgetNodeType } from "../../../modules/utils"; + +import Pango from "gi://Pango?version=1.0"; + + +export type PageProps = { + id: string; + title: string; + description?: string; + $?: (self: Gtk.Box) => void; + headerButtons?: Array | Accessor>; + bottomButtons?: Array | Accessor>; + orientation?: Gtk.Orientation | Accessor; + spacing?: number | Accessor; + content: () => WidgetNodeType; + actionClosed?: () => void; +}; + +export type BottomButton = { + title: string | Accessor; + description?: string | Accessor; + tooltipText?: string | Accessor; + tooltipMarkup?: string | Accessor; + actionClicked?: () => void; +}; + +export type HeaderButton = { + label?: string|Accessor; + icon: string|Accessor; + tooltipText?: string | Accessor; + tooltipMarkup?: string | Accessor; + actionClicked?: () => void; +}; + +export class Page { + #title: string; + #description?: string; + #orientation: Gtk.Orientation|Accessor< + Gtk.Orientation> = Gtk.Orientation.VERTICAL; + #spacing: number|Accessor = 4; + #headerButtons?: Array|Accessor>; + #bottomButtons?: Array|Accessor>; + #setup?: (self: Gtk.Box) => void; + readonly #id?: string; + readonly #create: () => WidgetNodeType; + + public get id() { return this.#id; } + public get title() { return this.#title; } + public get description() { return this.#description; } + public get headerButtons() { return this.#headerButtons; } + public get bottomButtons() { return this.#bottomButtons; } + public readonly actionClosed?: () => void; + + constructor(props: PageProps) { + this.#id = props.id; + this.#title = props.title; + this.#description = props.description; + this.#create = props.content; + this.actionClosed = props.actionClosed; + + if(props.orientation != null) + this.#orientation = props.orientation; + + if(props.spacing != null) + this.#spacing = props.spacing; + + if(props.headerButtons != null) + this.#headerButtons = props.headerButtons; + + if(props.$ != null) + this.#setup = props.$; + } + + public create(): Gtk.Box { + return createRoot((dispose) => + dispose()} + $={this.#setup}> + + + + + + + + + + + {this.#headerButtons && transformWidget(this.#headerButtons, (button) => + button.actionClicked?.()} + tooltipText={button.tooltipText} tooltipMarkup={button.tooltipMarkup} + /> + )} + + + + + + {this.#create()} + + + + + {this.#bottomButtons && transformWidget(this.#bottomButtons, (button) => + button?.actionClicked?.()} tooltipText={button?.tooltipText} + tooltipMarkup={button?.tooltipMarkup}> + + + + + )} + + as Gtk.Box + ); + } + + public static getContent(pageWidget: Gtk.Box) { + return pageWidget.get_first_child()!.get_next_sibling()! as Gtk.Box; + } +} + +export function PageButton({ onUnmap, ...props }: { + class?: string | Accessor; + icon?: string | Accessor; + title: string | Accessor; + endWidget?: WidgetNodeType; + description?: string | Accessor; + extraButtons?: Array | WidgetNodeType; + maxWidthChars?: number | Accessor; + onUnmap?: (self: Gtk.Box) => void; + actionClicked?: (self: Gtk.Button) => void; + tooltipText?: string | Accessor; + tooltipMarkup?: string | Accessor; +}): Gtk.Box { + return onUnmap?.(self)} class={"page-button"}> + + + + {props.icon && } + + + + + + + + {props.endWidget && props.endWidget} + + + + + + {props.extraButtons} + + as Gtk.Box; +} diff --git a/src/window/control-center/widgets/QuickActions.tsx b/src/window/control-center/widgets/QuickActions.tsx new file mode 100644 index 00000000..869316ba --- /dev/null +++ b/src/window/control-center/widgets/QuickActions.tsx @@ -0,0 +1,91 @@ +import { Gtk } from "ags/gtk4"; +import { Windows } from "../../../windows"; +import { Wallpaper } from "../../../modules/wallpaper"; +import { execApp } from "../../../modules/apps"; +import { Accessor } from "ags"; +import { createPoll } from "ags/time"; + +import GLib from "gi://GLib?version=2.0"; +import Gio from "gi://Gio?version=2.0"; + + +const userFace: Gio.File = Gio.File.new_for_path(`${GLib.get_home_dir()}/.face`); +const uptime: Accessor = createPoll("Just turned on", 1000, "uptime -p"); + +function LockButton(): Gtk.Button { + return { + Windows.getDefault().close("control-center"); + execApp("hyprlock"); + }} + /> as Gtk.Button; +} + +function ColorPickerButton(): Gtk.Button { + return { + Windows.getDefault().close("control-center"); + execApp("sh $HOME/.config/hypr/scripts/color-picker.sh"); + }} + /> as Gtk.Button; +} + +function ScreenshotButton(): Gtk.Button { + return { + Windows.getDefault().close("control-center"); + execApp(`sh ${GLib.get_user_config_dir()}/hypr/scripts/screenshot.sh`); + }} + /> as Gtk.Button; +} + +function SelectWallpaperButton(): Gtk.Button { + return { + Windows.getDefault().close("control-center"); + Wallpaper.getDefault().pickWallpaper(); + }} + /> as Gtk.Button; +} + +function LogoutButton(): Gtk.Button { + return { + Windows.getDefault().close("control-center"); + Windows.getDefault().open("logout-menu"); + }} + /> as Gtk.Button; +} + +export const QuickActions = () => + + + {userFace.query_exists(null) && + + } + + + + + + + + + str.replace(/^up /, ""))} /> + + + + + + + + + + + + as Gtk.Box; diff --git a/src/window/control-center/widgets/Sliders.tsx b/src/window/control-center/widgets/Sliders.tsx new file mode 100644 index 00000000..06180bb8 --- /dev/null +++ b/src/window/control-center/widgets/Sliders.tsx @@ -0,0 +1,76 @@ +import { Astal, Gtk } from "ags/gtk4"; +import { Wireplumber } from "../../../modules/volume"; +import { Pages } from "./pages"; +import { PageSound } from "./pages/Sound"; +import { PageMicrophone } from "./pages/Microphone"; +import { createBinding, With } from "ags"; +import { Backlights } from "../../../modules/backlight"; +import { PageBacklight } from "./pages/Backlight"; + +import AstalWp from "gi://AstalWp"; + + +export let slidersPages: Pages|undefined; + +export function Sliders() { + return slidersPages = undefined}> + + + {(sink: AstalWp.Endpoint) => + Wireplumber.getDefault().toggleMuteSink()} + iconName={createBinding(sink, "volumeIcon").as((icon) => + (!Wireplumber.getDefault().isMutedSink() && + Wireplumber.getDefault().getSinkVolume() > 0 + ) ? icon : "audio-volume-muted-symbolic" + )} /> + + sink.set_volume(value)} /> + + + slidersPages?.toggle(PageSound)} /> + } + + + {(source: AstalWp.Endpoint) => + Wireplumber.getDefault().toggleMuteSource()} + iconName={createBinding(source, "volumeIcon").as((icon) => + (!Wireplumber.getDefault().isMutedSource() && + Wireplumber.getDefault().getSourceVolume() > 0 + ) ? icon : "microphone-sensitivity-muted-symbolic" + )} /> + + source.set_volume(value)} /> + + + slidersPages?.toggle(PageMicrophone)} /> + } + + + + {(bklight: Backlights.Backlight|null) => bklight && + + { + bklight.brightness = bklight.maxBrightness + }} iconName={"display-brightness-symbolic"} + /> + + { + bklight.brightness = value + }} + /> + + slidersPages?.toggle(PageBacklight)} /> + + } + + + slidersPages = self} /> + +} diff --git a/src/window/control-center/widgets/pages/Backlight.tsx b/src/window/control-center/widgets/pages/Backlight.tsx new file mode 100644 index 00000000..02f61751 --- /dev/null +++ b/src/window/control-center/widgets/pages/Backlight.tsx @@ -0,0 +1,84 @@ +import { Astal, Gtk } from "ags/gtk4"; +import { tr } from "../../../../i18n/intl"; +import { Backlights } from "../../../../modules/backlight"; +import { Page, PageButton } from "../Page"; +import { createBinding, For, With } from "ags"; +import { addSliderMarksFromMinMax } from "../../../../modules/utils"; +import { userData } from "../../../../config"; + + +export const PageBacklight = new Page({ + id: "backlight", + title: tr("control_center.pages.backlight.title"), + description: tr("control_center.pages.backlight.description"), + $: () => { + const dataDefaultBacklight = userData.getProperty("control_center.default_backlight", "any"); + if(typeof dataDefaultBacklight === "string" && + Backlights.getDefault().default?.name !== dataDefaultBacklight) { + + const bk = Backlights.getDefault().backlights.filter(b => b.name === dataDefaultBacklight)[0]; + if(!bk) return; + + Backlights.getDefault().setDefault(bk); + } + }, + content: () => ( + + {(bklights: Array) => bklights.length > 0 && + + bklights.length > 1)}> + + + + {(bk: Backlights.Backlight) => + is ? "highlight" : "")} + title={bk.name} + icon={"video-display-symbolic"} + actionClicked={() => { + if(Backlights.getDefault().default?.path !== bk.path) { + Backlights.getDefault().setDefault(bk); + // save data + userData.setProperty( + "control_center.default_backlight", + bk.name, + true + ); + } + }} + endWidget={ + + } + /> + } + + + + {bklights.map((bklight, i) => + + + + addSliderMarksFromMinMax(self)} + min={0} max={bklight.maxBrightness} + value={createBinding(bklight, "brightness")} + onChangeValue={(_, __, value) => { + bklight.brightness = value + }} + /> + + )} + + + } + + ), + headerButtons: [{ + icon: "arrow-circular-top-right", + tooltipText: tr("control_center.pages.backlight.refresh"), + actionClicked: () => Backlights.getDefault().scan() + }] +}); diff --git a/src/window/control-center/widgets/pages/Bluetooth.tsx b/src/window/control-center/widgets/pages/Bluetooth.tsx new file mode 100644 index 00000000..eb9b0a08 --- /dev/null +++ b/src/window/control-center/widgets/pages/Bluetooth.tsx @@ -0,0 +1,213 @@ +import { Gtk } from "ags/gtk4"; +import { Page, PageButton } from "../Page"; +import { tr } from "../../../../i18n/intl"; +import { Windows } from "../../../../windows"; +import { Notifications } from "../../../../modules/notifications"; +import { execApp } from "../../../../modules/apps"; +import { createBinding, createComputed, For, With } from "ags"; +import { Bluetooth } from "../../../../modules/bluetooth"; + +import AstalNotifd from "gi://AstalNotifd"; +import AstalBluetooth from "gi://AstalBluetooth"; +import Adw from "gi://Adw?version=1"; +import Gio from "gi://Gio?version=2.0"; + + +export const BluetoothPage = new Page({ + id: "bluetooth", + title: tr("control_center.pages.bluetooth.title"), + spacing: 6, + description: tr("control_center.pages.bluetooth.description"), + headerButtons: createBinding(Bluetooth.getDefault(), "adapter").as(adapter => adapter ? [{ + icon: createBinding(adapter, "discovering") + .as(discovering => !discovering ? + "arrow-circular-top-right-symbolic" + : "media-playback-stop-symbolic" + ), + tooltipText: createBinding(adapter, "discovering") + .as((discovering) => !discovering ? + tr("control_center.pages.bluetooth.start_discovering") + : tr("control_center.pages.bluetooth.stop_discovering")), + actionClicked: () => { + if(adapter.discovering) { + adapter.stop_discovery(); + return; + } + + adapter.start_discovery(); + } + }]: []), + actionClosed: () => Bluetooth.getDefault().adapter?.discovering && + Bluetooth.getDefault().adapter?.stop_discovery(), + bottomButtons: [{ + title: tr("control_center.pages.more_settings"), + actionClicked: () => { + Windows.getDefault().close("control-center"); + execApp("overskride", "[float; animation slide right]"); + } + }], + content: () => { + const adapter = createBinding(Bluetooth.getDefault(), "adapter"); + const adapters = createBinding(AstalBluetooth.get_default(), "adapters"); + const devices = createBinding(AstalBluetooth.get_default(), "devices"); + + return [ + adptrs.length > 1) + } spacing={2} orientation={Gtk.Orientation.VERTICAL}> + + + adpts.length > 1)}> + {(hasMoreAdapters: boolean) => hasMoreAdapters && + + + {(adapter: AstalBluetooth.Adapter) => { + const isSelected = createBinding(Bluetooth.getDefault(), "adapter").as(a => + adapter.address === a?.address); + + return is ? "selected" : "")} + title={adapter.alias ?? "Adapter"} icon={"bluetooth-active-symbolic"} + description={createBinding(adapter, "address")} + actionClicked={() => { + if(adapter.address !== Bluetooth.getDefault().adapter?.address) + Bluetooth.getDefault().adapter = adapter; + }} + endWidget={ + + } + />; + }} + + + } + + , + + + devs.filter(dev => + (dev.adapter as AstalBluetooth.Adapter).address === adapter.get()?.address && + dev.paired || dev.connected || dev.trusted).length > 0) + }> + + + devs.filter(dev => + (dev.adapter as AstalBluetooth.Adapter).address === adapter.get()?.address && + dev.paired || dev.connected || dev.trusted)) + }> + + {(dev: AstalBluetooth.Device) => } + + + devs.filter(dev => + (dev.adapter as AstalBluetooth.Adapter).address === adapter.get()?.address && + !dev.connected && !dev.paired && !dev.trusted).length > 0) + }> + + + devs.filter(dev => + (dev.adapter as AstalBluetooth.Adapter).address === adapter.get()?.address && + !dev.connected && !dev.paired && !dev.trusted)) + }> + + {(dev: AstalBluetooth.Device) => } + + + + ]; + } +}); + +function DeviceWidget({ device }: { device: AstalBluetooth.Device }): Gtk.Widget { + const pair = async () => { + if(device.paired) return; + + device.pair(); + device.set_trusted(true); + }; + + return + conn ? "selected" : "")} title={ + createBinding(device, "alias").as(alias => alias ?? "Unknown Device")} + icon={createBinding(device, "icon").as(ico => ico ?? "bluetooth-active-symbolic")} + tooltipText={ + createBinding(device, "connected").as(connected => + !connected ? tr("connect") : "") + } actionClicked={() => { + if(device.connected) return; + + pair().then(() => { + device.connect_device((_, res) => { + + // get error + try { device.connect_device_finish(res); } + catch(e: any) { + Notifications.getDefault().sendNotification({ + appName: "bluetooth", + summary: "Connection Error", + body: `An error occurred while attempting to connect to ${ + device.alias ?? device.name}: ${(e as Gio.IOErrorEnum).message}` + }); + } + }); + }).catch((err: Gio.IOErrorEnum) => + Notifications.getDefault().sendNotification({ + appName: "bluetooth", + summary: "Pairing Error", + body: `Couldn't pair with ${device.alias ?? device.name}: ${err.message}`, + urgency: AstalNotifd.Urgency.NORMAL + }) + ); + }} + endWidget={ + + connected && (batt > -1)) + } spacing={4}> + + `${Math.floor(batt * 100)}%`) + } visible={createBinding(device, "connected")} + /> + + + `battery-level-${Math.floor(batt * 100)}-symbolic`) + } css={"font-size: 16px; margin-left: 6px;"} /> + + } extraButtons={ + {([connected, trusted]: [boolean, boolean]) => + + { { + if(!connected) { + Bluetooth.getDefault().adapter?.remove_device(device); + return; + } + + device.disconnect_device(null); + }} />} + + device.set_trusted(!trusted)} + /> + + } + } + /> as Gtk.Widget; +} diff --git a/src/window/control-center/widgets/pages/Microphone.tsx b/src/window/control-center/widgets/pages/Microphone.tsx new file mode 100644 index 00000000..455bd215 --- /dev/null +++ b/src/window/control-center/widgets/pages/Microphone.tsx @@ -0,0 +1,34 @@ +import { Page, PageButton } from "../Page"; +import { Wireplumber } from "../../../../modules/volume"; +import { Gtk } from "ags/gtk4"; +import { tr } from "../../../../i18n/intl"; +import { createBinding, For } from "ags"; +import { lookupIcon } from "../../../../modules/apps"; + +import AstalWp from "gi://AstalWp?version=0.1"; + + +export const PageMicrophone = new Page({ + id: "microphone", + title: tr("control_center.pages.microphone.title"), + description: tr("control_center.pages.microphone.description"), + content: () => [ + , + + + {(source: AstalWp.Endpoint) => isDefault ? "selected" : "") + } icon={createBinding(source, "icon").as(ico => lookupIcon(ico) ? + ico : "audio-input-microphone-symbolic")} title={ + createBinding(source, "description").as(desc => desc ?? "Microphone") + } actionClicked={() => !source.isDefault && source.set_is_default(true)} + endWidget={ + + } + />} + + + ] +}); diff --git a/src/window/control-center/widgets/pages/Network.tsx b/src/window/control-center/widgets/pages/Network.tsx new file mode 100644 index 00000000..714653eb --- /dev/null +++ b/src/window/control-center/widgets/pages/Network.tsx @@ -0,0 +1,170 @@ +import { Gtk } from "ags/gtk4"; +import { Page, PageButton } from "../Page"; +import { Windows } from "../../../../windows"; +import { tr } from "../../../../i18n/intl"; +import { execApp } from "../../../../modules/apps"; +import { Notifications } from "../../../../modules/notifications"; +import { AskPopup, AskPopupProps } from "../../../../widget/AskPopup"; +import { encoder, variableToBoolean } from "../../../../modules/utils"; +import { createBinding, For, With } from "ags"; + +import GLib from "gi://GLib?version=2.0"; +import NM from "gi://NM"; +import AstalNetwork from "gi://AstalNetwork"; + + +export const PageNetwork = new Page({ + id: "network", + title: tr("control_center.pages.network.title"), + headerButtons: createBinding(AstalNetwork.get_default(), "primary").as(primary => + primary === AstalNetwork.Primary.WIFI ? [{ + icon: "arrow-circular-top-right-symbolic", + tooltipText: "Re-scan networks", + actionClicked: () => AstalNetwork.get_default().wifi.scan() + }] : [] + ), + bottomButtons: [{ + title: tr("control_center.pages.more_settings"), + actionClicked: () => { + Windows.getDefault().close("control-center"); + execApp("nm-connection-editor", "[animationstyle gnomed]"); + } + }], + content: () => [ + + + + + devs.filter(dev => dev.interface !== "lo" && dev.real /* filter local device */))}> + + {(device: NM.Device) => + iface ?? tr("control_center.pages.network.interface"))} class={"device"} + icon={createBinding(device, "deviceType").as(type => type === NM.DeviceType.WIFI ? + "network-wireless-symbolic" : "network-wired-symbolic")} extraButtons={[ + + { + Windows.getDefault().close("control-center"); + execApp( + `nm-connection-editor --edit ${device.activeConnection?.connection.get_uuid()}`, + "[animationstyle gnomed; float]" + ); + }} /> + ]} + />} + + , + + primary === AstalNetwork.Primary.WIFI)}> + + {(isWifi: boolean) => isWifi && + + + + {(ap: AstalNetwork.AccessPoint) => + activeAP.ssid === ap.ssid ? "active" : "") + } title={createBinding(ap, "ssid").as(ssid => ssid ?? "No SSID")} + icon={createBinding(ap, "iconName")} endWidget={ + // @ts-ignore + flags & NM["80211ApFlags"].PRIVACY ? + "channel-secure-symbolic" + : "channel-insecure-symbolic")} + css={"font-size: 18px;"} + />} extraButtons={[ + + activeAp.ssid === ap.ssid) + } css={"font-size: 18px;"} onClicked={() => { + const active = AstalNetwork.get_default().wifi.activeAccessPoint; + + if(active?.ssid === ap.ssid) { + AstalNetwork.get_default().wifi.deactivate_connection((_, res) => { + try { + AstalNetwork.get_default().wifi.deactivate_connection_finish(res); + } catch(e: any) { + e = e as Error; + + console.error( + `Network: couldn't deactivate connection with access point(SSID: ${ + ap.ssid}. Stderr: \n${e.message}\n${e.stack}` + ); + } + }) + } + }}/> + ]} actionClicked={() => { + const uuid = NM.utils_uuid_generate(); + const ssidBytes = GLib.Bytes.new(encoder.encode(ap.ssid)); + + const connection = NM.SimpleConnection.new(); + const connSetting = NM.SettingConnection.new(); + const wifiSetting = NM.SettingWireless.new(); + const wifiSecuritySetting = NM.SettingWirelessSecurity.new(); + const setting8021x = NM.Setting8021x.new(); + + // @ts-ignore yep, type-gen issues again + if(ap.rsnFlags !& NM["80211ApSecurityFlags"].KEY_MGMT_802_1X && + // @ts-ignore + ap.wpaFlags !& NM["80211ApSecurityFlags"].KEY_MGMT_802_1X) { + return; + } + + connSetting.uuid = uuid; + connection.add_setting(connSetting); + + connection.add_setting(wifiSetting); + wifiSetting.ssid = ssidBytes; + + wifiSecuritySetting.keyMgmt = "wpa-eap"; + connection.add_setting(wifiSecuritySetting); + + setting8021x.add_eap_method("ttls"); + setting8021x.phase2Auth = "mschapv2"; + connection.add_setting(setting8021x); + }} + />} + + } + + ] +}); + +function activateWirelessConnection(connection: NM.RemoteConnection, ssid: string): void { + AstalNetwork.get_default().get_client().activate_connection_async( + connection, AstalNetwork.get_default().wifi.get_device(), null, null, (_, asyncRes) => { + const activeConnection = AstalNetwork.get_default().get_client().activate_connection_finish(asyncRes); + if(!activeConnection) { + Notifications.getDefault().sendNotification({ + appName: "network", + summary: "Couldn't activate wireless connection", + body: `An error occurred while activating the wireless connection "${ssid}"` + }); + return; + } + } + ); +} + +function notifyConnectionError(ssid: string): void { + Notifications.getDefault().sendNotification({ + appName: "network", + summary: "Coudn't connect Wi-Fi", + body: `An error occurred while trying to connect to the "${ssid}" access point. \nMaybe the password is invalid?` + }); +} +function saveToDisk(remoteConnection: NM.RemoteConnection, ssid: string): void { + AskPopup({ + text: `Save password for connection "${ssid}"?`, + acceptText: "Yes", + onAccept: () => remoteConnection.commit_changes_async(true, null, (_, asyncRes) => + !remoteConnection.commit_changes_finish(asyncRes) && Notifications.getDefault().sendNotification({ + appName: "network", + summary: "Couldn't save Wi-Fi password", + body: `An error occurred while trying to write the password for "${ssid}" to disk` + })) + } as AskPopupProps); +} diff --git a/src/window/control-center/widgets/pages/NightLight.tsx b/src/window/control-center/widgets/pages/NightLight.tsx new file mode 100644 index 00000000..d8948784 --- /dev/null +++ b/src/window/control-center/widgets/pages/NightLight.tsx @@ -0,0 +1,43 @@ +import { Page } from "../Page"; +import { NightLight } from "../../../../modules/nightlight"; +import { tr } from "../../../../i18n/intl"; +import { Astal, Gtk } from "ags/gtk4"; +import { addSliderMarksFromMinMax } from "../../../../modules/utils"; +import { createBinding } from "ags"; + +export const PageNightLight = new Page({ + id: "night-light", + title: tr("control_center.pages.night_light.title"), + description: tr("control_center.pages.night_light.description"), + content: () => [ + , + { + self.value = NightLight.getDefault().temperature; + addSliderMarksFromMinMax(self, 5, "{}K"); + }} value={createBinding(NightLight.getDefault(), "temperature")} + tooltipText={createBinding(NightLight.getDefault(), "temperature").as(temp => + `${temp}K`)} min={NightLight.getDefault().minTemperature} + max={NightLight.getDefault().maxTemperature} + onChangeValue={(_, type, value) => { + if(type != undefined && type !== null) + NightLight.getDefault().temperature = Math.floor(value) + }} + />, + , + { + self.value = NightLight.getDefault().gamma; + addSliderMarksFromMinMax(self, 5, "{}%"); + }} value={createBinding(NightLight.getDefault(), "gamma")} + tooltipText={createBinding(NightLight.getDefault(), "gamma").as(gamma => + `${gamma}%`)} max={NightLight.getDefault().maxGamma} + onChangeValue={(_, type, value) => { + if(type != undefined && type !== null) + NightLight.getDefault().gamma = Math.floor(value) + }} + /> + ] +}); diff --git a/src/window/control-center/widgets/pages/Sound.tsx b/src/window/control-center/widgets/pages/Sound.tsx new file mode 100644 index 00000000..7bba0d70 --- /dev/null +++ b/src/window/control-center/widgets/pages/Sound.tsx @@ -0,0 +1,96 @@ +import { Page, PageButton } from "../Page"; +import { Astal, Gtk } from "ags/gtk4"; +import { getAppIcon, lookupIcon } from "../../../../modules/apps"; +import { Wireplumber } from "../../../../modules/volume"; +import { tr } from "../../../../i18n/intl"; +import { createBinding, For } from "ags"; +import { variableToBoolean } from "../../../../modules/utils"; + +import AstalWp from "gi://AstalWp"; +import GObject from "gi://GObject?version=2.0"; +import Pango from "gi://Pango?version=1.0"; + + +export const PageSound = new Page({ + id: "sound", + title: tr("control_center.pages.sound.title"), + description: tr("control_center.pages.sound.description"), + content: () => [ + , + + + {(sink: AstalWp.Endpoint) => + + isDefault ? "selected" : "")} + icon={createBinding(sink, "icon").as(ico => + lookupIcon(ico) ? ico : "audio-card-symbolic")} + title={createBinding(sink, "description").as(desc => + desc ?? "Speaker")} + actionClicked={() => !sink.isDefault && sink.set_is_default(true)} + endWidget={ + + } + />} + + , + + + + {(stream: AstalWp.Stream) => + { + const conns: Map> = new Map(); + const controllerMotion = Gtk.EventControllerMotion.new(); + + self.add_controller(controllerMotion); + + conns.set(controllerMotion, [ + controllerMotion.connect("enter", () => { + const revealer = self.get_last_child()!.get_first_child() as Gtk.Revealer; + revealer.set_reveal_child(true); + }), + controllerMotion.connect("leave", () => { + const revealer = self.get_last_child()!.get_first_child() as Gtk.Revealer; + revealer.set_reveal_child(false); + }) + ]); + + conns.set(self, [ + self.connect("destroy", () => conns.forEach((ids, obj) => + ids.forEach(id => obj.disconnect(id)) + )) + ]); + }}> + + getAppIcon(name.split(' ')[0]) ?? "application-x-executable-symbolic")} + css={"font-size: 18px; margin-right: 6px;"} /> + + + + + + desc ?? "Unnamed audio stream")} + ellipsize={Pango.EllipsizeMode.END} + tooltipText={createBinding(stream, "name")} + class={"name"} xalign={0} + /> + + + stream.set_volume(value)} + hexpand min={0} max={1.5} + /> + + + } + + + ] +}); diff --git a/src/window/control-center/widgets/pages/index.tsx b/src/window/control-center/widgets/pages/index.tsx new file mode 100644 index 00000000..867f86e1 --- /dev/null +++ b/src/window/control-center/widgets/pages/index.tsx @@ -0,0 +1,97 @@ +import { register } from "ags/gobject"; +import { Gtk } from "ags/gtk4"; +import { Page } from "../Page"; + +import GLib from "gi://GLib?version=2.0"; + + +export type PagesProps = { + initialPage?: Page; + transitionDuration?: number; +}; + +@register({ GTypeName: "Pages" }) +export class Pages extends Gtk.Box { + #timeouts: Array<[GLib.Source, (() => void)|undefined]> = []; + #page: (Page|undefined); + #transDuration: number; + #transType: Gtk.RevealerTransitionType = Gtk.RevealerTransitionType.SLIDE_DOWN; + + get isOpen() { return Boolean(this.#page); } + get page() { return this.#page; } + + constructor(props?: PagesProps) { + super({ + orientation: Gtk.Orientation.VERTICAL, + cssName: "pages", + name: "pages" + }); + + this.add_css_class("pages"); + + this.#transDuration = props?.transitionDuration ?? 280; + + if(props?.initialPage) + this.open(props.initialPage); + + + const destroyId = this.connect("destroy", () => { + this.disconnect(destroyId); + this.#timeouts.forEach((tmout) => { + tmout[0].destroy(); + (async () => tmout[1]?.())().catch((err: Error) => { + console.error(`${err.message}\n${err.stack}`); + }); + }); + }); + } + + toggle(newPage?: Page, onToggled?: () => void): void { + if(!newPage || (this.#page?.id === newPage.id)) { + this.close(onToggled); + return; + } + + if(!this.isOpen) { + newPage && this.open(newPage, onToggled); + return; + } + + if(this.#page?.id !== newPage.id) { + this.close(); + this.open(newPage, onToggled); + } + } + + open(newPage: Page, onOpen?: () => void) { + this.#page = newPage; + + this.prepend( + + + {newPage.create()} + as Gtk.Revealer + ); + + (this.get_first_child() as Gtk.Revealer)?.set_reveal_child(true); + onOpen?.(); + } + + close(onClosed?: () => void): void { + const page = this.get_first_child() as Gtk.Revealer|null; + if(!page) return; + + this.#page?.actionClosed?.(); + this.#page = undefined; + + page.set_reveal_child(false); + this.#timeouts.push([ + setTimeout(() => { + this.remove(page); + onClosed?.(); + }, page.transitionDuration), + onClosed + ]); + } +} diff --git a/src/window/control-center/widgets/tiles/Bluetooth.tsx b/src/window/control-center/widgets/tiles/Bluetooth.tsx new file mode 100644 index 00000000..df2ae1a8 --- /dev/null +++ b/src/window/control-center/widgets/tiles/Bluetooth.tsx @@ -0,0 +1,34 @@ +import { Tile } from "./Tile"; +import { BluetoothPage } from "../pages/Bluetooth"; +import { TilesPages } from "../tiles"; +import { createBinding, createComputed } from "ags"; +import { Bluetooth } from "../../../../modules/bluetooth"; + +import AstalBluetooth from "gi://AstalBluetooth"; + + +export const TileBluetooth = () => + { + if(!connected) return ""; + + const connectedDevs = AstalBluetooth.get_default().devices.filter(dev => dev.connected); + const connectedDev = connectedDevs[connectedDevs.length - 1]; // last connected device is on display + return connectedDev ? connectedDev.get_alias() : "" + })} + onEnabled={() => Bluetooth.getDefault().adapter?.set_powered(true)} + onDisabled={() => Bluetooth.getDefault().adapter?.set_powered(false)} + onClicked={() => TilesPages?.toggle(BluetoothPage)} + hasArrow + state={createBinding(AstalBluetooth.get_default(), "isPowered")} + icon={createComputed([ + createBinding(AstalBluetooth.get_default(), "isPowered"), + createBinding(AstalBluetooth.get_default(), "isConnected") + ], + (powered: boolean, isConnected: boolean) => + powered ? ( isConnected ? + "bluetooth-active-symbolic" + : "bluetooth-symbolic" + ) : "bluetooth-disabled-symbolic") + } + />; diff --git a/src/window/control-center/widgets/tiles/DoNotDisturb.tsx b/src/window/control-center/widgets/tiles/DoNotDisturb.tsx new file mode 100644 index 00000000..e6634693 --- /dev/null +++ b/src/window/control-center/widgets/tiles/DoNotDisturb.tsx @@ -0,0 +1,14 @@ +import { Notifications } from "../../../../modules/notifications"; +import { Tile } from "./Tile"; +import { tr } from "../../../../i18n/intl"; +import { createBinding } from "ags"; + +export const TileDND = () => + dnd ? tr("control_center.tiles.enabled") : tr("control_center.tiles.disabled"))} + onDisabled={() => Notifications.getDefault().getNotifd().dontDisturb = false} + onEnabled={() => Notifications.getDefault().getNotifd().dontDisturb = true} + icon={"minus-circle-filled-symbolic"} + state={Notifications.getDefault().getNotifd().dontDisturb} + />; diff --git a/src/window/control-center/widgets/tiles/Network.tsx b/src/window/control-center/widgets/tiles/Network.tsx new file mode 100644 index 00000000..37072bd6 --- /dev/null +++ b/src/window/control-center/widgets/tiles/Network.tsx @@ -0,0 +1,168 @@ +import { Tile } from "./Tile"; +import { execAsync } from "ags/process"; +import { PageNetwork } from "../pages/Network"; +import { tr } from "../../../../i18n/intl"; +import { TilesPages } from "../tiles"; +import { Accessor, createBinding, createComputed } from "ags"; +import { secureBaseBinding } from "../../../../modules/utils"; + +import AstalNetwork from "gi://AstalNetwork"; +import { Notifications } from "../../../../modules/notifications"; + + +const { WIFI, WIRED } = AstalNetwork.Primary, + { CONNECTED, CONNECTING, DISCONNECTED } = AstalNetwork.Internet; + +const wiredInternet = secureBaseBinding( + createBinding(AstalNetwork.get_default(), "wired"), + "internet", + AstalNetwork.Internet.DISCONNECTED +) as Accessor; + +const wifiInternet = secureBaseBinding( + createBinding(AstalNetwork.get_default(), "wifi"), + "internet", + AstalNetwork.Internet.DISCONNECTED +) as Accessor; + +const wifiSSID = secureBaseBinding( + createBinding(AstalNetwork.get_default(), "wifi"), + "ssid", + "Unknown" +) as Accessor; + +const wifiIcon = secureBaseBinding( + createBinding(AstalNetwork.get_default(), "wifi"), + "iconName", + "network-wireless-symbolic" +); + +const wiredIcon = secureBaseBinding( + createBinding(AstalNetwork.get_default(), "wired"), + "iconName", + "network-wired-symbolic" +); + +const primary = createBinding(AstalNetwork.get_default(), "primary"); + +export const TileNetwork = () => + { + switch(primary) { + case WIFI: + if(wiInternet === CONNECTED) + return wiSSID; + + return tr("control_center.tiles.network.wireless"); + + case WIRED: + return tr("control_center.tiles.network.wired"); + } + + return tr("control_center.tiles.network.network"); + })} + onClicked={() => TilesPages?.toggle(PageNetwork)} + icon={createComputed([ + primary, + wifiIcon, + wiredIcon + ], (primary, wifiIcon, wiredIcon) => { + switch(primary) { + case WIFI: + return wifiIcon; + + case WIRED: + return wiredIcon; + } + + return "network-wired-no-route-symbolic"; + })} + state={createComputed([ + primary, + secureBaseBinding( + createBinding(AstalNetwork.get_default(), "wifi"), + "enabled", + false + ), + wiredInternet.as(internet => internet === CONNECTED || internet === CONNECTING) + ], (primary, wifiEnabled, wiredEnabled) => { + switch(primary) { + case WIFI: + return wifiEnabled; + + case WIRED: + return wiredEnabled; + } + + return false; + })} + description={createComputed([ + primary, + wifiInternet, + wiredInternet + ], (primary, wifiInternet, wiredInternet) => { + switch(primary) { + case WIFI: + return internetToTranslatedString(wifiInternet); + + case WIRED: + return internetToTranslatedString(wiredInternet); + } + + return tr("disconnected"); + })} + onToggled={(self, state) => { + const wifi = AstalNetwork.get_default().wifi, + wired = AstalNetwork.get_default().wired; + + switch(AstalNetwork.get_default().primary) { + case WIFI: + wifi.set_enabled(state); + return; + + case WIRED: + setNetworking(state); + return; + } + + if(wired && wired.internet === DISCONNECTED) { + setNetworking(true); + return; + } else if(wifi && !wifi.enabled) { + wifi.set_enabled(true); + return; + } + + // disable if no device available + self.state = false; + }} + />; + + +function internetToTranslatedString(internet: AstalNetwork.Internet): string { + switch(internet) { + case AstalNetwork.Internet.CONNECTED: + return tr("connected"); + case AstalNetwork.Internet.CONNECTING: + return tr("connecting") + "..."; + } + + return tr("disconnected"); +} + +function setNetworking(state: boolean): void { + (!state ? + execAsync("nmcli n off") + : execAsync("nmcli n on") + ).catch(e => { + Notifications.getDefault().sendNotification({ + appName: "network", + summary: "Couldn't turn off network", + body: `Turning off networking with nmcli failed${ + e?.message !== undefined ? `: ${e?.message}` : ""}` + }); + }); +} diff --git a/src/window/control-center/widgets/tiles/NightLight.tsx b/src/window/control-center/widgets/tiles/NightLight.tsx new file mode 100644 index 00000000..c34e328a --- /dev/null +++ b/src/window/control-center/widgets/tiles/NightLight.tsx @@ -0,0 +1,28 @@ +import { Tile } from "./Tile"; +import { NightLight } from "../../../../modules/nightlight"; +import { PageNightLight } from "../pages/NightLight"; +import { tr } from "../../../../i18n/intl"; +import { TilesPages } from "../tiles"; +import { isInstalled } from "../../../../modules/utils"; +import { createBinding, createComputed } from "ags"; + + +export const TileNightLight = () => + !identity ? + `${temp === NightLight.getDefault().identityTemperature ? + tr("control_center.tiles.night_light.default_desc") : `${temp}K` + } ${gamma < NightLight.getDefault().maxGamma ? `(${gamma}%)` : ""}` + : tr("control_center.tiles.disabled") + )} + hasArrow visible={isInstalled("hyprsunset")} + onDisabled={() => NightLight.getDefault().identity = true} + onEnabled={() => NightLight.getDefault().identity = false} + onClicked={() => TilesPages?.toggle(PageNightLight)} + state={createBinding(NightLight.getDefault(), "identity").as(identity => !identity)} + /> diff --git a/src/window/control-center/widgets/tiles/Recording.tsx b/src/window/control-center/widgets/tiles/Recording.tsx new file mode 100644 index 00000000..745d5357 --- /dev/null +++ b/src/window/control-center/widgets/tiles/Recording.tsx @@ -0,0 +1,24 @@ +import { Tile } from "./Tile"; +import { Recording } from "../../../../modules/recording"; +import { tr } from "../../../../i18n/intl"; +import { isInstalled } from "../../../../modules/utils"; +import { createBinding, createComputed } from "ags"; + + +export const TileRecording = () => + { + if(!recording || !Recording.getDefault().startedAt) + return tr("control_center.tiles.recording.disabled_desc") || "Start recording"; + + return time; + })} + icon={"media-record-symbolic"} + visible={isInstalled("wf-recorder")} + onDisabled={() => Recording.getDefault().stopRecording()} + onEnabled={() => Recording.getDefault().startRecording()} + state={createBinding(Recording.getDefault(), "recording")} + />; diff --git a/src/window/control-center/widgets/tiles/Tile.tsx b/src/window/control-center/widgets/tiles/Tile.tsx new file mode 100644 index 00000000..232e6799 --- /dev/null +++ b/src/window/control-center/widgets/tiles/Tile.tsx @@ -0,0 +1,150 @@ +import { Gtk } from "ags/gtk4"; +import { createBinding } from "ags"; +import { omitObjectKeys, variableToBoolean } from "../../../../modules/utils"; +import { property, register, signal } from "ags/gobject"; + +import Pango from "gi://Pango?version=1.0"; + + +@register({ GTypeName: "Tile" }) +export class Tile extends Gtk.Box { + @signal(Boolean) toggled(_state: boolean) {} + @signal() enabled() {} + @signal() disabled() {} + @signal() clicked() { + if(this.enableOnClicked) + this.enable(); + } + + @property(String) + public icon: string; + @property(String) + public title: string; + @property(String) + public description: string = ""; + @property(Boolean) + public enableOnClicked: boolean = false; + @property(Boolean) + public state: boolean = false; + @property(Boolean) + public hasArrow: boolean = false; + + declare $signals: Gtk.Box.SignalSignatures & { + "toggled": (state: boolean) => void; + "enabled": () => void; + "disabled": () => void; + "clicked": () => void; + }; + + public enable(): void { + if(this.state) return; + + this.state = true; + !this.has_css_class("enabled") && + this.add_css_class("enabled"); + + this.emit("toggled", true); + this.emit("enabled"); + } + + public disable(): void { + if(!this.state) return; + + this.state = false; + this.remove_css_class("enabled"); + this.emit("toggled", false); + this.emit("disabled"); + } + + constructor(props: Partial> & { + icon: string; + title: string; + description?: string; + state?: boolean; + enableOnClicked?: boolean; + hasArrow?: boolean; + }) { + super(omitObjectKeys(props, [ + "icon", + "title", + "description", + "state", + "enableOnClicked" + ])); + + this.add_css_class("tile"); + this.add_controller( + { + // gets the icon part of the tile + const { x, y, width, height } = this.get_first_child()!.get_allocation(); + + if((px < x || px > x+width) || (py < y || y > py+height)) + this.emit("clicked"); + }} /> as Gtk.GestureClick + ); + + this.icon = props.icon; + this.title = props.title; + this.hexpand = true; + + if(props.hasArrow !== undefined) + this.hasArrow = props.hasArrow; + + if(props.description !== undefined) + this.description = props.description; + + if(props.state !== undefined) + this.state = props.state; + + if(props.enableOnClicked !== undefined) + this.enableOnClicked = props.enableOnClicked; + + this.state && + this.add_css_class("enabled"); // fix no highlight when enabled on init + + this.prepend( + + + { + this.state ? this.disable() : this.enable(); + }} /> + as Gtk.Box + ); + + this.append( + + + + + as Gtk.Box + ); + + if(this.hasArrow) + this.append( + as Gtk.Image + ); + } + + emit( + signal: Signal, + ...args: Parameters<(typeof this.$signals)[Signal]> + ): void { + super.emit(signal, ...args); + } + + connect( + signal: Signal, + callback: (typeof this.$signals)[Signal] + ): number { + return super.connect(signal, callback); + } +} diff --git a/src/window/control-center/widgets/tiles/index.tsx b/src/window/control-center/widgets/tiles/index.tsx new file mode 100644 index 00000000..8744a343 --- /dev/null +++ b/src/window/control-center/widgets/tiles/index.tsx @@ -0,0 +1,32 @@ +import { Gtk } from "ags/gtk4"; +import { TileNetwork } from "./Network"; +import { TileBluetooth } from "./Bluetooth"; +import { TileDND } from "./DoNotDisturb"; +import { TileRecording } from "./Recording"; +import { TileNightLight } from "./NightLight"; +import { Pages } from "../pages"; + + +export let TilesPages: Pages|undefined; +export const tileList: Array<() => JSX.Element|Gtk.Widget> = [ + TileNetwork, + TileBluetooth, + TileRecording, + TileDND, + TileNightLight +] as Array<() => Gtk.Widget>; + +export function Tiles(): Gtk.Widget { + return TilesPages = undefined}> + + + + {tileList.map(t => t())} + + + TilesPages = self} /> + as Gtk.Box; +} diff --git a/src/window/floating-notifications/index.tsx b/src/window/floating-notifications/index.tsx new file mode 100644 index 00000000..92598634 --- /dev/null +++ b/src/window/floating-notifications/index.tsx @@ -0,0 +1,94 @@ +import { Astal, Gtk } from "ags/gtk4"; +import { createBinding, createComputed, For } from "ags"; +import { Notifications } from "../../modules/notifications"; +import { NotificationWidget } from "../../widget/Notification"; +import { generalConfig } from "../../config"; + +import AstalNotifd from "gi://AstalNotifd"; +import Adw from "gi://Adw?version=1"; + + +const size = 450; + +export const FloatingNotifications = (mon: number) => + { + const pos: Array = []; + + switch(posH) { + case "left": + pos.push(Astal.WindowAnchor.LEFT); + break; + case "center": + pos.push(Astal.WindowAnchor.LEFT); + pos.push(Astal.WindowAnchor.RIGHT); + break; + case "right": + pos.push(Astal.WindowAnchor.RIGHT); + break; + } + + switch(posV) { + case "top": + pos.push(Astal.WindowAnchor.TOP); + break; + case "center": + pos.push(Astal.WindowAnchor.TOP); + pos.push(Astal.WindowAnchor.BOTTOM); + break; + case "bottom": + pos.push(Astal.WindowAnchor.BOTTOM); + break; + } + + let finalPos: Astal.WindowAnchor; + + pos.forEach(pos => finalPos = (finalPos !== undefined ? + finalPos | pos + : pos)); + + return finalPos!; + + })} exclusivity={Astal.Exclusivity.NORMAL} + resizable={false} widthRequest={450}> + + + + + {(notif: AstalNotifd.Notification) => + { + //TODO: support different animations depending on screen position + return Gtk.StackTransitionType.SLIDE_RIGHT + })} transitionDuration={300}> + + + + Notifications.getDefault().removeNotification(notif)} + holdOnHover actionClicked={() => { + const viewAction = notif.actions.filter(a => + a.id.toLowerCase() === "view" || + a.label.toLowerCase() === "view" + )?.[0]; + + viewAction && notif.invoke(viewAction.id); + }} + /> + + as Gtk.Widget + }> + + + } + + + as Astal.Window; diff --git a/src/window/logout-menu/index.tsx b/src/window/logout-menu/index.tsx new file mode 100644 index 00000000..6147078d --- /dev/null +++ b/src/window/logout-menu/index.tsx @@ -0,0 +1,128 @@ +import { Astal, Gdk, Gtk } from "ags/gtk4"; +import { execAsync } from "ags/process"; +import { generalConfig } from "../../config"; +import { AskPopup } from "../../widget/AskPopup"; +import { Notifications } from "../../modules/notifications"; +import { NightLight } from "../../modules/nightlight"; +import { time } from "../../modules/utils"; + +import GObject from "ags/gobject"; +import AstalNotifd from "gi://AstalNotifd"; +import Gio from "gi://Gio?version=2.0"; + + +const { TOP, LEFT, RIGHT, BOTTOM } = Astal.WindowAnchor; + +export const LogoutMenu = (mon: number) => + { + const conns: Map = new Map(); + const controllerKey = Gtk.EventControllerKey.new(); + + self.add_controller(controllerKey); + + conns.set(controllerKey, controllerKey.connect("key-released", (_, keyval) => { + if(keyval === Gdk.KEY_Escape) + self.close(); + })); + + conns.set(self, self.connect("close-request", () => conns.forEach((id, obj) => + obj.disconnect(id)))); + }}> + + { + const conns: Map = new Map(); + const gestureClick = Gtk.GestureClick.new(); + + self.add_controller(gestureClick); + gestureClick.set_button(0); + + conns.set(gestureClick, gestureClick.connect("released", (gesture) => { + if(gesture.get_current_button() === Gdk.BUTTON_PRIMARY) { + (self.get_root() as Astal.Window|null)?.close(); + return true; + } + })); + + conns.set(self, self.connect("destroy", () => conns.forEach((id, obj) => + obj.disconnect(id)))); + }}> + + + + t.format("%H:%M")!)} /> + d.format("%A, %B %d %Y")!)} /> + + + + AskPopup({ + title: "Power Off", + text: "Are you sure you want to power off? Unsaved work will be lost.", + onAccept: () => { + generalConfig.getProperty("night_light.save_on_shutdown", "boolean") && + NightLight.getDefault().saveData(); + + execAsync("systemctl poweroff"); + } + })} + /> + AskPopup({ + title: "Reboot", + text: "Are you sure you want to Reboot? Unsaved work will be lost.", + onAccept: () => { + generalConfig.getProperty("night_light.save_on_shutdown", "boolean") && + NightLight.getDefault().saveData(); + + execAsync("systemctl reboot"); + } + })} + /> + AskPopup({ + title: "Suspend", + text: "Are you sure you want to Suspend?", + onAccept: () => execAsync("systemctl suspend") + })} + /> + AskPopup({ + title: "Log out", + text: "Are you sure you want to log out? Your session will be ended.", + onAccept: () => { + generalConfig.getProperty("night_light.save_on_shutdown", "boolean") && + NightLight.getDefault().saveData(); + + execAsync(`hyprctl dispatch exit`).catch((err: Gio.IOErrorEnum) => + Notifications.getDefault().sendNotification({ + appName: "colorshell", + summary: "Couldn't exit Hyprland", + body: `An error occurred and colorshell couldn't exit Hyprland. Stderr: \n${ + err.message ? `${err.message}\n` : ""}${err.stack}`, + urgency: AstalNotifd.Urgency.NORMAL, + actions: [{ + text: "Report Issue on colorshell", + onAction: () => execAsync( + `xdg-open https://github.com/retrozinndev/colorshell/issues/new` + ).catch((err: Gio.IOErrorEnum) => + Notifications.getDefault().sendNotification({ + appName: "colorshell", + summary: "Couldn't open link", + body: `Do you have \`xdg-utils\` installed? Stderr: \n${ + err.message ? `${err.message}\n` : ""}${err.stack}` + }) + ) + }] + }) + ) + } + })} + /> + + + as Astal.Window; diff --git a/src/window/osd/index.tsx b/src/window/osd/index.tsx new file mode 100644 index 00000000..fdf32695 --- /dev/null +++ b/src/window/osd/index.tsx @@ -0,0 +1,105 @@ +import { Astal, Gtk } from "ags/gtk4"; +import { createBinding, createState, With } from "ags"; +import { Wireplumber } from "../../modules/volume"; +import { Windows } from "../../windows"; +import { Backlights } from "../../modules/backlight"; +import { secureBaseBinding, variableToBoolean } from "../../modules/utils"; + +import Pango from "gi://Pango?version=1.0"; +import GLib from "gi://GLib?version=2.0"; +import AstalWp from "gi://AstalWp?version=0.1"; +import OSDMode from "./modules/osdmode"; + + +export const OSDModes = { + sink: new OSDMode({ + available: createBinding(AstalWp.get_default(), "defaultSpeaker").as((sink) => + Boolean(sink)), + icon: secureBaseBinding( + createBinding(AstalWp.get_default(), "defaultSpeaker"), + "volumeIcon", + "audio-volume-high-symbolic" + ), + value: secureBaseBinding( + createBinding(AstalWp.get_default(), "defaultSpeaker"), + "volume", + .5 + ), + text: secureBaseBinding( + createBinding(AstalWp.get_default(), "defaultSpeaker"), + "description", + "Unknown Speaker" + ), + max: Wireplumber.getDefault().getMaxSinkVolume() / 100 + }), + brightness: new OSDMode({ + icon: "display-brightness-symbolic", + value: secureBaseBinding( + createBinding(Backlights.getDefault(), "default"), + "brightness", + 100 + ), + max: secureBaseBinding( + createBinding(Backlights.getDefault(), "default"), + "maxBrightness", + 100 + ), + text: secureBaseBinding( + createBinding(Backlights.getDefault(), "default"), + "name", + "Unknown Backlight" + ), + available: createBinding(Backlights.getDefault(), "available") + }) +} + +const [osdMode, setOSDMode] = createState(OSDModes.sink); +let osdTimer: (GLib.Source|undefined), osdTimeout = 3500; + +export const OSD = (mon: number) => + + + + + {(mode: OSDMode) => { + if(!mode.available) return; + + return + + + t ?? "")} + ellipsize={Pango.EllipsizeMode.END} + visible={variableToBoolean(createBinding(mode, "text"))} + /> + + + ; + }} + + + ; + +export function triggerOSD(mode: OSDMode) { + setOSDMode(mode); + Windows.getDefault().open("osd"); + + if(!osdTimer) { + osdTimer = setTimeout(() => { + osdTimer = undefined; + Windows.getDefault().close("osd"); + }, osdTimeout); + + return; + } + + osdTimer.destroy(); + osdTimer = setTimeout(() => { + Windows.getDefault().close("osd"); + osdTimer = undefined; + }, osdTimeout); +} diff --git a/src/window/osd/modules/osdmode.ts b/src/window/osd/modules/osdmode.ts new file mode 100644 index 00000000..aada207b --- /dev/null +++ b/src/window/osd/modules/osdmode.ts @@ -0,0 +1,39 @@ +import { Accessor } from "ags"; +import { construct } from "../../../modules/utils"; +import GObject, { gtype, property, register } from "ags/gobject"; + + +@register({ GTypeName: "OSDMode" }) +export default class OSDMode extends GObject.Object { + readonly #subs: Array<() => void> = []; + + @property(String) + icon: string = "image-missing"; + + @property(Number) + value: number = 0; + + @property(Number) + max: number = 100; + + @property(gtype(String)) + text: string|null = null; + + @property(Boolean) + available: boolean = true; + + constructor(props: { + icon: string | Accessor; + value: number | Accessor; + max?: number | Accessor; + text?: string | Accessor; + available?: boolean | Accessor; + }) { + super(); + this.#subs = construct(this, props); + } + + vfunc_dispose(): void { + this.#subs.forEach(s => s()); + } +} diff --git a/src/windows.ts b/src/windows.ts new file mode 100644 index 00000000..3eb3fbee --- /dev/null +++ b/src/windows.ts @@ -0,0 +1,319 @@ +import { Astal } from "ags/gtk4"; +import { Shell } from "./app"; +import GObject, { getter, register, signal } from "ags/gobject"; +import { variableToBoolean } from "./modules/utils"; +import { createRoot, getScope, onCleanup } from "ags"; +import { Bar } from "./window/bar"; +import { OSD } from "./window/osd"; +import { ControlCenter } from "./window/control-center"; +import { FloatingNotifications } from "./window/floating-notifications"; +import { CenterWindow } from "./window/center-window"; +import { LogoutMenu } from "./window/logout-menu"; +import { AppsWindow } from "./window/apps-window"; + +import AstalHyprland from "gi://AstalHyprland"; + + +export type WindowInstance = { instance?: Astal.Window, connections: Array }; +export type WindowData = { + create: () => (Astal.Window | Array); + instance?: WindowInstance | Array; + status?: "open" | "closed"; +}; + + +/** + * Windowing System + * Possible actions: getting window states, close, open, toggle windows and + * registering windows. + * Also contains util functions to create dynamic windows, opening the window only on focused + * monitor, or all available monitors! + */ +@register({ GTypeName: "Windows" }) +export class Windows extends GObject.Object { + private static instance: (Windows | null); + + declare $signals: GObject.Object.SignalSignatures & { + "window-open": (name: string) => void; + "window-closed": (name: string) => void; + }; + + #scope!: ReturnType; + #windows: Record = { + "bar": { create: this.createWindowForMonitors(Bar) }, + "osd": { create: this.createWindowForFocusedMonitor(OSD), }, + "control-center": { create: this.createWindowForFocusedMonitor(ControlCenter), }, + "center-window": { create: this.createWindowForFocusedMonitor(CenterWindow), }, + "logout-menu": { create: this.createWindowForFocusedMonitor(LogoutMenu), }, + "floating-notifications": { create: this.createWindowForFocusedMonitor(FloatingNotifications), }, + "apps-window": { create: this.createWindowForFocusedMonitor(AppsWindow) } + }; + + @signal(String) windowOpen(_name: string) {} + @signal(String) windowClosed(_name: string) {} + + @getter(Object) + get windows(): object { return this.#windows; } + + @getter(Array) + get openWindows(): Array { + return Object.keys(this.#windows).filter((key) => + this.#windows[key].status === "open"); + } + + constructor() { + super(); + + createRoot((dispose) => { + this.#scope = getScope(); + Shell.getDefault().scope.onMount(dispose); + + // Listen to monitor events + const hyprConnections = [ + AstalHyprland.get_default().connect("monitor-added", () => + this.reopen()), + AstalHyprland.get_default().connect("monitor-removed", () => + AstalHyprland.get_default().get_monitors().length > 0 && + this.reopen()) + ]; + + onCleanup(() => { + hyprConnections.forEach(id => AstalHyprland.get_default().disconnect(id)); + this.openWindows.forEach(name => this.disconnectWindow(name)); + }); + + }); + } + + private disconnectWindow(name: string) { + if(!variableToBoolean(this.#windows[name]?.instance) || !this.#windows[name]) { + console.error(`Windows: couldn't disconnect window's connections: either the window \`${name + }\` doesn't exist in the windows list or it has no valid instance to disconnect signals from(not open)`); + return; + } + + const window = this.#windows[name].instance!; + + if(Array.isArray(window)) { + window.forEach(win => { + this._disconnectAllFromInstance(win.instance!, win.connections!) + win.connections = []; + }); + + return; + } + + this._disconnectAllFromInstance(window.instance!, window.connections!); + window.connections = []; + } + + private _disconnectAllFromInstance(instance: GObject.Object, connections: Array): void { + connections.forEach(id => + GObject.signal_handler_is_connected(instance, id) && + instance.disconnect(id)); + } + + private hasConnections(name: string): boolean { + if(!this.openWindows.includes(name)) + return false; + + const window = this.#windows[name].instance; + if(!window) return false; + + if(Array.isArray(window)) { + for(const win of window) { + if(win.connections?.length > 0) + return true; + } + + return false; + } + + return window.connections?.length > 0; + } + + private connectWindow(name: string) { + if(this.hasConnections(name)) { + console.log(`Windows: skipped connecting window: \`${name}\`. Already connected`); + return; + } + + if(!this.openWindows.includes(name)) { + console.log(`Windows: \`${name}\` is not open, will not connect`); + return; + } + + const window = this.#windows[name as keyof typeof this.windows]; + if(!window || !window.instance) { + console.error(`Windows: Either \`${name}\` does not exist in the window list or it doesn't have a valid instance. Please add the window before trying to manage it here`); + return; + } + + if(Array.isArray(window.instance)) { + window.instance.forEach(inst => inst.connections = [ + inst.instance!.connect("close-request", () => { + this.disconnectWindow(name); + delete window.instance; + window.status = "closed"; + this.notify("open-windows"); + }) + ]); + + return; + } + + window.instance.connections = [ + window.instance.instance!.connect("close-request", () => { + this.disconnectWindow(name); + delete window.instance; + window.status = "closed"; + this.notify("open-windows"); + }) + ]; + } + + public static getDefault(): Windows { + if(!this.instance) + this.instance = new Windows(); + + return this.instance; + } + + /** + * Creates a window instance for every monitor connected + * @param create generates the window. use provided monitor number in the returned window + * @returns a function that when called, returns Array + * @throws Error if there are no monitors connected + */ + public createWindowForMonitors(create: (mon: number, scope: ReturnType) => GObject.Object|Astal.Window): (() => Array) { + const monitors = AstalHyprland.get_default().get_monitors(); + + if(monitors.length < 1) + throw new Error("Couldn't create window for monitors", { + cause: "No monitors connected on Hyprland" + }); + + // create a scope for every window generator function and dispose on ::close-request + return () => monitors.map(mon => { + return createRoot(() => { + const scope = getScope(); + const instance = create(mon.id, scope) as Astal.Window; + const connection: number = instance.connect("close-request", () => + scope.dispose()); + + this.#scope.onMount(scope.dispose); + + scope.onCleanup(() => instance.disconnect(connection)); + + return instance; + }) + }) + } + + /** + * Creates a window instance for focused monitor only + * @param create generates the window. use provided monitor number in the returned window + * @returns a function that when called, returns a Astal.Window instance + * @throws Error if no focused monitor is found + */ + public createWindowForFocusedMonitor(create: (mon: number, scope: ReturnType) => GObject.Object|Astal.Window): (() => Astal.Window) { + const focusedMonitor = this.getFocusedMonitorId(); + + if(focusedMonitor == null) + throw new Error("Couldn't create window for focused monitor", { + cause: `No focused monitor found (${typeof focusedMonitor})` + }); + + return () => { + return createRoot((dispose) => { + const scope = getScope(); + const instance = create(focusedMonitor, scope) as Astal.Window; + const connection = instance.connect("close-request", () => dispose()); + + this.#scope.onMount(dispose) + scope.onCleanup(() => instance.disconnect(connection)); + + return instance; + }); + } + } + + public addWindow(name: string, create: () => Astal.Window|Array): void { + this.#windows[name] = { create }; + } + + public hasWindow(name: string): boolean { + return Boolean(this.windows?.[name as keyof typeof this.windows]); + } + + public getWindows(): Array<(() => (Astal.Window | Array))> { + return Object.values(this.windows); + } + + public getFocusedMonitorId(): (number|null) { + return AstalHyprland.get_default().get_monitors().filter(mon => mon.focused)?.[0]?.id ?? null; + } + + public isOpen(name: string): boolean { + return this.openWindows.includes(name); + } + + public open(name: string, ignoreOpenStatus: boolean = false): void { + if(this.isOpen(name) && !ignoreOpenStatus) return; + + const window = this.#windows[name]; + if(!window) { + console.error(`Windows: cannot open a window (\`${name}\`) that is not registered/doesn't exist.`); + return; + } + + this.#windows[name].status = "open"; + const windowInstance = window.create(); + + if(Array.isArray(windowInstance)) { + window.instance = windowInstance.map(wi => { + wi.show(); + return { instance: wi, connections: [] }; + }); + } else { + window.instance = { instance: windowInstance, connections: [] }; + windowInstance.show(); + } + + this.connectWindow(name); + + this.emit("window-open", name); + this.notify("open-windows"); + } + + public close(name: string): void { + if(!this.isOpen(name)) return; + + this.disconnectWindow(name); + const window = this.#windows[name]; + + if(Array.isArray(window.instance)) + window.instance.map(inst => inst.instance!.close()); + else + window.instance!.instance!.close(); + + this.#windows[name].status = "closed"; + + this.emit("window-closed", name); + this.notify("open-windows"); + } + + public toggle(name: string): void { + this.isOpen(name) ? this.close(name) : this.open(name); + } + + public closeAll(): void { + this.openWindows.forEach(name => this.close(name)); + } + + public reopen(): void { + const openWins = [ ...this.openWindows ]; + this.closeAll(); + openWins.forEach(name => this.open(name)); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..f8a16970 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "allowJs": false, + "checkJs": true, + "module": "es2022", + "target": "es2020", + "lib": ["ES2024"], + "moduleResolution": "bundler", + "skipLibCheck": true, + "types": [ + "./@types" + ], + "strict": true, + "jsx": "react-jsx", + "jsxImportSource": "ags/gtk4" + } +} diff --git a/update-repo.sh b/update-repo.sh deleted file mode 100644 index 8c1c700d..00000000 --- a/update-repo.sh +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/bash - -source ./utils.sh - -Check_current_dir() { - if ! [[ -f ./utils.sh ]]; then - Send_log warn "Looks like you're not in the repository directory!\nPlease run this script from the repo directory to avoid problems." - Send_log "Exiting" - sleep .5 - exit 1 - fi -} - -Clean_local() { - Send_log "info" "Cleaning current repo dotfiles..." - for dir in ${config_dirs[@]}; do - if [[ -d "./$dir" ]]; then - rm -rf ./$dir - fi - done - - Send_log "info" "Cleaning wallpapers..." - rm -rf ./wallpapers - - echo "Done cleaning." -} - -Update_local() { - for dir in ${config_dirs[@]}; do - if [[ -d "$XDG_CONFIG_HOME/$dir" ]] || [[ -f "$XDG_CONFIG_HOME/$dir" ]]; then - Send_log "Copying ${dir^}" - cp -r $XDG_CONFIG_HOME/$dir ./$dir - else - Send_log "warn" "Looks like the ${dir^} dir is in fault! Skipping..." - fi - done - - walls_dir=$WALLPAPERS - - if [[ -z "$walls_dir" ]] || [[ ! -d "$walls_dir" ]]; then - walls_dir="$HOME/wallpapers" - fi - - if [[ ! -z "$walls_dir" ]] && [[ -d "$walls_dir" ]]; then - Send_log "Copying wallpapers" - mkdir -p ./wallpapers - cp -rf $HOME/wallpapers/* ./wallpapers - - return - fi - - Send_log warn "Wallpapers dir could not be found in $HOME, skipping..." -} - -Update_remote() { - echo "Git status:" - /bin/env git status - echo "Please type one of the dotfiles you want to push now(only one dir):" - printf "directory/file: " - read chosen_dir - if [[ -d $chosen_dir ]] || [[ -f $chosen_dir ]]; then - git add $chosen_dir - echo -n "Would you like to add more dirs to queue? [y/n] " - read add_more_dirs - if [[ $add_more_dirs =~ y ]]; then - Update_remote - else - commit_message="" - commit_description="" - push_changes="" - echo -en "(You can use emojis by typing its name between colons, e.g.: \":tada:\" for \"🎉\").\nCommit message: " - read commit_message - echo -n "Type commit description(leave blank if none): " - read commit_description - - echo "Committing changes..." - [[ ! -z $commit_description ]] && \ - git commit -m "$commit_message" -m "$commit_description" || \ - git commit -m "$commit_message" - - echo -n "Done! Do you want to push? If not, you'll go back to file selection [y/n] " - read push_changes - - if [[ $push_changes =~ "y" ]]; then - git push - echo "Done pushing!!" - else - Update_remote - fi - fi - else - echo "Looks like this directory does not exist! Try taking a look at the dir list." - Update_remote - fi - - -} - -Check_current_dir -Print_header - -printf "\n" -echo "!!WARNING!! Running this script may override all data in current repo with host configurations." -echo "This script is intended to be used only by the dotfiles owner" -printf "\n" - -echo "Please run this script in it's current directory to avoid issues" -echo "Tip: Press Ctrl + C to stop script at any time" - -printf "\n" - -echo -n "Do you want to update local repository with host configurations? [y/n] " -read answer -if ! [[ $answer =~ y ]]; then - Send_log "Exiting" - exit 1 -fi - -printf "\n" - -Clean_local -Update_local - -echo -n "Would you like to commit to remote? (You will be prompted for commits) [y/n] " -read answer - -if [[ $answer =~ y ]]; then - Update_remote - echo "Looks like it's done! Have a great day!" -else - echo "Ok, work's finished here! Have a great day!" -fi - -env git status - -exit 0 diff --git a/wallpapers/Arch Linux Miku.jpg b/wallpapers/Arch Linux Miku.jpg deleted file mode 100644 index 69eda12c..00000000 Binary files a/wallpapers/Arch Linux Miku.jpg and /dev/null differ diff --git a/wallpapers/Balcony Girl.png b/wallpapers/Balcony Girl.png deleted file mode 100644 index c5e35787..00000000 Binary files a/wallpapers/Balcony Girl.png and /dev/null differ diff --git a/wallpapers/Big Sur.jpg b/wallpapers/Big Sur.jpg deleted file mode 100644 index cebebf59..00000000 Binary files a/wallpapers/Big Sur.jpg and /dev/null differ diff --git a/wallpapers/Bocchi The Rock!.png b/wallpapers/Bocchi The Rock!.png deleted file mode 100644 index b2860b4d..00000000 Binary files a/wallpapers/Bocchi The Rock!.png and /dev/null differ diff --git a/wallpapers/Chinatown.png b/wallpapers/Chinatown.png deleted file mode 100644 index 20ce4311..00000000 Binary files a/wallpapers/Chinatown.png and /dev/null differ diff --git a/wallpapers/Frieren At The Funeral.jpg b/wallpapers/Frieren At The Funeral.jpg deleted file mode 100644 index f63acab8..00000000 Binary files a/wallpapers/Frieren At The Funeral.jpg and /dev/null differ diff --git a/wallpapers/Frieren Blue.jpeg b/wallpapers/Frieren Blue.jpeg deleted file mode 100644 index 31ede009..00000000 Binary files a/wallpapers/Frieren Blue.jpeg and /dev/null differ diff --git a/wallpapers/Frieren Night Film.jpeg b/wallpapers/Frieren Night Film.jpeg deleted file mode 100644 index f7029dc2..00000000 Binary files a/wallpapers/Frieren Night Film.jpeg and /dev/null differ diff --git a/wallpapers/Frieren Rain.jpg b/wallpapers/Frieren Rain.jpg deleted file mode 100644 index 183e802a..00000000 Binary files a/wallpapers/Frieren Rain.jpg and /dev/null differ diff --git a/wallpapers/Frieren Ring.jpeg b/wallpapers/Frieren Ring.jpeg deleted file mode 100644 index a23e7682..00000000 Binary files a/wallpapers/Frieren Ring.jpeg and /dev/null differ diff --git a/wallpapers/Frieren Sending Kiss.jpeg b/wallpapers/Frieren Sending Kiss.jpeg deleted file mode 100644 index 02e7287c..00000000 Binary files a/wallpapers/Frieren Sending Kiss.jpeg and /dev/null differ diff --git a/wallpapers/Frieren Sunset.jpeg b/wallpapers/Frieren Sunset.jpeg deleted file mode 100644 index 26315f08..00000000 Binary files a/wallpapers/Frieren Sunset.jpeg and /dev/null differ diff --git a/wallpapers/Frieren Underwater.jpg b/wallpapers/Frieren Underwater.jpg deleted file mode 100644 index a1558276..00000000 Binary files a/wallpapers/Frieren Underwater.jpg and /dev/null differ diff --git a/wallpapers/Garden Kita.png b/wallpapers/Garden Kita.png deleted file mode 100644 index b09392d8..00000000 Binary files a/wallpapers/Garden Kita.png and /dev/null differ diff --git a/wallpapers/Gruvbox Girl.png b/wallpapers/Gruvbox Girl.png deleted file mode 100644 index e96d7905..00000000 Binary files a/wallpapers/Gruvbox Girl.png and /dev/null differ diff --git a/wallpapers/Gumi Bridge.jpg b/wallpapers/Gumi Bridge.jpg deleted file mode 100644 index 6a2bc2c2..00000000 Binary files a/wallpapers/Gumi Bridge.jpg and /dev/null differ diff --git a/wallpapers/Gumi Forest Sunlight.jpg b/wallpapers/Gumi Forest Sunlight.jpg deleted file mode 100644 index 874c745e..00000000 Binary files a/wallpapers/Gumi Forest Sunlight.jpg and /dev/null differ diff --git a/wallpapers/Gumi Ocean Sunset.jpg b/wallpapers/Gumi Ocean Sunset.jpg deleted file mode 100644 index 615ad68b..00000000 Binary files a/wallpapers/Gumi Ocean Sunset.jpg and /dev/null differ diff --git a/wallpapers/Gumi Street Bike.jpg b/wallpapers/Gumi Street Bike.jpg deleted file mode 100644 index eeb0f764..00000000 Binary files a/wallpapers/Gumi Street Bike.jpg and /dev/null differ diff --git a/wallpapers/Gumi VOCALOID.png b/wallpapers/Gumi VOCALOID.png deleted file mode 100644 index 051d9e6c..00000000 Binary files a/wallpapers/Gumi VOCALOID.png and /dev/null differ diff --git a/wallpapers/Hatsune Miku Birthday!.png b/wallpapers/Hatsune Miku Birthday!.png deleted file mode 100644 index adae960f..00000000 Binary files a/wallpapers/Hatsune Miku Birthday!.png and /dev/null differ diff --git a/wallpapers/Hatsune Miku and Megurine Luka.jpg b/wallpapers/Hatsune Miku and Megurine Luka.jpg deleted file mode 100644 index 8410181e..00000000 Binary files a/wallpapers/Hatsune Miku and Megurine Luka.jpg and /dev/null differ diff --git a/wallpapers/Hitori Gotoh College Corridor.png b/wallpapers/Hitori Gotoh College Corridor.png deleted file mode 100644 index 0626a257..00000000 Binary files a/wallpapers/Hitori Gotoh College Corridor.png and /dev/null differ diff --git a/wallpapers/Hypr-chan.png b/wallpapers/Hypr-chan.png deleted file mode 100644 index 5a465efa..00000000 Binary files a/wallpapers/Hypr-chan.png and /dev/null differ diff --git a/wallpapers/Inabakumori Kaai Yuki.png b/wallpapers/Inabakumori Kaai Yuki.png deleted file mode 100644 index 7232d2dd..00000000 Binary files a/wallpapers/Inabakumori Kaai Yuki.png and /dev/null differ diff --git a/wallpapers/Inabakumori Osage.jpg b/wallpapers/Inabakumori Osage.jpg deleted file mode 100644 index e2b52589..00000000 Binary files a/wallpapers/Inabakumori Osage.jpg and /dev/null differ diff --git a/wallpapers/Kagamine Rin Yellow Tapes.png b/wallpapers/Kagamine Rin Yellow Tapes.png deleted file mode 100644 index b5d8f1cc..00000000 Binary files a/wallpapers/Kagamine Rin Yellow Tapes.png and /dev/null differ diff --git a/wallpapers/Kessoku Albums.jpeg b/wallpapers/Kessoku Albums.jpeg deleted file mode 100644 index bd7b8ac0..00000000 Binary files a/wallpapers/Kessoku Albums.jpeg and /dev/null differ diff --git a/wallpapers/Kessoku Band Reunited.jpg b/wallpapers/Kessoku Band Reunited.jpg deleted file mode 100644 index 1738cf0b..00000000 Binary files a/wallpapers/Kessoku Band Reunited.jpg and /dev/null differ diff --git a/wallpapers/Kessoku Band Rooftop.jpeg b/wallpapers/Kessoku Band Rooftop.jpeg deleted file mode 100644 index cffadb03..00000000 Binary files a/wallpapers/Kessoku Band Rooftop.jpeg and /dev/null differ diff --git a/wallpapers/Kikuri Hiroi.jpg b/wallpapers/Kikuri Hiroi.jpg deleted file mode 100644 index 2f87769e..00000000 Binary files a/wallpapers/Kikuri Hiroi.jpg and /dev/null differ diff --git a/wallpapers/Kita Street.jpeg b/wallpapers/Kita Street.jpeg deleted file mode 100644 index a5010211..00000000 Binary files a/wallpapers/Kita Street.jpeg and /dev/null differ diff --git a/wallpapers/Kita-chan!!.jpg b/wallpapers/Kita-chan!!.jpg deleted file mode 100644 index 43c0f688..00000000 Binary files a/wallpapers/Kita-chan!!.jpg and /dev/null differ diff --git a/wallpapers/Linux Anime Girl.png b/wallpapers/Linux Anime Girl.png deleted file mode 100644 index 5fa70653..00000000 Binary files a/wallpapers/Linux Anime Girl.png and /dev/null differ diff --git a/wallpapers/Miku Balloons.jpg b/wallpapers/Miku Balloons.jpg deleted file mode 100644 index 1d85d991..00000000 Binary files a/wallpapers/Miku Balloons.jpg and /dev/null differ diff --git a/wallpapers/Miku Bush.jpg b/wallpapers/Miku Bush.jpg deleted file mode 100644 index b7fa7ab1..00000000 Binary files a/wallpapers/Miku Bush.jpg and /dev/null differ diff --git a/wallpapers/Miku City Sky.png b/wallpapers/Miku City Sky.png deleted file mode 100644 index 77413dc8..00000000 Binary files a/wallpapers/Miku City Sky.png and /dev/null differ diff --git a/wallpapers/Miku Crying with Mask.jpg b/wallpapers/Miku Crying with Mask.jpg deleted file mode 100644 index 3f02402a..00000000 Binary files a/wallpapers/Miku Crying with Mask.jpg and /dev/null differ diff --git a/wallpapers/Miku Door.png b/wallpapers/Miku Door.png deleted file mode 100644 index a6d84427..00000000 Binary files a/wallpapers/Miku Door.png and /dev/null differ diff --git a/wallpapers/Miku Flower Field.jpg b/wallpapers/Miku Flower Field.jpg deleted file mode 100644 index 11147cbc..00000000 Binary files a/wallpapers/Miku Flower Field.jpg and /dev/null differ diff --git a/wallpapers/Miku Garden.jpg b/wallpapers/Miku Garden.jpg deleted file mode 100644 index d0b74a86..00000000 Binary files a/wallpapers/Miku Garden.jpg and /dev/null differ diff --git a/wallpapers/Miku Green Hair Glasses.png b/wallpapers/Miku Green Hair Glasses.png deleted file mode 100644 index 0cc78c5c..00000000 Binary files a/wallpapers/Miku Green Hair Glasses.png and /dev/null differ diff --git a/wallpapers/Miku Guitar.jpg b/wallpapers/Miku Guitar.jpg deleted file mode 100644 index cc908729..00000000 Binary files a/wallpapers/Miku Guitar.jpg and /dev/null differ diff --git a/wallpapers/Miku Setup.png b/wallpapers/Miku Setup.png deleted file mode 100644 index 30c7a2a6..00000000 Binary files a/wallpapers/Miku Setup.png and /dev/null differ diff --git a/wallpapers/Miku Stylish with Glasses.jpg b/wallpapers/Miku Stylish with Glasses.jpg deleted file mode 100644 index 9ef7254b..00000000 Binary files a/wallpapers/Miku Stylish with Glasses.jpg and /dev/null differ diff --git a/wallpapers/Miku Winter.jpg b/wallpapers/Miku Winter.jpg deleted file mode 100644 index dddccf6b..00000000 Binary files a/wallpapers/Miku Winter.jpg and /dev/null differ diff --git a/wallpapers/Miku, Rin and Luka Chibi.jpg b/wallpapers/Miku, Rin and Luka Chibi.jpg deleted file mode 100644 index aa573d0a..00000000 Binary files a/wallpapers/Miku, Rin and Luka Chibi.jpg and /dev/null differ diff --git a/wallpapers/Mualani!!.jpg b/wallpapers/Mualani!!.jpg deleted file mode 100755 index c2c2d100..00000000 Binary files a/wallpapers/Mualani!!.jpg and /dev/null differ diff --git a/wallpapers/Nijika Ijichi.jpg b/wallpapers/Nijika Ijichi.jpg deleted file mode 100644 index 97f05be1..00000000 Binary files a/wallpapers/Nijika Ijichi.jpg and /dev/null differ diff --git a/wallpapers/Nijika Train.jpeg b/wallpapers/Nijika Train.jpeg deleted file mode 100644 index 85f5920c..00000000 Binary files a/wallpapers/Nijika Train.jpeg and /dev/null differ diff --git a/wallpapers/Oshi no Ko Kana Arima.png b/wallpapers/Oshi no Ko Kana Arima.png deleted file mode 100644 index 3ac35a04..00000000 Binary files a/wallpapers/Oshi no Ko Kana Arima.png and /dev/null differ diff --git a/wallpapers/Pixel Girl Reading Book.png b/wallpapers/Pixel Girl Reading Book.png deleted file mode 100644 index f67c92ab..00000000 Binary files a/wallpapers/Pixel Girl Reading Book.png and /dev/null differ diff --git a/wallpapers/Ryo Vending Machine.png b/wallpapers/Ryo Vending Machine.png deleted file mode 100644 index 170791f2..00000000 Binary files a/wallpapers/Ryo Vending Machine.png and /dev/null differ diff --git a/wallpapers/Ryo Yamada Maid Dress.png b/wallpapers/Ryo Yamada Maid Dress.png deleted file mode 100644 index d75bcf83..00000000 Binary files a/wallpapers/Ryo Yamada Maid Dress.png and /dev/null differ diff --git a/wallpapers/Ryo Yamada.png b/wallpapers/Ryo Yamada.png deleted file mode 100644 index 52396819..00000000 Binary files a/wallpapers/Ryo Yamada.png and /dev/null differ diff --git a/wallpapers/Vocaloid Karaoke.jpg b/wallpapers/Vocaloid Karaoke.jpg deleted file mode 100644 index 36c66354..00000000 Binary files a/wallpapers/Vocaloid Karaoke.jpg and /dev/null differ