aboutsummaryrefslogtreecommitdiff
path: root/.config/ags/widget
diff options
context:
space:
mode:
Diffstat (limited to '.config/ags/widget')
-rw-r--r--.config/ags/widget/PopupWindow.ts156
-rw-r--r--.config/ags/widget/RegularWindow.ts3
-rw-r--r--.config/ags/widget/bar/Bar.ts57
-rw-r--r--.config/ags/widget/bar/PanelButton.ts46
-rw-r--r--.config/ags/widget/bar/ScreenCorners.ts25
-rw-r--r--.config/ags/widget/bar/bar.scss242
-rw-r--r--.config/ags/widget/bar/buttons/BatteryBar.ts94
-rw-r--r--.config/ags/widget/bar/buttons/ColorPicker.ts37
-rw-r--r--.config/ags/widget/bar/buttons/Date.ts15
-rw-r--r--.config/ags/widget/bar/buttons/Launcher.ts49
-rw-r--r--.config/ags/widget/bar/buttons/Media.ts92
-rw-r--r--.config/ags/widget/bar/buttons/Messages.ts16
-rw-r--r--.config/ags/widget/bar/buttons/PowerMenu.ts15
-rw-r--r--.config/ags/widget/bar/buttons/ScreenRecord.ts21
-rw-r--r--.config/ags/widget/bar/buttons/SysTray.ts39
-rw-r--r--.config/ags/widget/bar/buttons/SystemIndicators.ts107
-rw-r--r--.config/ags/widget/bar/buttons/Taskbar.ts90
-rw-r--r--.config/ags/widget/bar/buttons/Workspaces.ts66
-rw-r--r--.config/ags/widget/bar/screencorner.scss51
-rw-r--r--.config/ags/widget/datemenu/DateColumn.ts58
-rw-r--r--.config/ags/widget/datemenu/DateMenu.ts36
-rw-r--r--.config/ags/widget/datemenu/NotificationColumn.ts113
-rw-r--r--.config/ags/widget/datemenu/datemenu.scss110
-rw-r--r--.config/ags/widget/desktop/Desktop.ts40
-rw-r--r--.config/ags/widget/dock/Dock.ts150
-rw-r--r--.config/ags/widget/dock/FloatingDock.ts70
-rw-r--r--.config/ags/widget/dock/ToolBox.ts122
-rw-r--r--.config/ags/widget/dock/ToolBoxDock.ts57
-rw-r--r--.config/ags/widget/dock/dock.scss73
-rw-r--r--.config/ags/widget/launcher/AppLauncher.ts125
-rw-r--r--.config/ags/widget/launcher/Launcher.ts134
-rw-r--r--.config/ags/widget/launcher/NixRun.ts118
-rw-r--r--.config/ags/widget/launcher/ShRun.ts89
-rw-r--r--.config/ags/widget/launcher/launcher.scss143
-rw-r--r--.config/ags/widget/notifications/Notification.ts138
-rw-r--r--.config/ags/widget/notifications/NotificationPopups.ts90
-rw-r--r--.config/ags/widget/notifications/notifications.scss79
-rw-r--r--.config/ags/widget/osd/OSD.ts111
-rw-r--r--.config/ags/widget/osd/Progress.ts74
-rw-r--r--.config/ags/widget/osd/osd.scss26
-rw-r--r--.config/ags/widget/overview/Overview.ts41
-rw-r--r--.config/ags/widget/overview/Window.ts48
-rw-r--r--.config/ags/widget/overview/Workspace.ts76
-rw-r--r--.config/ags/widget/overview/overview.scss34
-rw-r--r--.config/ags/widget/powermenu/PowerMenu.ts56
-rw-r--r--.config/ags/widget/powermenu/Verification.ts47
-rw-r--r--.config/ags/widget/powermenu/powermenu.scss110
-rw-r--r--.config/ags/widget/quicksettings/QuickSettings.ts84
-rw-r--r--.config/ags/widget/quicksettings/ToggleButton.ts154
-rw-r--r--.config/ags/widget/quicksettings/quicksettings.scss177
-rw-r--r--.config/ags/widget/quicksettings/widgets/Bluetooth.ts61
-rw-r--r--.config/ags/widget/quicksettings/widgets/Brightness.ts23
-rw-r--r--.config/ags/widget/quicksettings/widgets/DND.ts12
-rw-r--r--.config/ags/widget/quicksettings/widgets/DarkMode.ts12
-rw-r--r--.config/ags/widget/quicksettings/widgets/Header.ts69
-rw-r--r--.config/ags/widget/quicksettings/widgets/Media.ts153
-rw-r--r--.config/ags/widget/quicksettings/widgets/MicMute.ts18
-rw-r--r--.config/ags/widget/quicksettings/widgets/Network.ts61
-rw-r--r--.config/ags/widget/quicksettings/widgets/PowerProfile.ts99
-rw-r--r--.config/ags/widget/quicksettings/widgets/Volume.ts161
-rw-r--r--.config/ags/widget/settings/Group.ts34
-rw-r--r--.config/ags/widget/settings/Page.ts19
-rw-r--r--.config/ags/widget/settings/Row.ts55
-rw-r--r--.config/ags/widget/settings/Setter.ts93
-rw-r--r--.config/ags/widget/settings/SettingsDialog.ts63
-rw-r--r--.config/ags/widget/settings/Wallpaper.ts31
-rw-r--r--.config/ags/widget/settings/layout.ts147
-rw-r--r--.config/ags/widget/settings/settingsdialog.scss144
68 files changed, 5259 insertions, 0 deletions
diff --git a/.config/ags/widget/PopupWindow.ts b/.config/ags/widget/PopupWindow.ts
new file mode 100644
index 0000000..b53b6fd
--- /dev/null
+++ b/.config/ags/widget/PopupWindow.ts
@@ -0,0 +1,156 @@
+import { type WindowProps } from "types/widgets/window"
+import { type RevealerProps } from "types/widgets/revealer"
+import { type EventBoxProps } from "types/widgets/eventbox"
+import type Gtk from "gi://Gtk?version=3.0"
+import options from "options"
+
+type Transition = RevealerProps["transition"]
+type Child = WindowProps["child"]
+
+type PopupWindowProps = Omit<WindowProps, "name"> & {
+ name: string
+ layout?: keyof ReturnType<typeof Layout>
+ transition?: Transition,
+}
+
+export const Padding = (name: string, {
+ css = "",
+ hexpand = true,
+ vexpand = true,
+}: EventBoxProps = {}) => Widget.EventBox({
+ hexpand,
+ vexpand,
+ can_focus: false,
+ child: Widget.Box({ css }),
+ setup: w => w.on("button-press-event", () => App.toggleWindow(name)),
+})
+
+const PopupRevealer = (
+ name: string,
+ child: Child,
+ transition: Transition = "slide_down",
+) => Widget.Box(
+ { css: "padding: 1px;" },
+ Widget.Revealer({
+ transition,
+ child: Widget.Box({
+ class_name: "window-content",
+ child,
+ }),
+ transitionDuration: options.transition.bind(),
+ setup: self => self.hook(App, (_, wname, visible) => {
+ if (wname === name)
+ self.reveal_child = visible
+ }),
+ }),
+)
+
+const Layout = (name: string, child: Child, transition?: Transition) => ({
+ "center": () => Widget.CenterBox({},
+ Padding(name),
+ Widget.CenterBox(
+ { vertical: true },
+ Padding(name),
+ PopupRevealer(name, child, transition),
+ Padding(name),
+ ),
+ Padding(name),
+ ),
+ "top": () => Widget.CenterBox({},
+ Padding(name),
+ Widget.Box(
+ { vertical: true },
+ PopupRevealer(name, child, transition),
+ Padding(name),
+ ),
+ Padding(name),
+ ),
+ "top-right": () => Widget.Box({},
+ Padding(name),
+ Widget.Box(
+ {
+ hexpand: false,
+ vertical: true,
+ },
+ PopupRevealer(name, child, transition),
+ Padding(name),
+ ),
+ ),
+ "top-center": () => Widget.Box({},
+ Padding(name),
+ Widget.Box(
+ {
+ hexpand: false,
+ vertical: true,
+ },
+ PopupRevealer(name, child, transition),
+ Padding(name),
+ ),
+ Padding(name),
+ ),
+ "top-left": () => Widget.Box({},
+ Widget.Box(
+ {
+ hexpand: false,
+ vertical: true,
+ },
+ PopupRevealer(name, child, transition),
+ Padding(name),
+ ),
+ Padding(name),
+ ),
+ "bottom-left": () => Widget.Box({},
+ Widget.Box(
+ {
+ hexpand: false,
+ vertical: true,
+ },
+ Padding(name),
+ PopupRevealer(name, child, transition),
+ ),
+ Padding(name),
+ ),
+ "bottom-center": () => Widget.Box({},
+ Padding(name),
+ Widget.Box(
+ {
+ hexpand: false,
+ vertical: true,
+ },
+ Padding(name),
+ PopupRevealer(name, child, transition),
+ ),
+ Padding(name),
+ ),
+ "bottom-right": () => Widget.Box({},
+ Padding(name),
+ Widget.Box(
+ {
+ hexpand: false,
+ vertical: true,
+ },
+ Padding(name),
+ PopupRevealer(name, child, transition),
+ ),
+ ),
+})
+
+export default ({
+ name,
+ child,
+ layout = "center",
+ transition,
+ exclusivity = "ignore",
+ ...props
+}: PopupWindowProps) => Widget.Window<Gtk.Widget>({
+ name,
+ class_names: [name, "popup-window"],
+ setup: w => w.keybind("Escape", () => App.closeWindow(name)),
+ visible: false,
+ keymode: "on-demand",
+ exclusivity,
+ layer: "top",
+ anchor: ["top", "bottom", "right", "left"],
+ child: Layout(name, child, transition)[layout](),
+ ...props,
+})
diff --git a/.config/ags/widget/RegularWindow.ts b/.config/ags/widget/RegularWindow.ts
new file mode 100644
index 0000000..1e4225d
--- /dev/null
+++ b/.config/ags/widget/RegularWindow.ts
@@ -0,0 +1,3 @@
+import Gtk from "gi://Gtk?version=3.0"
+
+export default Widget.subclass<typeof Gtk.Window, Gtk.Window.ConstructorProperties>(Gtk.Window)
diff --git a/.config/ags/widget/bar/Bar.ts b/.config/ags/widget/bar/Bar.ts
new file mode 100644
index 0000000..9343a36
--- /dev/null
+++ b/.config/ags/widget/bar/Bar.ts
@@ -0,0 +1,57 @@
+import BatteryBar from "./buttons/BatteryBar"
+import ColorPicker from "./buttons/ColorPicker"
+import Date from "./buttons/Date"
+import Launcher from "./buttons/Launcher"
+import Media from "./buttons/Media"
+import PowerMenu from "./buttons/PowerMenu"
+import SysTray from "./buttons/SysTray"
+import SystemIndicators from "./buttons/SystemIndicators"
+import Taskbar from "./buttons/Taskbar"
+import Workspaces from "./buttons/Workspaces"
+import ScreenRecord from "./buttons/ScreenRecord"
+import Messages from "./buttons/Messages"
+import options from "options"
+
+const { start, center, end } = options.bar.layout
+const pos = options.bar.position.bind()
+
+export type BarWidget = keyof typeof widget
+
+const widget = {
+ battery: BatteryBar,
+ colorpicker: ColorPicker,
+ date: Date,
+ launcher: Launcher,
+ media: Media,
+ powermenu: PowerMenu,
+ systray: SysTray,
+ system: SystemIndicators,
+ taskbar: Taskbar,
+ workspaces: Workspaces,
+ screenrecord: ScreenRecord,
+ messages: Messages,
+ expander: () => Widget.Box({ expand: true }),
+}
+
+export default (monitor: number) => Widget.Window({
+ monitor,
+ class_name: "bar",
+ name: `bar${monitor}`,
+ exclusivity: "exclusive",
+ anchor: pos.as(pos => [pos, "right", "left"]),
+ child: Widget.CenterBox({
+ css: "min-width: 2px; min-height: 2px;",
+ startWidget: Widget.Box({
+ hexpand: true,
+ children: start.bind().as(s => s.map(w => widget[w]())),
+ }),
+ centerWidget: Widget.Box({
+ hpack: "center",
+ children: center.bind().as(c => c.map(w => widget[w]())),
+ }),
+ endWidget: Widget.Box({
+ hexpand: true,
+ children: end.bind().as(e => e.map(w => widget[w]())),
+ }),
+ }),
+})
diff --git a/.config/ags/widget/bar/PanelButton.ts b/.config/ags/widget/bar/PanelButton.ts
new file mode 100644
index 0000000..332b46d
--- /dev/null
+++ b/.config/ags/widget/bar/PanelButton.ts
@@ -0,0 +1,46 @@
+import options from "options"
+import { ButtonProps } from "types/widgets/button"
+
+type PanelButtonProps = ButtonProps & {
+ window?: string,
+ flat?: boolean
+}
+
+export default ({
+ window = "",
+ flat,
+ child,
+ setup,
+ ...rest
+}: PanelButtonProps) => Widget.Button({
+ child: Widget.Box({ child }),
+ setup: self => {
+ let open = false
+
+ self.toggleClassName("panel-button")
+ self.toggleClassName(window)
+
+ self.hook(options.bar.flatButtons, () => {
+ self.toggleClassName("flat", flat ?? options.bar.flatButtons.value)
+ })
+
+ self.hook(App, (_, win, visible) => {
+ if (win !== window)
+ return
+
+ if (open && !visible) {
+ open = false
+ self.toggleClassName("active", false)
+ }
+
+ if (visible) {
+ open = true
+ self.toggleClassName("active")
+ }
+ })
+
+ if (setup)
+ setup(self)
+ },
+ ...rest,
+})
diff --git a/.config/ags/widget/bar/ScreenCorners.ts b/.config/ags/widget/bar/ScreenCorners.ts
new file mode 100644
index 0000000..1b35e50
--- /dev/null
+++ b/.config/ags/widget/bar/ScreenCorners.ts
@@ -0,0 +1,25 @@
+import options from "options"
+
+const { corners } = options.bar
+
+export default (monitor: number) => Widget.Window({
+ monitor,
+ name: `corner${monitor}`,
+ class_name: "screen-corner",
+ anchor: ["top", "bottom", "right", "left"],
+ click_through: true,
+ child: Widget.Box({
+ class_name: "shadow",
+ child: Widget.Box({
+ class_name: "border",
+ expand: true,
+ child: Widget.Box({
+ class_name: "corner",
+ expand: true,
+ }),
+ }),
+ }),
+ setup: self => self.hook(corners, () => {
+ self.toggleClassName("corners", corners.value)
+ }),
+})
diff --git a/.config/ags/widget/bar/bar.scss b/.config/ags/widget/bar/bar.scss
new file mode 100644
index 0000000..5c6c2cd
--- /dev/null
+++ b/.config/ags/widget/bar/bar.scss
@@ -0,0 +1,242 @@
+@use 'sass:color';
+
+$bar-spacing: $spacing * 0.3;
+$button-radius: $radius;
+
+@mixin panel-button($flat: true, $reactive: true) {
+ @include accs-button($flat, $reactive);
+
+ >* {
+ border-radius: $button-radius;
+ margin: $bar-spacing;
+ background-color: $bg;
+ }
+
+ label,
+ image {
+ font-weight: bold;
+ }
+
+ >* {
+ padding: $padding * 0.8 $padding * 1.2;
+ }
+}
+
+.bar {
+ .panel-button {
+ @include panel-button;
+
+ &:not(.flat) {
+ @include accs-button($flat: false);
+ }
+ }
+
+ .launcher {
+ .colored {
+ color: transparentize($primary-bg, 0.2);
+ }
+
+ &:hover .colored {
+ color: $primary-bg;
+ }
+
+ &:active .colored,
+ &.active .colored {
+ color: $primary-fg;
+ }
+ }
+
+ .workspaces {
+ label {
+ font-size: 0;
+ min-width: 5pt;
+ min-height: 5pt;
+ border-radius: $radius * 0.6;
+ box-shadow: inset 0 0 0 $border-width $border-color;
+ margin: 0 $padding * 0.5;
+ transition: $transition * 0.5;
+ background-color: transparentize($fg, 0.8);
+
+ &.occupied {
+ background-color: transparentize($fg, 0.2);
+ min-width: 7pt;
+ min-height: 7pt;
+ }
+
+ &.active {
+ // background-color: $primary-bg;
+ background-image: $active-gradient;
+ min-width: 20pt;
+ min-height: 12pt;
+ }
+
+ &.inctive {
+ // background-color: $primary-bg;
+ background-image: $active-gradient;
+ min-width: 20pt;
+ min-height: 12pt;
+ }
+ }
+
+ &.inactive,
+ &:active {
+ label {
+ background-color: transparentize($primary-fg, 0.3);
+
+ &.occupied {
+ background-color: transparentize($primary-fg, 0.15);
+ }
+
+ &.active {
+ background-color: $primary-fg;
+ }
+
+ &.inactive {
+ background-color: $primary-fg;
+ }
+ }
+ }
+ }
+
+ .media label {
+ margin: 0 ($spacing * 0.5);
+ }
+
+ .taskbar .indicator.active {
+ background-color: $primary-bg;
+ border-radius: $radius;
+ min-height: 4pt;
+ min-width: 6pt;
+ margin: 2pt;
+ }
+
+ .powermenu.colored,
+ .recorder {
+ image {
+ color: transparentize($error-bg, 0.3);
+ }
+
+ &:hover image {
+ color: transparentize($error-bg, 0.15);
+ }
+
+ &:active image {
+ color: $primary-fg;
+ }
+ }
+
+ .quicksettings>box>box {
+ @include spacing($spacing: if($bar-spacing==0, $padding / 2, $bar-spacing));
+ }
+
+ .quicksettings:not(.active):not(:active) {
+ .bluetooth {
+ color: $primary-bg;
+
+ label {
+ font-size: $font-size * 0.7;
+ color: $fg;
+ text-shadow: $text-shadow;
+ }
+ }
+ }
+
+ .battery-bar {
+ >* {
+ padding: 0;
+ }
+
+ &.bar-hidden>box {
+ padding: 0 $spacing * 0.5;
+
+ image {
+ margin: 0;
+ }
+ }
+
+ levelbar * {
+ all: unset;
+ transition: $transition;
+ }
+
+ .whole {
+ @if $shadows {
+ image {
+ -gtk-icon-shadow: $text-shadow;
+ }
+
+ label {
+ text-shadow: $text-shadow;
+ }
+ }
+ }
+
+ .regular image {
+ margin-left: $spacing * 0.5;
+ }
+
+ trough {
+ @include widget;
+ min-height: 12pt;
+ min-width: 12pt;
+ }
+
+ .regular trough {
+ margin-right: $spacing * 0.5;
+ }
+
+ block {
+ margin: 0;
+
+ &:last-child {
+ border-radius: 0 $button-radius $button-radius 0;
+ }
+
+ &:first-child {
+ border-radius: $button-radius 0 0 $button-radius;
+ }
+ }
+
+ .vertical {
+ block {
+ &:last-child {
+ border-radius: 0 0 $button-radius $button-radius;
+ }
+
+ &:first-child {
+ border-radius: $button-radius $button-radius 0 0;
+ }
+ }
+ }
+
+ @for $i from 1 through $bar-battery-blocks {
+ block:nth-child(#{$i}).filled {
+ background-color: color.mix($bg, $primary-bg, $i * 3);
+ }
+
+ &.low block:nth-child(#{$i}).filled {
+ background-color: color.mix($bg, $error-bg, $i * 3);
+ }
+
+ &.charging block:nth-child(#{$i}).filled {
+ background-color: color.mix($bg, $charging-bg, $i * 3);
+ }
+
+ &:active .regular block:nth-child(#{$i}).filled {
+ background-color: color.mix($bg, $primary-fg, $i * 3);
+ }
+ }
+
+ &.low image {
+ color: $error-bg;
+ }
+
+ &.charging image {
+ color: $charging-bg;
+ }
+
+ &:active image {
+ color: $primary-fg;
+ }
+ }
+}
diff --git a/.config/ags/widget/bar/buttons/BatteryBar.ts b/.config/ags/widget/bar/buttons/BatteryBar.ts
new file mode 100644
index 0000000..18de329
--- /dev/null
+++ b/.config/ags/widget/bar/buttons/BatteryBar.ts
@@ -0,0 +1,94 @@
+import icons from "lib/icons"
+import options from "options"
+import PanelButton from "../PanelButton"
+
+const battery = await Service.import("battery")
+const { bar, percentage, blocks, width, low } = options.bar.battery
+
+const Indicator = () => Widget.Icon({
+ setup: self => self.hook(battery, () => {
+ self.icon = battery.charging || battery.charged
+ ? icons.battery.charging
+ : battery.icon_name
+ }),
+})
+
+const PercentLabel = () => Widget.Revealer({
+ transition: "slide_right",
+ click_through: true,
+ reveal_child: percentage.bind(),
+ child: Widget.Label({
+ label: battery.bind("percent").as(p => `${p}%`),
+ }),
+})
+
+const LevelBar = () => {
+ const level = Widget.LevelBar({
+ bar_mode: "discrete",
+ max_value: blocks.bind(),
+ visible: bar.bind().as(b => b !== "hidden"),
+ value: battery.bind("percent").as(p => (p / 100) * blocks.value),
+ })
+ const update = () => {
+ level.value = (battery.percent / 100) * blocks.value
+ level.css = `block { min-width: ${width.value / blocks.value}pt; }`
+ }
+ return level
+ .hook(width, update)
+ .hook(blocks, update)
+ .hook(bar, () => {
+ level.vpack = bar.value === "whole" ? "fill" : "center"
+ level.hpack = bar.value === "whole" ? "fill" : "center"
+ })
+}
+
+const WholeButton = () => Widget.Overlay({
+ vexpand: true,
+ child: LevelBar(),
+ class_name: "whole",
+ pass_through: true,
+ overlay: Widget.Box({
+ hpack: "center",
+ children: [
+ Widget.Icon({
+ icon: icons.battery.charging,
+ visible: Utils.merge([
+ battery.bind("charging"),
+ battery.bind("charged"),
+ ], (ing, ed) => ing || ed),
+ }),
+ Widget.Box({
+ hpack: "center",
+ vpack: "center",
+ child: PercentLabel(),
+ }),
+ ],
+ }),
+})
+
+const Regular = () => Widget.Box({
+ class_name: "regular",
+ children: [
+ Indicator(),
+ PercentLabel(),
+ LevelBar(),
+ ],
+})
+
+export default () => PanelButton({
+ class_name: "battery-bar",
+ hexpand: false,
+ on_clicked: () => { percentage.value = !percentage.value },
+ visible: battery.bind("available"),
+ child: Widget.Box({
+ expand: true,
+ visible: battery.bind("available"),
+ child: bar.bind().as(b => b === "whole" ? WholeButton() : Regular()),
+ }),
+ setup: self => self
+ .hook(bar, w => w.toggleClassName("bar-hidden", bar.value === "hidden"))
+ .hook(battery, w => {
+ w.toggleClassName("charging", battery.charging || battery.charged)
+ w.toggleClassName("low", battery.percent < low.value)
+ }),
+})
diff --git a/.config/ags/widget/bar/buttons/ColorPicker.ts b/.config/ags/widget/bar/buttons/ColorPicker.ts
new file mode 100644
index 0000000..5b1f3f6
--- /dev/null
+++ b/.config/ags/widget/bar/buttons/ColorPicker.ts
@@ -0,0 +1,37 @@
+import PanelButton from "../PanelButton"
+import colorpicker from "service/colorpicker"
+import Gdk from "gi://Gdk"
+
+const css = (color: string) => `
+* {
+ background-color: ${color};
+ color: transparent;
+}
+*:hover {
+ color: white;
+ text-shadow: 2px 2px 3px rgba(0,0,0,.8);
+}`
+
+export default () => {
+ const menu = Widget.Menu({
+ class_name: "colorpicker",
+ children: colorpicker.bind("colors").as(c => c.map(color => Widget.MenuItem({
+ child: Widget.Label(color),
+ css: css(color),
+ on_activate: () => colorpicker.wlCopy(color),
+ }))),
+ })
+
+ return PanelButton({
+ class_name: "color-picker",
+ child: Widget.Icon("color-select-symbolic"),
+ tooltip_text: colorpicker.bind("colors").as(v => `${v.length} colors`),
+ on_clicked: colorpicker.pick,
+ on_secondary_click: self => {
+ if (colorpicker.colors.length === 0)
+ return
+
+ menu.popup_at_widget(self, Gdk.Gravity.SOUTH, Gdk.Gravity.NORTH, null)
+ },
+ })
+}
diff --git a/.config/ags/widget/bar/buttons/Date.ts b/.config/ags/widget/bar/buttons/Date.ts
new file mode 100644
index 0000000..44c2540
--- /dev/null
+++ b/.config/ags/widget/bar/buttons/Date.ts
@@ -0,0 +1,15 @@
+import { clock } from "lib/variables"
+import PanelButton from "../PanelButton"
+import options from "options"
+
+const { format, action } = options.bar.date
+const time = Utils.derive([clock, format], (c, f) => c.format(f) || "")
+
+export default () => PanelButton({
+ window: "datemenu",
+ on_clicked: action.bind(),
+ child: Widget.Label({
+ justification: "center",
+ label: time.bind(),
+ }),
+})
diff --git a/.config/ags/widget/bar/buttons/Launcher.ts b/.config/ags/widget/bar/buttons/Launcher.ts
new file mode 100644
index 0000000..f3fee6b
--- /dev/null
+++ b/.config/ags/widget/bar/buttons/Launcher.ts
@@ -0,0 +1,49 @@
+import PanelButton from "../PanelButton"
+import options from "options"
+import nix from "service/nix"
+
+const { icon, label, action } = options.bar.launcher
+
+function Spinner() {
+ const child = Widget.Icon({
+ icon: icon.icon.bind(),
+ class_name: Utils.merge([
+ icon.colored.bind(),
+ nix.bind("ready"),
+ ], (c, r) => `${c ? "colored" : ""} ${r ? "" : "spinning"}`),
+ css: `
+ @keyframes spin {
+ to { -gtk-icon-transform: rotate(1turn); }
+ }
+
+ image.spinning {
+ animation-name: spin;
+ animation-duration: 1s;
+ animation-timing-function: linear;
+ animation-iteration-count: infinite;
+ }
+ `,
+ })
+
+ return Widget.Revealer({
+ transition: "slide_left",
+ child,
+ reveal_child: Utils.merge([
+ icon.icon.bind(),
+ nix.bind("ready"),
+ ], (i, r) => Boolean(i || r)),
+ })
+}
+
+export default () => PanelButton({
+ window: "launcher",
+ on_clicked: action.bind(),
+ child: Widget.Box([
+ Spinner(),
+ Widget.Label({
+ class_name: label.colored.bind().as(c => c ? "colored" : ""),
+ visible: label.label.bind().as(v => !!v),
+ label: label.label.bind(),
+ }),
+ ]),
+})
diff --git a/.config/ags/widget/bar/buttons/Media.ts b/.config/ags/widget/bar/buttons/Media.ts
new file mode 100644
index 0000000..b3aab61
--- /dev/null
+++ b/.config/ags/widget/bar/buttons/Media.ts
@@ -0,0 +1,92 @@
+import { type MprisPlayer } from "types/service/mpris"
+import PanelButton from "../PanelButton"
+import options from "options"
+import icons from "lib/icons"
+import { icon } from "lib/utils"
+
+const mpris = await Service.import("mpris")
+const { length, direction, preferred, monochrome, format } = options.bar.media
+
+const getPlayer = (name = preferred.value) =>
+ mpris.getPlayer(name) || mpris.players[0] || null
+
+const Content = (player: MprisPlayer) => {
+ const revealer = Widget.Revealer({
+ click_through: true,
+ visible: length.bind().as(l => l > 0),
+ transition: direction.bind().as(d => `slide_${d}` as const),
+ setup: self => {
+ let current = ""
+ self.hook(player, () => {
+ if (current === player.track_title)
+ return
+
+ current = player.track_title
+ self.reveal_child = true
+ Utils.timeout(3000, () => {
+ !self.is_destroyed && (self.reveal_child = false)
+ })
+ })
+ },
+ child: Widget.Label({
+ truncate: "end",
+ max_width_chars: length.bind().as(n => n > 0 ? n : -1),
+ label: Utils.merge([
+ player.bind("track_title"),
+ player.bind("track_artists"),
+ format.bind(),
+ ], () => `${format}`
+ .replace("{title}", player.track_title)
+ .replace("{artists}", player.track_artists.join(", "))
+ .replace("{artist}", player.track_artists[0] || "")
+ .replace("{album}", player.track_album)
+ .replace("{name}", player.name)
+ .replace("{identity}", player.identity),
+ ),
+ }),
+ })
+
+ const playericon = Widget.Icon({
+ icon: Utils.merge([player.bind("entry"), monochrome.bind()], (entry => {
+ const name = `${entry}${monochrome.value ? "-symbolic" : ""}`
+ return icon(name, icons.fallback.audio)
+ })),
+ })
+
+ return Widget.Box({
+ attribute: { revealer },
+ children: direction.bind().as(d => d === "right"
+ ? [playericon, revealer] : [revealer, playericon]),
+ })
+}
+
+export default () => {
+ let player = getPlayer()
+
+ const btn = PanelButton({
+ class_name: "media",
+ child: Widget.Icon(icons.fallback.audio),
+ })
+
+ const update = () => {
+ player = getPlayer()
+ btn.visible = !!player
+
+ if (!player)
+ return
+
+ const content = Content(player)
+ const { revealer } = content.attribute
+ btn.child = content
+ btn.on_primary_click = () => { player.playPause() }
+ btn.on_secondary_click = () => { player.playPause() }
+ btn.on_scroll_up = () => { player.next() }
+ btn.on_scroll_down = () => { player.previous() }
+ btn.on_hover = () => { revealer.reveal_child = true }
+ btn.on_hover_lost = () => { revealer.reveal_child = false }
+ }
+
+ return btn
+ .hook(preferred, update)
+ .hook(mpris, update, "notify::players")
+}
diff --git a/.config/ags/widget/bar/buttons/Messages.ts b/.config/ags/widget/bar/buttons/Messages.ts
new file mode 100644
index 0000000..a8971e9
--- /dev/null
+++ b/.config/ags/widget/bar/buttons/Messages.ts
@@ -0,0 +1,16 @@
+import icons from "lib/icons"
+import PanelButton from "../PanelButton"
+import options from "options"
+
+const n = await Service.import("notifications")
+const notifs = n.bind("notifications")
+const action = options.bar.messages.action.bind()
+
+export default () => PanelButton({
+ class_name: "messages",
+ on_clicked: action,
+ visible: notifs.as(n => n.length > 0),
+ child: Widget.Box([
+ Widget.Icon(icons.notifications.message),
+ ]),
+})
diff --git a/.config/ags/widget/bar/buttons/PowerMenu.ts b/.config/ags/widget/bar/buttons/PowerMenu.ts
new file mode 100644
index 0000000..4432ade
--- /dev/null
+++ b/.config/ags/widget/bar/buttons/PowerMenu.ts
@@ -0,0 +1,15 @@
+import icons from "lib/icons"
+import PanelButton from "../PanelButton"
+import options from "options"
+
+const { monochrome, action } = options.bar.powermenu
+
+export default () => PanelButton({
+ window: "powermenu",
+ on_clicked: action.bind(),
+ child: Widget.Icon(icons.powermenu.shutdown),
+ setup: self => self.hook(monochrome, () => {
+ self.toggleClassName("colored", !monochrome.value)
+ self.toggleClassName("box")
+ }),
+})
diff --git a/.config/ags/widget/bar/buttons/ScreenRecord.ts b/.config/ags/widget/bar/buttons/ScreenRecord.ts
new file mode 100644
index 0000000..1d6eb36
--- /dev/null
+++ b/.config/ags/widget/bar/buttons/ScreenRecord.ts
@@ -0,0 +1,21 @@
+import PanelButton from "../PanelButton"
+import screenrecord from "service/screenrecord"
+import icons from "lib/icons"
+
+export default () => PanelButton({
+ class_name: "recorder",
+ on_clicked: () => screenrecord.stop(),
+ visible: screenrecord.bind("recording"),
+ child: Widget.Box({
+ children: [
+ Widget.Icon(icons.recorder.recording),
+ Widget.Label({
+ label: screenrecord.bind("timer").as(time => {
+ const sec = time % 60
+ const min = Math.floor(time / 60)
+ return `${min}:${sec < 10 ? "0" + sec : sec}`
+ }),
+ }),
+ ],
+ }),
+})
diff --git a/.config/ags/widget/bar/buttons/SysTray.ts b/.config/ags/widget/bar/buttons/SysTray.ts
new file mode 100644
index 0000000..9f569d1
--- /dev/null
+++ b/.config/ags/widget/bar/buttons/SysTray.ts
@@ -0,0 +1,39 @@
+import { type TrayItem } from "types/service/systemtray"
+import PanelButton from "../PanelButton"
+import Gdk from "gi://Gdk"
+import options from "options"
+
+const systemtray = await Service.import("systemtray")
+const { ignore } = options.bar.systray
+
+const SysTrayItem = (item: TrayItem) => PanelButton({
+ class_name: "tray-item",
+ child: Widget.Icon({ icon: item.bind("icon") }),
+ tooltip_markup: item.bind("tooltip_markup"),
+ setup: self => {
+ const { menu } = item
+ if (!menu)
+ return
+
+ const id = menu.connect("popped-up", () => {
+ self.toggleClassName("active")
+ menu.connect("notify::visible", () => {
+ self.toggleClassName("active", menu.visible)
+ })
+ menu.disconnect(id!)
+ })
+
+ self.connect("destroy", () => menu.disconnect(id))
+ },
+
+ on_primary_click: btn => item.menu?.popup_at_widget(
+ btn, Gdk.Gravity.SOUTH, Gdk.Gravity.NORTH, null),
+
+ on_secondary_click: btn => item.menu?.popup_at_widget(
+ btn, Gdk.Gravity.SOUTH, Gdk.Gravity.NORTH, null),
+})
+
+export default () => Widget.Box()
+ .bind("children", systemtray, "items", i => i
+ .filter(({ id }) => !ignore.value.includes(id))
+ .map(SysTrayItem))
diff --git a/.config/ags/widget/bar/buttons/SystemIndicators.ts b/.config/ags/widget/bar/buttons/SystemIndicators.ts
new file mode 100644
index 0000000..cc98548
--- /dev/null
+++ b/.config/ags/widget/bar/buttons/SystemIndicators.ts
@@ -0,0 +1,107 @@
+import PanelButton from '../PanelButton';
+import icons from 'lib/icons';
+import asusctl from 'service/asusctl';
+
+const notifications = await Service.import('notifications');
+const bluetooth = await Service.import('bluetooth');
+const audio = await Service.import('audio');
+const network = await Service.import('network');
+const powerprof = await Service.import('powerprofiles');
+
+const ProfileIndicator = () => {
+ const visible = asusctl.available ? asusctl.bind('profile').as(p => p !== 'Balanced') : powerprof.bind('active_profile').as(p => p !== 'balanced');
+
+ const icon = asusctl.available ? asusctl.bind('profile').as(p => icons.asusctl.profile[p]) : powerprof.bind('active_profile').as(p => icons.powerprofile[p]);
+
+ return Widget.Icon({ visible, icon });
+};
+
+const ModeIndicator = () => {
+ if (!asusctl.available) {
+ return Widget.Icon({
+ setup(self) {
+ Utils.idle(() => (self.visible = false));
+ },
+ });
+ }
+
+ return Widget.Icon({
+ visible: asusctl.bind('mode').as(m => m !== 'Hybrid'),
+ icon: asusctl.bind('mode').as(m => icons.asusctl.mode[m]),
+ });
+};
+
+const MicrophoneIndicator = () =>
+ Widget.Icon()
+ .hook(audio, self => (self.visible = audio.recorders.length > 0 || audio.microphone.is_muted || false))
+ .hook(audio.microphone, self => {
+ const vol = audio.microphone.is_muted ? 0 : audio.microphone.volume;
+ const { muted, low, medium, high } = icons.audio.mic;
+ const cons = [
+ [67, high],
+ [34, medium],
+ [1, low],
+ [0, muted],
+ ] as const;
+ self.icon = cons.find(([n]) => n <= vol * 100)?.[1] || '';
+ });
+
+const DNDIndicator = () =>
+ Widget.Icon({
+ visible: notifications.bind('dnd'),
+ icon: icons.notifications.silent,
+ });
+
+const BluetoothIndicator = () =>
+ Widget.Overlay({
+ class_name: 'bluetooth',
+ passThrough: true,
+ child: Widget.Icon({
+ icon: icons.bluetooth.enabled,
+ visible: bluetooth.bind('enabled'),
+ }),
+ overlay: Widget.Label({
+ hpack: 'end',
+ vpack: 'start',
+ label: bluetooth.bind('connected_devices').as(c => `${c.length}`),
+ visible: bluetooth.bind('connected_devices').as(c => c.length > 0),
+ }),
+ });
+
+const NetworkIndicator = () =>
+ Widget.Icon().hook(network, self => {
+ const icon = network[network.primary || 'wifi']?.icon_name;
+ self.icon = icon || '';
+ self.visible = !!icon;
+ });
+
+const AudioIndicator = () =>
+ Widget.Icon().hook(audio.speaker, self => {
+ const vol = audio.speaker.is_muted ? 0 : audio.speaker.volume;
+ const { muted, low, medium, high, overamplified } = icons.audio.volume;
+ const cons = [
+ [101, overamplified],
+ [67, high],
+ [34, medium],
+ [1, low],
+ [0, muted],
+ ] as const;
+ self.icon = cons.find(([n]) => n <= vol * 100)?.[1] || '';
+ });
+
+export default () =>
+ PanelButton({
+ window: 'quicksettings',
+ on_clicked: () => App.toggleWindow('quicksettings'),
+ on_scroll_up: () => (audio.speaker.volume += 0.02),
+ on_scroll_down: () => (audio.speaker.volume -= 0.02),
+ child: Widget.Box([
+ //ProfileIndicator(),
+ ModeIndicator(),
+ DNDIndicator(),
+ BluetoothIndicator(),
+ MicrophoneIndicator(),
+ AudioIndicator(),
+ NetworkIndicator(),
+ ]),
+ });
diff --git a/.config/ags/widget/bar/buttons/Taskbar.ts b/.config/ags/widget/bar/buttons/Taskbar.ts
new file mode 100644
index 0000000..b9c65fa
--- /dev/null
+++ b/.config/ags/widget/bar/buttons/Taskbar.ts
@@ -0,0 +1,90 @@
+import { launchApp, icon } from "lib/utils"
+import icons from "lib/icons"
+import options from "options"
+import PanelButton from "../PanelButton"
+
+const hyprland = await Service.import("hyprland")
+const apps = await Service.import("applications")
+const { monochrome, exclusive, iconSize } = options.bar.taskbar
+const { position } = options.bar
+
+const focus = (address: string) => hyprland.messageAsync(
+ `dispatch focuswindow address:${address}`)
+
+const DummyItem = (address: string) => Widget.Box({
+ attribute: { address },
+ visible: false,
+})
+
+const AppItem = (address: string) => {
+ const client = hyprland.getClient(address)
+ if (!client || client.class === "")
+ return DummyItem(address)
+
+ const app = apps.list.find(app => app.match(client.class))
+
+ const btn = PanelButton({
+ class_name: "panel-button",
+ tooltip_text: Utils.watch(client.title, hyprland, () =>
+ hyprland.getClient(address)?.title || "",
+ ),
+ on_primary_click: () => focus(address),
+ on_middle_click: () => app && launchApp(app),
+ child: Widget.Icon({
+ size: iconSize.bind(),
+ icon: monochrome.bind().as(m => icon(
+ (app?.icon_name || client.class) + (m ? "-symbolic" : ""),
+ icons.fallback.executable + (m ? "-symbolic" : ""),
+ )),
+ }),
+ })
+
+ return Widget.Box(
+ {
+ attribute: { address },
+ visible: Utils.watch(true, [exclusive, hyprland], () => {
+ return exclusive.value
+ ? hyprland.active.workspace.id === client.workspace.id
+ : true
+ }),
+ },
+ Widget.Overlay({
+ child: btn,
+ pass_through: true,
+ overlay: Widget.Box({
+ className: "indicator",
+ hpack: "center",
+ vpack: position.bind().as(p => p === "top" ? "start" : "end"),
+ setup: w => w.hook(hyprland, () => {
+ w.toggleClassName("active", hyprland.active.client.address === address)
+ }),
+ }),
+ }),
+ )
+}
+
+function sortItems<T extends { attribute: { address: string } }>(arr: T[]) {
+ return arr.sort(({ attribute: a }, { attribute: b }) => {
+ const aclient = hyprland.getClient(a.address)!
+ const bclient = hyprland.getClient(b.address)!
+ return aclient.workspace.id - bclient.workspace.id
+ })
+}
+
+export default () => Widget.Box({
+ class_name: "taskbar",
+ children: sortItems(hyprland.clients.map(c => AppItem(c.address))),
+ setup: w => w
+ .hook(hyprland, (w, address?: string) => {
+ if (typeof address === "string")
+ w.children = w.children.filter(ch => ch.attribute.address !== address)
+ }, "client-removed")
+ .hook(hyprland, (w, address?: string) => {
+ if (typeof address === "string")
+ w.children = sortItems([...w.children, AppItem(address)])
+ }, "client-added")
+ .hook(hyprland, (w, event?: string) => {
+ if (event === "movewindow")
+ w.children = sortItems(w.children)
+ }, "event"),
+})
diff --git a/.config/ags/widget/bar/buttons/Workspaces.ts b/.config/ags/widget/bar/buttons/Workspaces.ts
new file mode 100644
index 0000000..a59f61b
--- /dev/null
+++ b/.config/ags/widget/bar/buttons/Workspaces.ts
@@ -0,0 +1,66 @@
+import PanelButton from '../PanelButton';
+import options from 'options';
+import { sh, range } from 'lib/utils';
+
+const hyprland = await Service.import('hyprland');
+const { workspaces } = options.bar.workspaces;
+
+const dispatch = arg => {
+ sh(`hyprctl dispatch workspace ${arg}`);
+};
+
+const Workspaces = ws =>
+ Widget.Box({
+ children: range(ws || 20).map(i =>
+ Widget.Label({
+ attribute: i,
+ vpack: 'center',
+ label: `${i}`,
+ setup: self => {
+ const updateState = () => {
+ const monitorData = JSON.parse(hyprland.message('j/monitors'));
+ const activeWorkspaceId = monitorData[0]?.activeWorkspace?.id;
+ const workspaceData = hyprland.getWorkspace(i);
+
+ if (activeWorkspaceId !== undefined) {
+ self.toggleClassName('active', activeWorkspaceId === i);
+ }
+ self.toggleClassName('occupied', (workspaceData?.windows || 0) > 0);
+ };
+
+ // Hook to Hyprland for updates
+ self.hook(hyprland, updateState);
+
+ // Initial update
+ updateState();
+ },
+ }),
+ ),
+ setup: box => {
+ box.hook(hyprland, () => {
+ const monitorData = JSON.parse(hyprland.message('j/monitors'));
+ const activeWorkspaceId = monitorData[0]?.activeWorkspace?.id;
+
+ if (activeWorkspaceId !== undefined) {
+ for (const btn of box.children) {
+ const workspaceId = btn.attribute;
+ btn.toggleClassName('active', workspaceId === activeWorkspaceId);
+
+ if (ws === 0) {
+ btn.visible = hyprland.workspaces.some(workspace => workspace.id === workspaceId);
+ }
+ }
+ }
+ });
+ },
+ });
+
+export default () =>
+ PanelButton({
+ window: 'overview',
+ class_name: 'workspaces',
+ on_scroll_up: () => dispatch('m+1'),
+ on_scroll_down: () => dispatch('m-1'),
+ on_clicked: () => App.toggleWindow('overview'),
+ child: workspaces.bind().as(Workspaces),
+ });
diff --git a/.config/ags/widget/bar/screencorner.scss b/.config/ags/widget/bar/screencorner.scss
new file mode 100644
index 0000000..93cd459
--- /dev/null
+++ b/.config/ags/widget/bar/screencorner.scss
@@ -0,0 +1,51 @@
+$_shadow-size: $padding;
+$_radius: $radius * $hyprland-gaps-multiplier;
+$_margin: 99px;
+
+window.screen-corner {
+ box.shadow {
+ margin-right: $_margin * -1;
+ margin-left: $_margin * -1;
+
+ @if $shadows {
+ box-shadow: inset 0 0 $_shadow-size 0 transparent;
+ }
+
+ @if $bar-position =='top' {
+ margin-bottom: $_margin * -1;
+ }
+
+ @if $bar-position =='bottom' {
+ margin-top: $_margin * -1;
+ }
+ }
+
+ box.border {
+ @if $bar-position =='top' {
+ border-top: $border-width none $bg;
+ //border-top: $border-width solid $bg;
+ }
+
+ @if $bar-position =='bottom' {
+ border-bottom: $border-width solid $bg;
+ }
+
+ margin-right: $_margin;
+ margin-left: $_margin;
+ }
+
+ box.corner {
+ box-shadow: 0 0 0 $border-width $border-color;
+ }
+
+ &.corners {
+ box.border {
+ border-radius: if($radius>0, $radius * $hyprland-gaps-multiplier, 0);
+ box-shadow: 0 0 0 $_radius $bg;
+ }
+
+ box.corner {
+ border-radius: if($radius>0, $radius * $hyprland-gaps-multiplier, 0);
+ }
+ }
+}
diff --git a/.config/ags/widget/datemenu/DateColumn.ts b/.config/ags/widget/datemenu/DateColumn.ts
new file mode 100644
index 0000000..a462302
--- /dev/null
+++ b/.config/ags/widget/datemenu/DateColumn.ts
@@ -0,0 +1,58 @@
+import { clock, uptime } from 'lib/variables';
+import GLib from 'gi://GLib';
+import Gtk from 'gi://Gtk';
+
+function up(up: number) {
+ const h = Math.floor(up / 60);
+ const m = Math.floor(up % 60);
+ return `uptime: ${h}:${m < 10 ? '0' + m : m}`;
+}
+
+export default () =>
+ Widget.Box({
+ vertical: true,
+ class_name: 'date-column vertical',
+ children: [
+ Widget.Box({
+ class_name: 'clock-box',
+ vertical: true,
+ children: [
+ Widget.Label({
+ class_name: 'clock',
+ label: clock.bind().as(t => t.format('%H:%M')!),
+ }),
+ Widget.Label({
+ class_name: 'uptime',
+ label: uptime.bind().as(up),
+ }),
+ ],
+ }),
+ Widget.Box({
+ class_name: 'calendar',
+ children: [
+ (() => {
+ const calendar = Widget.Calendar({
+ hexpand: true,
+ hpack: 'center',
+ });
+
+ // Get today's date and mark it
+ const today = new Date();
+ calendar.select_day(today.getDate());
+ calendar.select_month(today.getMonth(), today.getFullYear());
+ calendar.mark_day(today.getDate()); // This should trigger styling
+
+ // Prevent scrolling from triggering GNOME Calendar
+ const eventBox = Widget.EventBox({
+ child: calendar,
+ onPrimaryClick: () => {
+ GLib.spawn_command_line_async('gnome-calendar');
+ },
+ });
+
+ return eventBox;
+ })(),
+ ],
+ }),
+ ],
+ });
diff --git a/.config/ags/widget/datemenu/DateMenu.ts b/.config/ags/widget/datemenu/DateMenu.ts
new file mode 100644
index 0000000..f7fdf6d
--- /dev/null
+++ b/.config/ags/widget/datemenu/DateMenu.ts
@@ -0,0 +1,36 @@
+import PopupWindow from "widget/PopupWindow"
+import NotificationColumn from "./NotificationColumn"
+import DateColumn from "./DateColumn"
+import options from "options"
+
+const { bar, datemenu } = options
+const pos = bar.position.bind()
+const layout = Utils.derive([bar.position, datemenu.position], (bar, qs) =>
+ `${bar}-${qs}` as const,
+)
+
+const Settings = () => Widget.Box({
+ class_name: "datemenu horizontal",
+ vexpand: false,
+ children: [
+ NotificationColumn(),
+ Widget.Separator({ orientation: 1 }),
+ DateColumn(),
+ ],
+})
+
+const DateMenu = () => PopupWindow({
+ name: "datemenu",
+ exclusivity: "exclusive",
+ transition: pos.as(pos => pos === "top" ? "slide_down" : "slide_up"),
+ layout: layout.value,
+ child: Settings(),
+})
+
+export function setupDateMenu() {
+ App.addWindow(DateMenu())
+ layout.connect("changed", () => {
+ App.removeWindow("datemenu")
+ App.addWindow(DateMenu())
+ })
+}
diff --git a/.config/ags/widget/datemenu/NotificationColumn.ts b/.config/ags/widget/datemenu/NotificationColumn.ts
new file mode 100644
index 0000000..07d6829
--- /dev/null
+++ b/.config/ags/widget/datemenu/NotificationColumn.ts
@@ -0,0 +1,113 @@
+import { type Notification as Notif } from "types/service/notifications"
+import Notification from "widget/notifications/Notification"
+import options from "options"
+import icons from "lib/icons"
+
+const notifications = await Service.import("notifications")
+const notifs = notifications.bind("notifications")
+
+const Animated = (n: Notif) => Widget.Revealer({
+ transition_duration: options.transition.value,
+ transition: "slide_down",
+ child: Notification(n),
+ setup: self => Utils.timeout(options.transition.value, () => {
+ if (!self.is_destroyed)
+ self.reveal_child = true
+ }),
+})
+
+const ClearButton = () => Widget.Button({
+ on_clicked: notifications.clear,
+ sensitive: notifs.as(n => n.length > 0),
+ child: Widget.Box({
+ children: [
+ Widget.Label("Clear "),
+ Widget.Icon({
+ icon: notifs.as(n => icons.trash[n.length > 0 ? "full" : "empty"]),
+ }),
+ ],
+ }),
+})
+
+const Header = () => Widget.Box({
+ class_name: "header",
+ children: [
+ Widget.Label({ label: "Notifications", hexpand: true, xalign: 0 }),
+ ClearButton(),
+ ],
+})
+
+const NotificationList = () => {
+ const map: Map<number, ReturnType<typeof Animated>> = new Map
+ const box = Widget.Box({
+ vertical: true,
+ children: notifications.notifications.map(n => {
+ const w = Animated(n)
+ map.set(n.id, w)
+ return w
+ }),
+ visible: notifs.as(n => n.length > 0),
+ })
+
+ function remove(_: unknown, id: number) {
+ const n = map.get(id)
+ if (n) {
+ n.reveal_child = false
+ Utils.timeout(options.transition.value, () => {
+ n.destroy()
+ map.delete(id)
+ })
+ }
+ }
+
+ return box
+ .hook(notifications, remove, "closed")
+ .hook(notifications, (_, id: number) => {
+ if (id !== undefined) {
+ if (map.has(id))
+ remove(null, id)
+
+ const n = notifications.getNotification(id)!
+
+ const w = Animated(n)
+ map.set(id, w)
+ box.children = [w, ...box.children]
+ }
+ }, "notified")
+}
+
+const Placeholder = () => Widget.Box({
+ class_name: "placeholder",
+ vertical: true,
+ vpack: "center",
+ hpack: "center",
+ vexpand: true,
+ hexpand: true,
+ visible: notifs.as(n => n.length === 0),
+ children: [
+ Widget.Icon(icons.notifications.silent),
+ Widget.Label("Your inbox is empty"),
+ ],
+})
+
+export default () => Widget.Box({
+ class_name: "notifications",
+ css: options.notifications.width.bind().as(w => `min-width: ${w}px`),
+ vertical: true,
+ children: [
+ Header(),
+ Widget.Scrollable({
+ vexpand: true,
+ hscroll: "never",
+ class_name: "notification-scrollable",
+ child: Widget.Box({
+ class_name: "notification-list vertical",
+ vertical: true,
+ children: [
+ NotificationList(),
+ Placeholder(),
+ ],
+ }),
+ }),
+ ],
+})
diff --git a/.config/ags/widget/datemenu/datemenu.scss b/.config/ags/widget/datemenu/datemenu.scss
new file mode 100644
index 0000000..6fd9257
--- /dev/null
+++ b/.config/ags/widget/datemenu/datemenu.scss
@@ -0,0 +1,110 @@
+@import "../notifications/notifications.scss";
+
+@mixin calendar {
+ @include widget;
+ padding: $padding*2 $padding*2 0;
+
+ calendar {
+ all: unset;
+
+ &.button {
+ @include button($flat: true);
+ }
+
+ &:selected {
+ box-shadow: inset 0 -8px 0 0 transparentize($primary-bg, 0.5),
+ inset 0 0 0 1px $primary-bg;
+ border-radius: $radius*0.6;
+ }
+
+ &.header {
+ background-color: transparent;
+ border: none;
+ color: transparentize($fg, 0.5);
+ }
+
+ &.highlight {
+ background-color: transparent;
+ color: transparentize($primary-bg, 0.5);
+ }
+
+ &:indeterminate {
+ color: transparentize($fg, 0.9);
+ }
+
+ font-size: 1.1em;
+ padding: .2em;
+ }
+}
+
+window#datemenu .datemenu {
+ @include floating-widget;
+
+ .notifications {
+ .header {
+ margin-bottom: $spacing;
+ margin-right: $spacing;
+
+ >label {
+ margin-left: $radius * .5;
+ }
+
+ button {
+ @include button;
+ padding: $padding*.7 $padding;
+ }
+ }
+
+ .notification-scrollable {
+ @include scrollable($top: true, $bottom: true);
+ }
+
+ .notification-list {
+ margin-right: $spacing;
+ }
+
+ .notification {
+ @include notification;
+ @include widget;
+ padding: $padding;
+ margin-bottom: $spacing;
+ }
+
+ .placeholder {
+ image {
+ font-size: 7em;
+ }
+
+ label {
+ font-size: 1.2em;
+ }
+ }
+ }
+
+
+ separator {
+ background-color: $popover-border-color;
+ border-radius: $radius;
+ margin-right: $spacing;
+ }
+
+ .datemenu {
+ @include spacing;
+ }
+
+ .clock-box {
+ padding: $padding;
+
+ .clock {
+ font-size: 5em;
+ }
+
+ .uptime {
+ color: transparentize($fg, 0.2);
+ }
+ }
+
+ .calendar {
+ @include calendar;
+ }
+}
diff --git a/.config/ags/widget/desktop/Desktop.ts b/.config/ags/widget/desktop/Desktop.ts
new file mode 100644
index 0000000..f711967
--- /dev/null
+++ b/.config/ags/widget/desktop/Desktop.ts
@@ -0,0 +1,40 @@
+import options from "options"
+import { matugen } from "lib/matugen"
+const mpris = await Service.import("mpris")
+
+const pref = () => options.bar.media.preferred.value
+
+export default (monitor: number) => Widget.Window({
+ monitor,
+ layer: "bottom",
+ name: `desktop${monitor}`,
+ class_name: "desktop",
+ anchor: ["top", "bottom", "left", "right"],
+ child: Widget.Box({
+ expand: true,
+ css: options.theme.dark.primary.bg.bind().as(c => `
+ transition: 500ms;
+ background-color: ${c}`),
+ child: Widget.Box({
+ class_name: "wallpaper",
+ expand: true,
+ vpack: "center",
+ hpack: "center",
+ setup: self => self
+ .hook(mpris, () => {
+ const img = mpris.getPlayer(pref())!.cover_path
+ matugen("image", img)
+ Utils.timeout(500, () => self.css = `
+ background-image: url('${img}');
+ background-size: contain;
+ background-repeat: no-repeat;
+ transition: 200ms;
+ min-width: 700px;
+ min-height: 700px;
+ border-radius: 30px;
+ box-shadow: 25px 25px 30px 0 rgba(0,0,0,0.5);`,
+ )
+ }),
+ }),
+ }),
+})
diff --git a/.config/ags/widget/dock/Dock.ts b/.config/ags/widget/dock/Dock.ts
new file mode 100644
index 0000000..c55f89f
--- /dev/null
+++ b/.config/ags/widget/dock/Dock.ts
@@ -0,0 +1,150 @@
+import { launchApp } from "lib/utils";
+import options from "options";
+import * as Gtk from "gi://Gtk?version=3.0";
+import { type ButtonProps } from "types/widgets/button";
+import { type BoxProps } from "types/widgets/box";
+
+const hyprland = await Service.import("hyprland");
+const applications = await Service.import("applications");
+
+const focus = (address: string) => hyprland.messageAsync(`dispatch focuswindow address:${address}`);
+
+const AppButton = ({ icon, pinned = false, term, ...rest }: ButtonProps & { term?: string }): Gtk.Button & ButtonProps => {
+ const { iconSize } = options.dock;
+
+ const buttonBox = Widget.Box({
+ class_name: 'box',
+ child: Widget.Icon({
+ icon,
+ size: iconSize,
+ }),
+ });
+
+ const button = Widget.Button({
+ ...rest,
+ class_name: '',
+ child: pinned ? buttonBox : Widget.Overlay({
+ child: buttonBox,
+ pass_through: false,
+ overlays: [],
+ }),
+ });
+
+ return Object.assign(button, {});
+};
+
+const createAppButton = ({ app, term, ...params }) => {
+ return AppButton({
+ icon: app.icon_name || '',
+ term,
+ ...params,
+ });
+};
+
+const filterValidClients = (clients: any[]) => {
+ return clients.filter(client => (
+ client.mapped &&
+ [client.class, client.title, client.initialClass].every(prop => typeof prop === 'string' && prop !== '')
+ ));
+};
+
+const Taskbar = (): Gtk.Box & BoxProps => {
+ const addedApps = new Set<string>();
+
+ const updateTaskbar = (clients: any[]) => {
+ const validClients = filterValidClients(clients);
+
+ const focusedAddress = hyprland?.active.client?.address;
+ const running = validClients.filter(client => client.mapped);
+ const focused = running.find(client => client.address === focusedAddress);
+
+ return validClients.map(client => {
+ if (![client.class, client.title, client.initialClass].every(prop => typeof prop === 'string' && prop !== '')) {
+ return null;
+ }
+
+ if (addedApps.has(client.title)) {
+ return null;
+ }
+
+ for (const appName of options.dock.pinnedApps.value) {
+ if (!appName || typeof appName !== 'string') {
+ continue;
+ }
+
+ if (client.class.includes(appName) || client.title.includes(appName)
+ || client.initialClass.includes(appName)) {
+ return null;
+ }
+ }
+
+ const matchingApp = applications?.list.find(app => (
+ app.match(client.title) || app.match(client.class) || app.match(client.initialClass)
+ ));
+
+ if (matchingApp) {
+ addedApps.add(client.title);
+ return createAppButton({
+ app: matchingApp,
+ term: matchingApp.title,
+ on_primary_click: () => {
+ const clickAddress = client.address || focusedAddress;
+ clickAddress && focus(clickAddress);
+ },
+ on_secondary_click: () => launchApp(matchingApp),
+ });
+ a
+ }
+ return null;
+ });
+ };
+
+ return Widget.Box({
+ vertical: false,
+ })
+ .bind('children', hyprland, 'clients', updateTaskbar);
+};
+
+const PinnedApps = (): Gtk.Box & BoxProps => {
+ const updatePinnedApps = (pinnedApps: string[]) => {
+ return pinnedApps
+ .map(term => ({ app: applications?.query(term)?.[0], term }))
+ .filter(({ app }) => app)
+ .map(({ app, term = true }) => createAppButton({
+ app,
+ term,
+ pinned: true,
+ on_primary_click: () => {
+ const matchingClients = hyprland?.clients.filter(client => (
+ typeof client.class === 'string' &&
+ typeof client.title === 'string' &&
+ typeof client.initialClass === 'string' &&
+ (client.class.includes(term) || client.title.includes(term) || client.initialClass.includes(term))
+ ));
+
+ if (matchingClients.length > 0) {
+ const { address } = matchingClients[0];
+ address && focus(address);
+ } else {
+ launchApp(app);
+ }
+ },
+ on_secondary_click: () => launchApp(app),
+ }));
+ };
+
+ return Widget.Box({
+ class_name: 'pins',
+ vertical: false,
+ homogeneous: true,
+ })
+ .bind('children', options.dock.pinnedApps, 'value', updatePinnedApps);
+};
+
+const Dock = (): Gtk.Box & BoxProps => Widget.Box({
+ class_name: 'dock',
+ vertical: false,
+ children: [PinnedApps(), Taskbar()],
+});
+
+export default Dock;
diff --git a/.config/ags/widget/dock/FloatingDock.ts b/.config/ags/widget/dock/FloatingDock.ts
new file mode 100644
index 0000000..369f56f
--- /dev/null
+++ b/.config/ags/widget/dock/FloatingDock.ts
@@ -0,0 +1,70 @@
+import options from 'options';
+import Dock from './Dock.ts';
+const hyprland = await Service.import('hyprland');
+const apps = await Service.import('applications');
+
+const { Gdk, Gtk } = imports.gi;
+import type Gtk from 'gi://Gtk?version=3.0';
+import { type WindowProps } from 'types/widgets/window';
+import { type RevealerProps } from 'types/widgets/revealer';
+import { type EventBoxProps } from 'types/widgets/eventbox';
+
+/** @param {number} monitor */
+const FloatingDock = (monitor: number): Gtk.Window & WindowProps => {
+ const update = () => {
+ const ws = Hyprland.getWorkspace(Hyprland.active.workspace.id);
+ if (Hyprland.getMonitor(monitor)?.name === ws?.monitor) self.reveal_child = ws?.windows === 0;
+ };
+ const revealer: Gtk.Revealer & RevealerProps = Widget.Revealer({
+ transition: 'slide_up',
+ transitionDuration: 90,
+ child: Dock(),
+ setup: self => self.hook(hyprland, update, 'client-added').hook(hyprland, update, 'client-removed').hook(hyprland.active.workspace, update),
+ });
+
+ const window = Widget.Window({
+ monitor,
+ //halign: 'fill',
+ halign: 'end',
+ //layer: "overlay",
+ layer: 'dock',
+ name: `dock${monitor}`,
+ click_through: false,
+ class_name: 'floating-dock',
+ // class_name: 'floating-dock-no-gap',
+ // class_name: "f-dock-wrap",
+
+ typeHint: Gdk.WindowTypeHint.DOCK,
+ exclusivity: 'false',
+
+ anchor: ['bottom'],
+ child: Widget.Box({
+ vertical: false,
+ halign: 'bottom',
+ hpack: 'start',
+ children: [
+ revealer,
+ Widget.Box({
+ class_name: 'padding',
+ css: 'padding: 9px; margin: 0;',
+ vertical: false,
+ halign: 'bottom',
+ hpack: 'start',
+ }),
+ ],
+ }),
+ });
+
+ window
+ .on('enter-notify-event', () => {
+ revealer.reveal_child = true;
+ })
+ .on('leave-notify-event', () => {
+ revealer.reveal_child = false;
+ })
+ .bind('visible', options.bar.position, 'value', v => v !== 'left');
+
+ return window;
+};
+
+export default FloatingDock;
diff --git a/.config/ags/widget/dock/ToolBox.ts b/.config/ags/widget/dock/ToolBox.ts
new file mode 100644
index 0000000..51fda72
--- /dev/null
+++ b/.config/ags/widget/dock/ToolBox.ts
@@ -0,0 +1,122 @@
+import options from "options";
+import { sh } from "lib/utils";
+import * as Gtk from "gi://Gtk?version=3.0";
+import { type ButtonProps } from "types/widgets/button";
+import { type BoxProps } from "types/widgets/box";
+
+const hyprland = await Service.import("hyprland");
+const { icons } = options.dock.toolbox;
+const buttonToggles = {};
+
+const dispatch = (action: string, arg: string) => {
+ //console.log(`Performing action: ${action} with argument: ${arg}`);
+ sh(`hyprctl dispatch ${action} ${arg}`);
+};
+
+const keyword = (action: string, arg: string) => {
+ //console.log(`Performing action: ${action} with argument: ${arg}`);
+ sh(`hyprctl keyword ${action} ${arg}`);
+};
+
+const ToggleSwitch = (buttonIndex, actionOn, argOn, actionOff, argOff, actionExec) => {
+ buttonToggles[buttonIndex] = !buttonToggles[buttonIndex];
+ const { action, arg } = buttonToggles[buttonIndex] ? { action: actionOn, arg: argOn } : { action: actionOff, arg: argOff };
+ actionExec(action, arg);
+};
+
+const ToggleOnMulti = (buttonIndex, actionOn, argOn, actionOff, argOff, actionExec) => {
+ buttonToggles[buttonIndex] = !buttonToggles[buttonIndex];
+ if (buttonToggles[buttonIndex]) {
+ actionOn.forEach(({ action, arg }) => {
+ actionExec(action, arg);
+ });
+ } else {
+ actionExec(actionOff, argOff);
+ }
+};
+
+const execAction = (trigger, actionIndex, actionOn, argOn, actionOff, argOff, action, arg, actionExec) => {
+ switch (trigger) {
+ case 'toggleOn-multi':
+ ToggleOnMulti(actionIndex, actionOn, argOn, actionOff, argOff, actionExec);
+ break;
+ case 'toggle-switch':
+ ToggleSwitch(actionIndex, actionOn, argOn, actionOff, argOff, actionExec);
+ break;
+ case 'oneshot':
+ actionExec(action, arg);
+ break;
+ default:
+ break;
+ }
+};
+
+const buttonConfigs = [
+ { actionExec: dispatch, trigger: 'oneshot', actionIndex: 0, action: 'killactive', arg: '' },
+ { actionExec: dispatch, trigger: 'oneshot', actionIndex: 1, action: 'exec hyprctl', arg: 'kill' },
+ {
+ actionExec: keyword,
+ trigger: 'toggle-switch',
+ actionIndex: 2,
+ actionOn: 'monitor', argOn: 'eDP-1,2736x1824,0x0,0,transform,1',
+ actionOff: 'monitor', argOff: 'eDP-1,2736x1824,0x0,0,transform,0'
+ },
+ { actionExec: dispatch, trigger: 'oneshot', actionIndex: 3, action: 'workspace', arg: 'r-1' },
+ { actionExec: dispatch, trigger: 'oneshot', actionIndex: 4, action: 'workspace', arg: 'r+1' },
+ { actionExec: dispatch, trigger: 'oneshot', actionIndex: 5, action: 'movewindow', arg: 'l' },
+ { actionExec: dispatch, trigger: 'oneshot', actionIndex: 6, action: 'movewindow', arg: 'r' },
+ { actionExec: dispatch, trigger: 'oneshot', actionIndex: 7, action: 'movewindow', arg: 'u' },
+ { actionExec: dispatch, trigger: 'oneshot', actionIndex: 8, action: 'movewindow', arg: 'd' },
+ { actionExec: dispatch, trigger: 'oneshot', actionIndex: 9, action: 'swapnext', arg: 'next' },
+ { actionExec: dispatch, trigger: 'oneshot', actionIndex: 10, action: 'togglesplit', arg: '' },
+ {
+ actionExec: dispatch,
+ trigger: 'toggleOn-multi',
+ actionIndex: 11,
+ actionOn: [
+ { action: 'setfloating', arg: 'active' },
+ { action: 'resizeactive', arg: 'exact 90% 90%' },
+ { action: 'centerwindow', arg: '' },
+ ],
+ actionOff: 'settiled', argOff: 'active'
+ },
+ { actionExec: dispatch, trigger: 'oneshot', actionIndex: 12, action: 'pin', arg: '' },
+ { actionExec: dispatch, trigger: 'oneshot', actionIndex: 13, action: 'fullscreen', arg: '0' },
+ {
+ actionExec: dispatch,
+ trigger: 'toggle-switch',
+ actionIndex: 14,
+ actionOn: 'exec', argOn: 'wvctl 1',
+ actionOff: 'exec', argOff: 'wvctl 0'
+ },
+];
+
+const ToolBox = () => {
+ const ToolBoxButtons = () => {
+ const buttons = buttonConfigs.map(({ actionIndex, actionOn, argOn, actionOff, argOff, actionExec, trigger, action, arg }) => {
+ const execActionWrapper = () => execAction(trigger, actionIndex, actionOn, argOn, actionOff, argOff, action, arg, actionExec);
+
+ return Widget.Button({
+ child: Widget.Icon({
+ icon: icons[actionIndex].bind(),
+ }),
+ on_clicked: execActionWrapper,
+ });
+ });
+
+ return Widget.Box({
+ vertical: true,
+ homogeneous: true,
+ children: buttons,
+ });
+ };
+
+ return Widget.Box({
+ class_name: "toolbox",
+ vertical: true,
+ homogeneous: true,
+ children: [ToolBoxButtons()],
+ });
+};
+
+export default ToolBox;
diff --git a/.config/ags/widget/dock/ToolBoxDock.ts b/.config/ags/widget/dock/ToolBoxDock.ts
new file mode 100644
index 0000000..21beaeb
--- /dev/null
+++ b/.config/ags/widget/dock/ToolBoxDock.ts
@@ -0,0 +1,57 @@
+import options from "options";
+import ToolBox from "./ToolBox.ts";
+const hyprland = await Service.import("hyprland");
+const apps = await Service.import("applications");
+
+import type Gtk from "gi://Gtk?version=3.0";
+import { type WindowProps } from "types/widgets/window";
+import { type RevealerProps } from "types/widgets/revealer";
+import { type EventBoxProps } from "types/widgets/eventbox";
+
+/** @param {number} monitor */
+const ToolBoxDock = (monitor: number): Gtk.Window & WindowProps => {
+
+ const revealer: Gtk.Revealer & RevealerProps = Widget.Revealer({
+ transition: 'slide_left',
+ transitionDuration: 50,
+ child: ToolBox(),
+ });
+
+ const window = Widget.Window({
+ monitor,
+ halign: 'fill',
+ layer: "overlay",
+ name: `toolbox${monitor}`,
+ click_through: false,
+ class_name: 'floating-toolbox',
+ anchor: ['right'],
+ child: Widget.Box({
+ vertical: true,
+ halign: 'top',
+ hpack: 'fill',
+ children: [
+ revealer,
+ Widget.Box({
+ class_name: 'padding',
+ css: 'padding: 14px;',
+ vertical: true,
+ halign: 'top',
+ hpack: 'fill',
+ }),
+ ],
+ }),
+ });
+
+ window
+ .on('enter-notify-event', () => {
+ revealer.reveal_child = true;
+ })
+ .on('leave-notify-event', () => {
+ revealer.reveal_child = false;
+ })
+ .bind('visible', options.bar.position, 'value', v => v !== 'left');
+
+ return window;
+};
+
+export default ToolBoxDock;
diff --git a/.config/ags/widget/dock/dock.scss b/.config/ags/widget/dock/dock.scss
new file mode 100644
index 0000000..9dc6256
--- /dev/null
+++ b/.config/ags/widget/dock/dock.scss
@@ -0,0 +1,73 @@
+@use 'sass:color';
+
+.floating-dock {
+ padding-left: 0.2rem;
+ padding-right: 0.2rem;
+ padding-top: 0.3rem;
+ padding-bottom: 0.3rem;
+ border-radius: 1rem;
+}
+
+.dock {
+ // @include floating-widget;
+ border-radius: $radius;
+ background-color: transparentize($bg, 0.07);
+ min-width: 0;
+ padding: 6;
+
+ // Common styles for both PinnedApps and Taskbar buttons
+ button {
+ @include button($flat: true);
+ border-radius: 4;
+ padding: 2;
+
+ .box {
+ margin: 0em;
+ min-width: 1em;
+ min-height: 0em;
+ padding: 0;
+ }
+
+ image {
+ margin: 0px;
+ }
+
+ .indicator {
+ background-color: transparentize($primary-bg, 0.3);
+ border-radius: $radius;
+ min-height: 1pt;
+ min-width: 16pt;
+ margin: 1pt;
+ }
+ }
+}
+
+.toolbox {
+ // @include floating-widget;
+ border-radius: $radius;
+ background-color: transparentize($bg, 0.07);
+ min-width: 0;
+ padding: 6;
+
+ // Common styles for both PinnedApps and Taskbar buttons
+ button {
+ @include button($flat: true);
+ border-radius: 0;
+ padding: 0;
+
+ image {
+ margin: 0px;
+ font-size: 32px;
+ }
+
+ &:hover,
+ &:active,
+ &:focus {
+ // Override hover, active, and focus styles for buttons in .toolbox
+ background-color: transparent;
+ border: none;
+ box-shadow: none;
+ outline: none;
+ }
+ }
+}
diff --git a/.config/ags/widget/launcher/AppLauncher.ts b/.config/ags/widget/launcher/AppLauncher.ts
new file mode 100644
index 0000000..08258de
--- /dev/null
+++ b/.config/ags/widget/launcher/AppLauncher.ts
@@ -0,0 +1,125 @@
+import { type Application } from 'types/service/applications';
+import { launchApp, icon } from 'lib/utils';
+import options from 'options';
+import icons from 'lib/icons';
+
+const apps = await Service.import('applications');
+const { query } = apps;
+const { iconSize } = options.launcher.apps;
+
+const QuickAppButton = (app: Application) =>
+ Widget.Button({
+ hexpand: true,
+ tooltip_text: app.name,
+ on_clicked: () => {
+ App.closeWindow('launcher');
+ launchApp(app);
+ },
+ child: Widget.Icon({
+ size: iconSize.bind(),
+ icon: icon(app.icon_name, icons.fallback.executable),
+ }),
+ });
+
+const AppItem = (app: Application) => {
+ const title = Widget.Label({
+ class_name: 'title',
+ label: app.name,
+ hexpand: true,
+ xalign: 0,
+ vpack: 'center',
+ truncate: 'end',
+ });
+
+ const description = Widget.Label({
+ class_name: 'description',
+ label: app.description || '',
+ hexpand: true,
+ wrap: true,
+ max_width_chars: 30,
+ xalign: 0,
+ justification: 'left',
+ vpack: 'center',
+ });
+
+ const appicon = Widget.Icon({
+ icon: icon(app.icon_name, icons.fallback.executable),
+ size: iconSize.bind(),
+ });
+
+ const textBox = Widget.Box({
+ vertical: true,
+ vpack: 'center',
+ children: app.description ? [title, description] : [title],
+ });
+
+ return Widget.Button({
+ class_name: 'app-item',
+ attribute: { app },
+ child: Widget.Box({
+ children: [appicon, textBox],
+ }),
+ on_clicked: () => {
+ App.closeWindow('launcher');
+ launchApp(app);
+ },
+ });
+};
+export function Favorites() {
+ const favs = options.launcher.apps.favorites.bind();
+ return Widget.Revealer({
+ visible: favs.as(f => f.length > 0),
+ child: Widget.Box({
+ vertical: true,
+ children: favs.as(favs =>
+ favs.flatMap(fs => [
+ Widget.Separator(),
+ Widget.Box({
+ class_name: 'quicklaunch horizontal',
+ children: fs
+ .map(f => query(f)?.[0])
+ .filter(f => f)
+ .map(QuickAppButton),
+ }),
+ ]),
+ ),
+ }),
+ });
+}
+
+export function Launcher() {
+ const applist = Variable(query(''));
+ const max = options.launcher.apps.max;
+ let first = applist.value[0];
+
+ function SeparatedAppItem(app: Application) {
+ return Widget.Revealer({ attribute: { app } }, Widget.Box({ vertical: true }, Widget.Separator(), AppItem(app)));
+ }
+
+ const list = Widget.Box({
+ vertical: true,
+ children: applist.bind().as(list => list.map(SeparatedAppItem)),
+ setup: self => self.hook(apps, () => (applist.value = query('')), 'notify::frequents'),
+ });
+
+ return Object.assign(list, {
+ filter(text: string | null) {
+ first = query(text || '')[0];
+ list.children.reduce((i, item) => {
+ if (!text || i >= max.value) {
+ item.reveal_child = false;
+ return i;
+ }
+ if (item.attribute.app.match(text)) {
+ item.reveal_child = true;
+ return ++i;
+ }
+ item.reveal_child = false;
+ return i;
+ }, 0);
+ },
+ launchFirst() {
+ launchApp(first);
+ },
+ });
+}
diff --git a/.config/ags/widget/launcher/Launcher.ts b/.config/ags/widget/launcher/Launcher.ts
new file mode 100644
index 0000000..90b4d58
--- /dev/null
+++ b/.config/ags/widget/launcher/Launcher.ts
@@ -0,0 +1,134 @@
+import { type Binding } from 'lib/utils';
+import PopupWindow, { Padding } from 'widget/PopupWindow';
+import icons from 'lib/icons';
+import options from 'options';
+import nix from 'service/nix';
+import * as AppLauncher from './AppLauncher';
+import * as NixRun from './NixRun';
+import * as ShRun from './ShRun';
+
+const { width, margin } = options.launcher;
+const isnix = nix.available;
+
+function Launcher() {
+ const favs = AppLauncher.Favorites();
+ const applauncher = AppLauncher.Launcher();
+ const sh = ShRun.ShRun();
+ const shicon = ShRun.Icon();
+ const nix = NixRun.NixRun();
+ const nixload = NixRun.Spinner();
+
+ function HelpButton(cmd: string, desc: string | Binding<string>) {
+ return Widget.Box(
+ { vertical: true },
+ Widget.Separator(),
+ Widget.Button(
+ {
+ class_name: 'help',
+ on_clicked: () => {
+ entry.grab_focus();
+ entry.text = `:${cmd} `;
+ entry.set_position(-1);
+ },
+ },
+ Widget.Box([
+ Widget.Label({
+ class_name: 'name',
+ label: `:${cmd}`,
+ }),
+ Widget.Label({
+ hexpand: true,
+ hpack: 'end',
+ class_name: 'description',
+ label: desc,
+ }),
+ ]),
+ ),
+ );
+ }
+
+ const help = Widget.Revealer({
+ child: Widget.Box(
+ { vertical: true },
+ HelpButton('sh', 'run a binary'),
+ isnix
+ ? HelpButton(
+ 'nx',
+ options.launcher.nix.pkgs.bind().as(pkg => `run a nix package from ${pkg}`),
+ )
+ : Widget.Box(),
+ ),
+ });
+
+ const entry = Widget.Entry({
+ hexpand: true,
+ primary_icon_name: icons.ui.search,
+ on_accept: ({ text }) => {
+ if (text?.startsWith(':nx')) nix.run(text.substring(3));
+ else if (text?.startsWith(':sh')) sh.run(text.substring(3));
+ else applauncher.launchFirst();
+
+ App.toggleWindow('launcher');
+ entry.text = '';
+ },
+ on_change: ({ text }) => {
+ text ||= '';
+ favs.reveal_child = text === '';
+ help.reveal_child = text.split(' ').length === 1 && text?.startsWith(':');
+
+ if (text?.startsWith(':nx')) nix.filter(text.substring(3));
+ else nix.filter('');
+
+ if (text?.startsWith(':sh')) sh.filter(text.substring(3));
+ else sh.filter('');
+
+ if (!text?.startsWith(':')) applauncher.filter(text);
+ },
+ });
+
+ function focus() {
+ entry.text = '';
+ entry.set_position(-1);
+ entry.select_region(0, -1);
+ entry.grab_focus();
+ favs.reveal_child = true;
+ }
+
+ const layout = Widget.Box({
+ css: width.bind().as(v => `min-width: ${v}pt;`),
+ class_name: 'launcher',
+ vertical: true,
+ vpack: 'start',
+ setup: self =>
+ self.hook(App, (_, win, visible) => {
+ if (win !== 'launcher') return;
+
+ entry.text = '';
+ if (visible) focus();
+ }),
+ children: [
+ Widget.Box([entry, nixload, shicon]),
+ favs,
+ help,
+ applauncher,
+ //nix,
+ sh,
+ ],
+ });
+
+ return Widget.Box(
+ { vertical: true, css: 'padding: 1px' },
+ Padding('applauncher', {
+ css: margin.bind().as(v => `min-height: ${v}pt;`),
+ vexpand: false,
+ }),
+ layout,
+ );
+}
+
+export default () =>
+ PopupWindow({
+ name: 'launcher',
+ layout: 'top',
+ child: Launcher(),
+ });
diff --git a/.config/ags/widget/launcher/NixRun.ts b/.config/ags/widget/launcher/NixRun.ts
new file mode 100644
index 0000000..cec9e09
--- /dev/null
+++ b/.config/ags/widget/launcher/NixRun.ts
@@ -0,0 +1,118 @@
+import icons from "lib/icons"
+import nix, { type Nixpkg } from "service/nix"
+
+const iconVisible = Variable(false)
+
+function Item(pkg: Nixpkg) {
+ const name = Widget.Label({
+ class_name: "name",
+ label: pkg.name.split(".").at(-1),
+ })
+
+ const subpkg = pkg.name.includes(".") ? Widget.Label({
+ class_name: "description",
+ hpack: "end",
+ hexpand: true,
+ label: ` ${pkg.name.split(".").slice(0, -1).join(".")}`,
+ }) : null
+
+ const version = Widget.Label({
+ class_name: "version",
+ label: pkg.version,
+ hexpand: true,
+ hpack: "end",
+ })
+
+ const description = pkg.description ? Widget.Label({
+ class_name: "description",
+ label: pkg.description,
+ justification: "left",
+ wrap: true,
+ hpack: "start",
+ max_width_chars: 40,
+ }) : null
+
+ return Widget.Box(
+ {
+ attribute: { name: pkg.name },
+ vertical: true,
+ },
+ Widget.Separator(),
+ Widget.Button(
+ {
+ class_name: "nix-item",
+ on_clicked: () => {
+ nix.run(pkg.name)
+ App.closeWindow("launcher")
+ },
+ },
+ Widget.Box(
+ { vertical: true },
+ Widget.Box([name, version]),
+ Widget.Box([
+ description as ReturnType<typeof Widget.Label>,
+ subpkg as ReturnType<typeof Widget.Label>,
+ ]),
+ ),
+ ),
+ )
+}
+
+export function Spinner() {
+ const icon = Widget.Icon({
+ icon: icons.nix.nix,
+ class_name: "spinner",
+ css: `
+ @keyframes spin {
+ to { -gtk-icon-transform: rotate(1turn); }
+ }
+
+ image.spinning {
+ animation-name: spin;
+ animation-duration: 1s;
+ animation-timing-function: linear;
+ animation-iteration-count: infinite;
+ }
+ `,
+ setup: self => self.hook(nix, () => {
+ self.toggleClassName("spinning", !nix.ready)
+ }),
+ })
+
+ return Widget.Revealer({
+ transition: "slide_left",
+ child: icon,
+ reveal_child: Utils.merge([
+ nix.bind("ready"),
+ iconVisible.bind(),
+ ], (ready, show) => !ready || show),
+ })
+}
+
+export function NixRun() {
+ const list = Widget.Box<ReturnType<typeof Item>>({
+ vertical: true,
+ })
+
+ const revealer = Widget.Revealer({
+ child: list,
+ })
+
+ async function filter(term: string) {
+ iconVisible.value = Boolean(term)
+
+ if (!term)
+ revealer.reveal_child = false
+
+ if (term.trim()) {
+ const found = await nix.query(term)
+ list.children = found.map(k => Item(nix.db[k]))
+ revealer.reveal_child = true
+ }
+ }
+
+ return Object.assign(revealer, {
+ filter,
+ run: nix.run,
+ })
+}
diff --git a/.config/ags/widget/launcher/ShRun.ts b/.config/ags/widget/launcher/ShRun.ts
new file mode 100644
index 0000000..c4215ef
--- /dev/null
+++ b/.config/ags/widget/launcher/ShRun.ts
@@ -0,0 +1,89 @@
+import icons from "lib/icons"
+import options from "options"
+import { bash, dependencies } from "lib/utils"
+
+const iconVisible = Variable(false)
+
+const MAX = options.launcher.sh.max
+const BINS = `${Utils.CACHE_DIR}/binaries`
+bash("{ IFS=:; ls -H $PATH; } | sort ")
+ .then(bins => Utils.writeFile(bins, BINS))
+
+async function query(filter: string) {
+ if (!dependencies("fzf"))
+ return [] as string[]
+
+ return bash(`cat ${BINS} | fzf -f ${filter} | head -n ${MAX}`)
+ .then(str => Array.from(new Set(str.split("\n").filter(i => i)).values()))
+ .catch(err => { print(err); return [] })
+}
+
+function run(args: string) {
+ Utils.execAsync(args)
+ .then(out => {
+ print(`:sh ${args.trim()}:`)
+ print(out)
+ })
+ .catch(err => {
+ Utils.notify("ShRun Error", err, icons.app.terminal)
+ })
+}
+
+function Item(bin: string) {
+ return Widget.Box(
+ {
+ attribute: { bin },
+ vertical: true,
+ },
+ Widget.Separator(),
+ Widget.Button({
+ child: Widget.Label({
+ label: bin,
+ hpack: "start",
+ }),
+ class_name: "sh-item",
+ on_clicked: () => {
+ Utils.execAsync(bin)
+ App.closeWindow("launcher")
+ },
+ }),
+ )
+}
+
+export function Icon() {
+ const icon = Widget.Icon({
+ icon: icons.app.terminal,
+ class_name: "spinner",
+ })
+
+ return Widget.Revealer({
+ transition: "slide_left",
+ child: icon,
+ reveal_child: iconVisible.bind(),
+ })
+}
+
+export function ShRun() {
+ const list = Widget.Box<ReturnType<typeof Item>>({
+ vertical: true,
+ })
+
+ const revealer = Widget.Revealer({
+ child: list,
+ })
+
+ async function filter(term: string) {
+ iconVisible.value = Boolean(term)
+
+ if (!term)
+ revealer.reveal_child = false
+
+ if (term.trim()) {
+ const found = await query(term)
+ list.children = found.map(Item)
+ revealer.reveal_child = true
+ }
+ }
+
+ return Object.assign(revealer, { filter, run })
+}
diff --git a/.config/ags/widget/launcher/launcher.scss b/.config/ags/widget/launcher/launcher.scss
new file mode 100644
index 0000000..926abc3
--- /dev/null
+++ b/.config/ags/widget/launcher/launcher.scss
@@ -0,0 +1,143 @@
+@use "sass:math";
+@use "sass:color";
+
+window#launcher .launcher {
+ @include floating_widget;
+
+ .quicklaunch {
+ @include spacing;
+
+ button {
+ @include button($flat: true);
+ padding: $padding;
+ }
+ }
+
+ entry {
+ @include button;
+ padding: $padding;
+ margin: $spacing;
+
+ selection {
+ color: color.mix($fg, $bg, 50%);
+ background-color: transparent;
+ }
+
+ label,
+ image {
+ color: $fg;
+ }
+ }
+
+ image.spinner {
+ color: $primary-bg;
+ margin-right: $spacing;
+ }
+
+ separator {
+ margin: 4pt 0;
+ background-color: $popover-border-color;
+ }
+
+ button.app-item {
+ @include button($flat: true, $reactive: false);
+
+ >box {
+ @include spacing(0.5);
+ }
+
+ transition: $transition;
+ padding: $padding;
+
+ label {
+ transition: $transition;
+
+ &.title {
+ color: $fg;
+ }
+
+ &.description {
+ color: transparentize($fg, 0.3);
+ }
+ }
+
+ image {
+ transition: $transition;
+ }
+
+ &:hover,
+ &:focus {
+ .title {
+ color: $primary-bg;
+ }
+
+ .description {
+ color: transparentize($primary-bg, .4);
+ }
+
+ image {
+ -gtk-icon-shadow: 2px 2px $primary-bg;
+ }
+ }
+
+ &:active {
+ background-color: transparentize($primary-bg, 0.5);
+ border-radius: $radius;
+ box-shadow: inset 0 0 0 $border-width $border-color;
+
+ .title {
+ color: $fg;
+ }
+ }
+ }
+
+ button.help,
+ button.nix-item {
+ @include button($flat: true, $reactive: false);
+ padding: 0 ($padding * .5);
+
+ label {
+ transition: $transition;
+ color: $fg;
+ }
+
+ .name {
+ font-size: 1.2em;
+ font-weight: bold;
+ }
+
+ .description {
+ color: transparentize($fg, .3)
+ }
+
+ &:hover,
+ &:focus {
+ label {
+ text-shadow: $text-shadow;
+ }
+
+ .name,
+ .version {
+ color: $primary-bg;
+ }
+
+ .description {
+ color: transparentize($primary-bg, .3)
+ }
+ }
+ }
+
+ button.sh-item {
+ @include button($flat: true, $reactive: false);
+ padding: 0 ($padding * .5);
+
+ transition: $transition;
+ color: $fg;
+
+ &:hover,
+ &:focus {
+ color: $primary-bg;
+ text-shadow: $text-shadow;
+ }
+ }
+}
diff --git a/.config/ags/widget/notifications/Notification.ts b/.config/ags/widget/notifications/Notification.ts
new file mode 100644
index 0000000..c1c8dd8
--- /dev/null
+++ b/.config/ags/widget/notifications/Notification.ts
@@ -0,0 +1,138 @@
+import { type Notification } from "types/service/notifications"
+import GLib from "gi://GLib"
+import icons from "lib/icons"
+
+const time = (time: number, format = "%H:%M") => GLib.DateTime
+ .new_from_unix_local(time)
+ .format(format)
+
+const NotificationIcon = ({ app_entry, app_icon, image }: Notification) => {
+ if (image) {
+ return Widget.Box({
+ vpack: "start",
+ hexpand: false,
+ class_name: "icon img",
+ css: `
+ background-image: url("${image}");
+ background-size: cover;
+ background-repeat: no-repeat;
+ background-position: center;
+ min-width: 78px;
+ min-height: 78px;
+ `,
+ })
+ }
+
+ let icon = icons.fallback.notification
+ if (Utils.lookUpIcon(app_icon))
+ icon = app_icon
+
+ if (Utils.lookUpIcon(app_entry || ""))
+ icon = app_entry || ""
+
+ return Widget.Box({
+ vpack: "start",
+ hexpand: false,
+ class_name: "icon",
+ css: `
+ min-width: 78px;
+ min-height: 78px;
+ `,
+ child: Widget.Icon({
+ icon,
+ size: 58,
+ hpack: "center", hexpand: true,
+ vpack: "center", vexpand: true,
+ }),
+ })
+}
+
+export default (notification: Notification) => {
+ const content = Widget.Box({
+ class_name: "content",
+ children: [
+ NotificationIcon(notification),
+ Widget.Box({
+ hexpand: true,
+ vertical: true,
+ children: [
+ Widget.Box({
+ children: [
+ Widget.Label({
+ class_name: "title",
+ xalign: 0,
+ justification: "left",
+ hexpand: true,
+ max_width_chars: 24,
+ truncate: "end",
+ wrap: true,
+ label: notification.summary.trim(),
+ use_markup: true,
+ }),
+ Widget.Label({
+ class_name: "time",
+ vpack: "start",
+ label: time(notification.time),
+ }),
+ Widget.Button({
+ class_name: "close-button",
+ vpack: "start",
+ child: Widget.Icon("window-close-symbolic"),
+ on_clicked: notification.close,
+ }),
+ ],
+ }),
+ Widget.Label({
+ class_name: "description",
+ hexpand: true,
+ use_markup: true,
+ xalign: 0,
+ justification: "left",
+ label: notification.body.trim(),
+ max_width_chars: 24,
+ wrap: true,
+ }),
+ ],
+ }),
+ ],
+ })
+
+ const actionsbox = notification.actions.length > 0 ? Widget.Revealer({
+ transition: "slide_down",
+ child: Widget.EventBox({
+ child: Widget.Box({
+ class_name: "actions horizontal",
+ children: notification.actions.map(action => Widget.Button({
+ class_name: "action-button",
+ on_clicked: () => notification.invoke(action.id),
+ hexpand: true,
+ child: Widget.Label(action.label),
+ })),
+ }),
+ }),
+ }) : null
+
+ const eventbox = Widget.EventBox({
+ vexpand: false,
+ on_primary_click: notification.dismiss,
+ on_hover() {
+ if (actionsbox)
+ actionsbox.reveal_child = true
+ },
+ on_hover_lost() {
+ if (actionsbox)
+ actionsbox.reveal_child = true
+
+ notification.dismiss()
+ },
+ child: Widget.Box({
+ vertical: true,
+ children: actionsbox ? [content, actionsbox] : [content],
+ }),
+ })
+
+ return Widget.Box({
+ class_name: `notification ${notification.urgency}`,
+ child: eventbox,
+ })
+}
diff --git a/.config/ags/widget/notifications/NotificationPopups.ts b/.config/ags/widget/notifications/NotificationPopups.ts
new file mode 100644
index 0000000..a4a2b54
--- /dev/null
+++ b/.config/ags/widget/notifications/NotificationPopups.ts
@@ -0,0 +1,90 @@
+import Notification from "./Notification"
+import options from "options"
+
+const notifications = await Service.import("notifications")
+const { transition } = options
+const { position } = options.notifications
+const { timeout, idle } = Utils
+
+function Animated(id: number) {
+ const n = notifications.getNotification(id)!
+ const widget = Notification(n)
+
+ const inner = Widget.Revealer({
+ transition: "slide_left",
+ transition_duration: transition.value,
+ child: widget,
+ })
+
+ const outer = Widget.Revealer({
+ transition: "slide_down",
+ transition_duration: transition.value,
+ child: inner,
+ })
+
+ const box = Widget.Box({
+ hpack: "end",
+ child: outer,
+ })
+
+ idle(() => {
+ outer.reveal_child = true
+ timeout(transition.value, () => {
+ inner.reveal_child = true
+ })
+ })
+
+ return Object.assign(box, {
+ dismiss() {
+ inner.reveal_child = false
+ timeout(transition.value, () => {
+ outer.reveal_child = false
+ timeout(transition.value, () => {
+ box.destroy()
+ })
+ })
+ },
+ })
+}
+
+function PopupList() {
+ const map: Map<number, ReturnType<typeof Animated>> = new Map
+ const box = Widget.Box({
+ hpack: "end",
+ vertical: true,
+ css: options.notifications.width.bind().as(w => `min-width: ${w}px;`),
+ })
+
+ function remove(_: unknown, id: number) {
+ map.get(id)?.dismiss()
+ map.delete(id)
+ }
+
+ return box
+ .hook(notifications, (_, id: number) => {
+ if (id !== undefined) {
+ if (map.has(id))
+ remove(null, id)
+
+ if (notifications.dnd)
+ return
+
+ const w = Animated(id)
+ map.set(id, w)
+ box.children = [w, ...box.children]
+ }
+ }, "notified")
+ .hook(notifications, remove, "dismissed")
+ .hook(notifications, remove, "closed")
+}
+
+export default (monitor: number) => Widget.Window({
+ monitor,
+ name: `notifications${monitor}`,
+ anchor: position.bind(),
+ class_name: "notifications",
+ child: Widget.Box({
+ css: "padding: 2px;",
+ child: PopupList(),
+ }),
+})
diff --git a/.config/ags/widget/notifications/notifications.scss b/.config/ags/widget/notifications/notifications.scss
new file mode 100644
index 0000000..369932f
--- /dev/null
+++ b/.config/ags/widget/notifications/notifications.scss
@@ -0,0 +1,79 @@
+@mixin notification() {
+ &.critical {
+ box-shadow: inset 0 0 0.5em 0 $error-bg;
+ }
+
+ &:hover button.close-button {
+ @include button-hover;
+ background-color: transparentize($error-bg, 0.5);
+ }
+
+ .content {
+ .title {
+ margin-right: $spacing;
+ color: $fg;
+ font-size: 1.1em;
+ }
+
+ .time {
+ color: transparentize($fg, 0.2);
+ }
+
+ .description {
+ font-size: 0.9em;
+ color: transparentize($fg, 0.2);
+ }
+
+ .icon {
+ border-radius: $radius * 0.8;
+ margin-right: $spacing;
+
+ &.img {
+ border: $border;
+ }
+ }
+ }
+
+ box.actions {
+ @include spacing(0.5);
+ margin-top: $spacing;
+
+ button {
+ @include button;
+ border-radius: $radius * 0.8;
+ font-size: 1.2em;
+ padding: $padding * 0.7;
+ }
+ }
+
+ button.close-button {
+ @include button($flat: true);
+ margin-left: $spacing / 2;
+ border-radius: $radius * 0.8;
+ min-width: 1.2em;
+ min-height: 1.2em;
+
+ &:hover {
+ background-color: transparentize($error-bg, 0.2);
+ }
+
+ &:active {
+ background-image: none;
+ background-color: $error-bg;
+ }
+ }
+}
+
+window.notifications {
+ @include unset;
+
+ .notification {
+ @include notification;
+ @include floating-widget;
+ border-radius: $radius;
+
+ .description {
+ min-width: 200px;
+ }
+ }
+}
diff --git a/.config/ags/widget/osd/OSD.ts b/.config/ags/widget/osd/OSD.ts
new file mode 100644
index 0000000..8239a08
--- /dev/null
+++ b/.config/ags/widget/osd/OSD.ts
@@ -0,0 +1,111 @@
+import { icon } from "lib/utils"
+import icons from "lib/icons"
+import Progress from "./Progress"
+import brightness from "service/brightness"
+import options from "options"
+
+const audio = await Service.import("audio")
+const { progress, microphone } = options.osd
+
+const DELAY = 2500
+
+function OnScreenProgress(vertical: boolean) {
+ const indicator = Widget.Icon({
+ size: 42,
+ vpack: "start",
+ })
+ const progress = Progress({
+ vertical,
+ width: vertical ? 42 : 300,
+ height: vertical ? 300 : 42,
+ child: indicator,
+ })
+
+ const revealer = Widget.Revealer({
+ transition: "slide_left",
+ child: progress,
+ })
+
+ let count = 0
+ function show(value: number, icon: string) {
+ revealer.reveal_child = true
+ indicator.icon = icon
+ progress.setValue(value)
+ count++
+ Utils.timeout(DELAY, () => {
+ count--
+
+ if (count === 0)
+ revealer.reveal_child = false
+ })
+ }
+
+ return revealer
+ .hook(brightness, () => show(
+ brightness.screen,
+ icons.brightness.screen,
+ ), "notify::screen")
+ .hook(brightness, () => show(
+ brightness.kbd,
+ icons.brightness.keyboard,
+ ), "notify::kbd")
+ .hook(audio.speaker, () => show(
+ audio.speaker.volume,
+ icon(audio.speaker.icon_name || "", icons.audio.type.speaker),
+ ), "notify::volume")
+}
+
+function MicrophoneMute() {
+ const icon = Widget.Icon({
+ class_name: "microphone",
+ })
+
+ const revealer = Widget.Revealer({
+ transition: "slide_up",
+ child: icon,
+ })
+
+ let count = 0
+ let mute = audio.microphone.stream?.is_muted ?? false
+
+ return revealer.hook(audio.microphone, () => Utils.idle(() => {
+ if (mute !== audio.microphone.stream?.is_muted) {
+ mute = audio.microphone.stream!.is_muted
+ icon.icon = icons.audio.mic[mute ? "muted" : "high"]
+ revealer.reveal_child = true
+ count++
+
+ Utils.timeout(DELAY, () => {
+ count--
+ if (count === 0)
+ revealer.reveal_child = false
+ })
+ }
+ }))
+}
+
+export default (monitor: number) => Widget.Window({
+ monitor,
+ name: `indicator${monitor}`,
+ class_name: "indicator",
+ layer: "overlay",
+ click_through: true,
+ anchor: ["right", "left", "top", "bottom"],
+ child: Widget.Box({
+ css: "padding: 2px;",
+ expand: true,
+ child: Widget.Overlay(
+ { child: Widget.Box({ expand: true }) },
+ Widget.Box({
+ hpack: progress.pack.h.bind(),
+ vpack: progress.pack.v.bind(),
+ child: progress.vertical.bind().as(OnScreenProgress),
+ }),
+ Widget.Box({
+ hpack: microphone.pack.h.bind(),
+ vpack: microphone.pack.v.bind(),
+ child: MicrophoneMute(),
+ }),
+ ),
+ }),
+})
diff --git a/.config/ags/widget/osd/Progress.ts b/.config/ags/widget/osd/Progress.ts
new file mode 100644
index 0000000..bcf27da
--- /dev/null
+++ b/.config/ags/widget/osd/Progress.ts
@@ -0,0 +1,74 @@
+import type Gtk from "gi://Gtk?version=3.0"
+import GLib from "gi://GLib?version=2.0"
+import { range } from "lib/utils"
+import options from "options"
+
+type ProgressProps = {
+ height?: number
+ width?: number
+ vertical?: boolean
+ child: Gtk.Widget
+}
+
+export default ({
+ height = 18,
+ width = 180,
+ vertical = false,
+ child,
+}: ProgressProps) => {
+ const fill = Widget.Box({
+ class_name: "fill",
+ hexpand: vertical,
+ vexpand: !vertical,
+ hpack: vertical ? "fill" : "start",
+ vpack: vertical ? "end" : "fill",
+ child,
+ })
+
+ const container = Widget.Box({
+ class_name: "progress",
+ child: fill,
+ css: `
+ min-width: ${width}px;
+ min-height: ${height}px;
+ `,
+ })
+
+ let fill_size = 0
+ let animations: number[] = []
+
+ return Object.assign(container, {
+ setValue(value: number) {
+ if (value < 0)
+ return
+
+ if (animations.length > 0) {
+ for (const id of animations)
+ GLib.source_remove(id)
+
+ animations = []
+ }
+
+ const axis = vertical ? "height" : "width"
+ const axisv = vertical ? height : width
+ const min = vertical ? width : height
+ const preferred = (axisv - min) * value + min
+
+ if (!fill_size) {
+ fill_size = preferred
+ fill.css = `min-${axis}: ${preferred}px;`
+ return
+ }
+
+ const frames = options.transition.value / 10
+ const goal = preferred - fill_size
+ const step = goal / frames
+
+ animations = range(frames, 0).map(i => Utils.timeout(5 * i, () => {
+ fill_size += step
+ fill.css = `min-${axis}: ${fill_size}px`
+ animations.shift()
+ }))
+ },
+ })
+}
diff --git a/.config/ags/widget/osd/osd.scss b/.config/ags/widget/osd/osd.scss
new file mode 100644
index 0000000..33df6d2
--- /dev/null
+++ b/.config/ags/widget/osd/osd.scss
@@ -0,0 +1,26 @@
+window.indicator {
+ .progress {
+ @include floating-widget;
+ padding: $padding * 0.5;
+ border-radius: if($radius >0, calc($radius + $padding * 0.5), 0);
+ @debug $radius;
+
+ .fill {
+ border-radius: $radius;
+ background-color: $primary-bg;
+ color: $primary-fg;
+
+ image {
+ -gtk-icon-transform: scale(0.7);
+ }
+ }
+ }
+
+ .microphone {
+ @include floating-widget;
+ margin: $spacing * 2;
+ padding: $popover-padding * 2;
+ font-size: 58px;
+ color: transparentize($fg, 0.1);
+ }
+}
diff --git a/.config/ags/widget/overview/Overview.ts b/.config/ags/widget/overview/Overview.ts
new file mode 100644
index 0000000..8911920
--- /dev/null
+++ b/.config/ags/widget/overview/Overview.ts
@@ -0,0 +1,41 @@
+import PopupWindow from "widget/PopupWindow"
+import Workspace from "./Workspace"
+import options from "options"
+import { range } from "lib/utils"
+
+const hyprland = await Service.import("hyprland")
+
+const Overview = (ws: number) => Widget.Box({
+ class_name: "overview horizontal",
+ children: ws > 0
+ ? range(ws).map(Workspace)
+ : hyprland.workspaces
+ .map(({ id }) => Workspace(id))
+ .sort((a, b) => a.attribute.id - b.attribute.id),
+
+ setup: w => {
+ if (ws > 0)
+ return
+
+ w.hook(hyprland, (w, id?: string) => {
+ if (id === undefined)
+ return
+
+ w.children = w.children
+ .filter(ch => ch.attribute.id !== Number(id))
+ }, "workspace-removed")
+ w.hook(hyprland, (w, id?: string) => {
+ if (id === undefined)
+ return
+
+ w.children = [...w.children, Workspace(Number(id))]
+ .sort((a, b) => a.attribute.id - b.attribute.id)
+ }, "workspace-added")
+ },
+})
+
+export default () => PopupWindow({
+ name: "overview",
+ layout: "center",
+ child: options.overview.workspaces.bind().as(Overview),
+})
diff --git a/.config/ags/widget/overview/Window.ts b/.config/ags/widget/overview/Window.ts
new file mode 100644
index 0000000..02f71eb
--- /dev/null
+++ b/.config/ags/widget/overview/Window.ts
@@ -0,0 +1,48 @@
+import { type Client } from "types/service/hyprland"
+import { createSurfaceFromWidget, icon } from "lib/utils"
+import Gdk from "gi://Gdk"
+import Gtk from "gi://Gtk?version=3.0"
+import options from "options"
+import icons from "lib/icons"
+
+const monochrome = options.overview.monochromeIcon
+const TARGET = [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)]
+const hyprland = await Service.import("hyprland")
+const apps = await Service.import("applications")
+const dispatch = (args: string) => hyprland.messageAsync(`dispatch ${args}`)
+
+export default ({ address, size: [w, h], class: c, title }: Client) => Widget.Button({
+ class_name: "client",
+ attribute: { address },
+ tooltip_text: `${title}`,
+ child: Widget.Icon({
+ css: options.overview.scale.bind().as(v => `
+ min-width: ${(v / 100) * w}px;
+ min-height: ${(v / 100) * h}px;
+ `),
+ icon: monochrome.bind().as(m => {
+ const app = apps.list.find(app => app.match(c))
+ if (!app)
+ return icons.fallback.executable + (m ? "-symbolic" : "")
+
+
+ return icon(
+ app.icon_name + (m ? "-symbolic" : ""),
+ icons.fallback.executable + (m ? "-symbolic" : ""),
+ )
+ }),
+ }),
+ on_secondary_click: () => dispatch(`closewindow address:${address}`),
+ on_clicked: () => {
+ dispatch(`focuswindow address:${address}`)
+ App.closeWindow("overview")
+ },
+ setup: btn => btn
+ .on("drag-data-get", (_w, _c, data) => data.set_text(address, address.length))
+ .on("drag-begin", (_, context) => {
+ Gtk.drag_set_icon_surface(context, createSurfaceFromWidget(btn))
+ btn.toggleClassName("hidden", true)
+ })
+ .on("drag-end", () => btn.toggleClassName("hidden", false))
+ .drag_source_set(Gdk.ModifierType.BUTTON1_MASK, TARGET, Gdk.DragAction.COPY),
+})
diff --git a/.config/ags/widget/overview/Workspace.ts b/.config/ags/widget/overview/Workspace.ts
new file mode 100644
index 0000000..1b8d60b
--- /dev/null
+++ b/.config/ags/widget/overview/Workspace.ts
@@ -0,0 +1,76 @@
+import Window from "./Window"
+import Gdk from "gi://Gdk"
+import Gtk from "gi://Gtk?version=3.0"
+import options from "options"
+
+const TARGET = [Gtk.TargetEntry.new("text/plain", Gtk.TargetFlags.SAME_APP, 0)]
+const scale = (size: number) => (options.overview.scale.value / 100) * size
+const hyprland = await Service.import("hyprland")
+
+const dispatch = (args: string) => hyprland.messageAsync(`dispatch ${args}`)
+
+const size = (id: number) => {
+ const def = { h: 1080, w: 1920 }
+ const ws = hyprland.getWorkspace(id)
+ if (!ws)
+ return def
+
+ const mon = hyprland.getMonitor(ws.monitorID)
+ return mon ? { h: mon.height, w: mon.width } : def
+}
+
+export default (id: number) => {
+ const fixed = Widget.Fixed()
+
+ // TODO: early return if position is unchaged
+ async function update() {
+ const json = await hyprland.messageAsync("j/clients").catch(() => null)
+ if (!json)
+ return
+
+ fixed.get_children().forEach(ch => ch.destroy())
+ const clients = JSON.parse(json) as typeof hyprland.clients
+ clients
+ .filter(({ workspace }) => workspace.id === id)
+ .forEach(c => {
+ const x = c.at[0] - (hyprland.getMonitor(c.monitor)?.x || 0)
+ const y = c.at[1] - (hyprland.getMonitor(c.monitor)?.y || 0)
+ c.mapped && fixed.put(Window(c), scale(x), scale(y))
+ })
+ fixed.show_all()
+ }
+
+ return Widget.Box({
+ attribute: { id },
+ tooltipText: `${id}`,
+ class_name: "workspace",
+ vpack: "center",
+ css: options.overview.scale.bind().as(v => `
+ min-width: ${(v / 100) * size(id).w}px;
+ min-height: ${(v / 100) * size(id).h}px;
+ `),
+ setup(box) {
+ box.hook(options.overview.scale, update)
+ box.hook(hyprland, update, "notify::clients")
+ box.hook(hyprland.active.client, update)
+ box.hook(hyprland.active.workspace, () => {
+ box.toggleClassName("active", hyprland.active.workspace.id === id)
+ })
+ },
+ child: Widget.EventBox({
+ expand: true,
+ on_primary_click: () => {
+ App.closeWindow("overview")
+ dispatch(`workspace ${id}`)
+ },
+ setup: eventbox => {
+ eventbox.drag_dest_set(Gtk.DestDefaults.ALL, TARGET, Gdk.DragAction.COPY)
+ eventbox.connect("drag-data-received", (_w, _c, _x, _y, data) => {
+ const address = new TextDecoder().decode(data.get_data())
+ dispatch(`movetoworkspacesilent ${id},address:${address}`)
+ })
+ },
+ child: fixed,
+ }),
+ })
+}
diff --git a/.config/ags/widget/overview/overview.scss b/.config/ags/widget/overview/overview.scss
new file mode 100644
index 0000000..4665b52
--- /dev/null
+++ b/.config/ags/widget/overview/overview.scss
@@ -0,0 +1,34 @@
+window#overview .overview {
+ @include floating-widget;
+ @include spacing;
+
+ .workspace {
+ &.active>widget {
+ border-color: $primary-bg;
+ }
+
+ >widget {
+ @include widget;
+ border-radius: if($radius ==0, 0, $radius + $padding);
+
+ &:hover {
+ background-color: $hover-bg;
+ }
+
+ &:drop(active) {
+ border-color: $primary-bg;
+ }
+ }
+ }
+
+ .client {
+ @include button;
+ border-radius: $radius;
+ margin: $padding;
+
+ &.hidden {
+ @include hidden;
+ transition: 0;
+ }
+ }
+}
diff --git a/.config/ags/widget/powermenu/PowerMenu.ts b/.config/ags/widget/powermenu/PowerMenu.ts
new file mode 100644
index 0000000..fe0a0e9
--- /dev/null
+++ b/.config/ags/widget/powermenu/PowerMenu.ts
@@ -0,0 +1,56 @@
+import PopupWindow from "widget/PopupWindow"
+import powermenu, { type Action } from "service/powermenu"
+import icons from "lib/icons"
+import options from "options"
+import type Gtk from "gi://Gtk?version=3.0"
+
+const { layout, labels } = options.powermenu
+
+const SysButton = (action: Action, label: string) => Widget.Button({
+ on_clicked: () => powermenu.action(action),
+ child: Widget.Box({
+ vertical: true,
+ class_name: "system-button",
+ children: [
+ Widget.Icon(icons.powermenu[action]),
+ Widget.Label({
+ label,
+ visible: labels.bind(),
+ }),
+ ],
+ }),
+})
+
+export default () => PopupWindow({
+ name: "powermenu",
+ transition: "crossfade",
+ child: Widget.Box<Gtk.Widget>({
+ class_name: "powermenu horizontal",
+ setup: self => self.hook(layout, () => {
+ self.toggleClassName("box", layout.value === "box")
+ self.toggleClassName("line", layout.value === "line")
+ }),
+ children: layout.bind().as(layout => {
+ switch (layout) {
+ case "line": return [
+ SysButton("shutdown", "Shutdown"),
+ SysButton("logout", "Log Out"),
+ SysButton("reboot", "Reboot"),
+ SysButton("sleep", "Sleep"),
+ ]
+ case "box": return [
+ Widget.Box(
+ { vertical: true },
+ SysButton("shutdown", "Shutdown"),
+ SysButton("logout", "Log Out"),
+ ),
+ Widget.Box(
+ { vertical: true },
+ SysButton("reboot", "Reboot"),
+ SysButton("sleep", "Sleep"),
+ ),
+ ]
+ }
+ }),
+ }),
+})
diff --git a/.config/ags/widget/powermenu/Verification.ts b/.config/ags/widget/powermenu/Verification.ts
new file mode 100644
index 0000000..e85c81a
--- /dev/null
+++ b/.config/ags/widget/powermenu/Verification.ts
@@ -0,0 +1,47 @@
+import PopupWindow from "widget/PopupWindow"
+import powermenu from "service/powermenu"
+
+export default () => PopupWindow({
+ name: "verification",
+ transition: "crossfade",
+ child: Widget.Box({
+ class_name: "verification",
+ vertical: true,
+ children: [
+ Widget.Box({
+ class_name: "text-box",
+ vertical: true,
+ children: [
+ Widget.Label({
+ class_name: "title",
+ label: powermenu.bind("title"),
+ }),
+ Widget.Label({
+ class_name: "desc",
+ label: "Are you sure?",
+ }),
+ ],
+ }),
+ Widget.Box({
+ class_name: "buttons horizontal",
+ vexpand: true,
+ vpack: "end",
+ homogeneous: true,
+ children: [
+ Widget.Button({
+ child: Widget.Label("No"),
+ on_clicked: () => App.toggleWindow("verification"),
+ setup: self => self.hook(App, (_, name: string, visible: boolean) => {
+ if (name === "verification" && visible)
+ self.grab_focus()
+ }),
+ }),
+ Widget.Button({
+ child: Widget.Label("Yes"),
+ on_clicked: () => Utils.exec(powermenu.cmd),
+ }),
+ ],
+ }),
+ ],
+ }),
+})
diff --git a/.config/ags/widget/powermenu/powermenu.scss b/.config/ags/widget/powermenu/powermenu.scss
new file mode 100644
index 0000000..d5ce0de
--- /dev/null
+++ b/.config/ags/widget/powermenu/powermenu.scss
@@ -0,0 +1,110 @@
+window#powermenu,
+window#verification {
+ // the fraction has to be more than hyprland ignorealpha
+ background-color: rgba(0, 0, 0, .4);
+}
+
+window#verification .verification {
+ @include floating-widget;
+ padding: $popover-padding * 1.5;
+ min-width: 300px;
+ min-height: 100px;
+
+ .text-box {
+ margin-bottom: $spacing;
+
+ .title {
+ font-size: 1.6em;
+ }
+
+ .desc {
+ color: transparentize($fg, 0.1);
+ font-size: 1.1em;
+ }
+ }
+
+ .buttons {
+ @include spacing;
+ margin-top: $padding;
+
+ button {
+ @include button;
+ font-size: 1.5em;
+ padding: $padding;
+ }
+ }
+}
+
+window#powermenu .powermenu {
+ @include floating-widget;
+
+ &.line {
+ padding: $popover-padding * 1.5;
+
+ button {
+ padding: $popover-padding;
+ }
+
+ label {
+ margin-bottom: $spacing * -.5;
+ }
+ }
+
+ &.box {
+ padding: $popover-padding * 2;
+
+ button {
+ padding: $popover-padding * 1.5;
+ }
+
+ label {
+ margin-bottom: $spacing * -1;
+ }
+ }
+
+ button {
+ @include unset;
+
+ image {
+ @include button;
+ border-radius: $radius + ($popover-padding * 1.4);
+ min-width: 1.7em;
+ min-height: 1.7em;
+ font-size: 4em;
+ }
+
+ label,
+ image {
+ color: transparentize($fg, 0.1);
+ }
+
+ label {
+ margin-top: $spacing * .3;
+ }
+
+ &:hover {
+ image {
+ @include button-hover;
+ }
+
+ label {
+ color: $fg;
+ }
+ }
+
+ &:focus image {
+ @include button-focus;
+ }
+
+ &:active image {
+ @include button-active;
+ }
+
+ &:focus,
+ &:active {
+ label {
+ color: $primary-bg;
+ }
+ }
+ }
+}
diff --git a/.config/ags/widget/quicksettings/QuickSettings.ts b/.config/ags/widget/quicksettings/QuickSettings.ts
new file mode 100644
index 0000000..2c0d6ac
--- /dev/null
+++ b/.config/ags/widget/quicksettings/QuickSettings.ts
@@ -0,0 +1,84 @@
+import type Gtk from "gi://Gtk?version=3.0"
+import { ProfileSelector, ProfileToggle } from "./widgets/PowerProfile"
+import { Header } from "./widgets/Header"
+import { Volume, Microhone, SinkSelector, AppMixer } from "./widgets/Volume"
+import { Brightness } from "./widgets/Brightness"
+import { NetworkToggle, WifiSelection } from "./widgets/Network"
+import { BluetoothToggle, BluetoothDevices } from "./widgets/Bluetooth"
+import { DND } from "./widgets/DND"
+import { DarkModeToggle } from "./widgets/DarkMode"
+import { MicMute } from "./widgets/MicMute"
+import { Media } from "./widgets/Media"
+import PopupWindow from "widget/PopupWindow"
+import options from "options"
+
+const { bar, quicksettings } = options
+const media = (await Service.import("mpris")).bind("players")
+const layout = Utils.derive([bar.position, quicksettings.position], (bar, qs) =>
+ `${bar}-${qs}` as const,
+)
+
+const Row = (
+ toggles: Array<() => Gtk.Widget> = [],
+ menus: Array<() => Gtk.Widget> = [],
+) => Widget.Box({
+ vertical: true,
+ children: [
+ Widget.Box({
+ homogeneous: true,
+ class_name: "row horizontal",
+ children: toggles.map(w => w()),
+ }),
+ ...menus.map(w => w()),
+ ],
+})
+
+const Settings = () => Widget.Box({
+ vertical: true,
+ class_name: "quicksettings vertical",
+ css: quicksettings.width.bind().as(w => `min-width: ${w}px;`),
+ children: [
+ Header(),
+ Widget.Box({
+ class_name: "sliders-box vertical",
+ vertical: true,
+ children: [
+ Row(
+ [Volume],
+ [SinkSelector, AppMixer],
+ ),
+ Microhone(),
+ Brightness(),
+ ],
+ }),
+ Row(
+ [NetworkToggle, BluetoothToggle],
+ [WifiSelection, BluetoothDevices],
+ ),
+ Row(
+ [ProfileToggle, DarkModeToggle],
+ [ProfileSelector],
+ ),
+ Row([MicMute, DND]),
+ Widget.Box({
+ visible: media.as(l => l.length > 0),
+ child: Media(),
+ }),
+ ],
+})
+
+const QuickSettings = () => PopupWindow({
+ name: "quicksettings",
+ exclusivity: "exclusive",
+ transition: bar.position.bind().as(pos => pos === "top" ? "slide_down" : "slide_up"),
+ layout: layout.value,
+ child: Settings(),
+})
+
+export function setupQuickSettings() {
+ App.addWindow(QuickSettings())
+ layout.connect("changed", () => {
+ App.removeWindow("quicksettings")
+ App.addWindow(QuickSettings())
+ })
+}
diff --git a/.config/ags/widget/quicksettings/ToggleButton.ts b/.config/ags/widget/quicksettings/ToggleButton.ts
new file mode 100644
index 0000000..62a2e67
--- /dev/null
+++ b/.config/ags/widget/quicksettings/ToggleButton.ts
@@ -0,0 +1,154 @@
+import { type Props as IconProps } from "types/widgets/icon"
+import { type Props as LabelProps } from "types/widgets/label"
+import type GObject from "gi://GObject?version=2.0"
+import type Gtk from "gi://Gtk?version=3.0"
+import icons from "lib/icons"
+
+export const opened = Variable("")
+App.connect("window-toggled", (_, name: string, visible: boolean) => {
+ if (name === "quicksettings" && !visible)
+ Utils.timeout(500, () => opened.value = "")
+})
+
+export const Arrow = (name: string, activate?: false | (() => void)) => {
+ let deg = 0
+ let iconOpened = false
+ const icon = Widget.Icon(icons.ui.arrow.right).hook(opened, () => {
+ if (opened.value === name && !iconOpened || opened.value !== name && iconOpened) {
+ const step = opened.value === name ? 10 : -10
+ iconOpened = !iconOpened
+ for (let i = 0; i < 9; ++i) {
+ Utils.timeout(15 * i, () => {
+ deg += step
+ icon.setCss(`-gtk-icon-transform: rotate(${deg}deg);`)
+ })
+ }
+ }
+ })
+ return Widget.Button({
+ child: icon,
+ class_name: "arrow",
+ on_clicked: () => {
+ opened.value = opened.value === name ? "" : name
+ if (typeof activate === "function")
+ activate()
+ },
+ })
+}
+
+type ArrowToggleButtonProps = {
+ name: string
+ icon: IconProps["icon"]
+ label: LabelProps["label"]
+ activate: () => void
+ deactivate: () => void
+ activateOnArrow?: boolean
+ connection: [GObject.Object, () => boolean]
+}
+export const ArrowToggleButton = ({
+ name,
+ icon,
+ label,
+ activate,
+ deactivate,
+ activateOnArrow = true,
+ connection: [service, condition],
+}: ArrowToggleButtonProps) => Widget.Box({
+ class_name: "toggle-button",
+ setup: self => self.hook(service, () => {
+ self.toggleClassName("active", condition())
+ }),
+ children: [
+ Widget.Button({
+ child: Widget.Box({
+ hexpand: true,
+ children: [
+ Widget.Icon({
+ class_name: "icon",
+ icon,
+ }),
+ Widget.Label({
+ class_name: "label",
+ max_width_chars: 10,
+ truncate: "end",
+ label,
+ }),
+ ],
+ }),
+ on_clicked: () => {
+ if (condition()) {
+ deactivate()
+ if (opened.value === name)
+ opened.value = ""
+ } else {
+ activate()
+ }
+ },
+ }),
+ Arrow(name, activateOnArrow && activate),
+ ],
+})
+
+type MenuProps = {
+ name: string
+ icon: IconProps["icon"]
+ title: LabelProps["label"]
+ content: Gtk.Widget[]
+}
+export const Menu = ({ name, icon, title, content }: MenuProps) => Widget.Revealer({
+ transition: "slide_down",
+ reveal_child: opened.bind().as(v => v === name),
+ child: Widget.Box({
+ class_names: ["menu", name],
+ vertical: true,
+ children: [
+ Widget.Box({
+ class_name: "title-box",
+ children: [
+ Widget.Icon({
+ class_name: "icon",
+ icon,
+ }),
+ Widget.Label({
+ class_name: "title",
+ truncate: "end",
+ label: title,
+ }),
+ ],
+ }),
+ Widget.Separator(),
+ Widget.Box({
+ vertical: true,
+ class_name: "content vertical",
+ children: content,
+ }),
+ ],
+ }),
+})
+
+type SimpleToggleButtonProps = {
+ icon: IconProps["icon"]
+ label: LabelProps["label"]
+ toggle: () => void
+ connection: [GObject.Object, () => boolean]
+}
+export const SimpleToggleButton = ({
+ icon,
+ label,
+ toggle,
+ connection: [service, condition],
+}: SimpleToggleButtonProps) => Widget.Button({
+ on_clicked: toggle,
+ class_name: "simple-toggle",
+ setup: self => self.hook(service, () => {
+ self.toggleClassName("active", condition())
+ }),
+ child: Widget.Box([
+ Widget.Icon({ icon }),
+ Widget.Label({
+ max_width_chars: 10,
+ truncate: "end",
+ label,
+ }),
+ ]),
+})
diff --git a/.config/ags/widget/quicksettings/quicksettings.scss b/.config/ags/widget/quicksettings/quicksettings.scss
new file mode 100644
index 0000000..bd18ff1
--- /dev/null
+++ b/.config/ags/widget/quicksettings/quicksettings.scss
@@ -0,0 +1,177 @@
+window#quicksettings .quicksettings {
+ @include floating-widget;
+ @include spacing;
+
+ padding: $popover-padding * 1.4;
+
+ .avatar {
+ @include widget;
+ border-radius: $radius * 3;
+ }
+
+ .header {
+ @include spacing(.5);
+ color: transparentize($fg, .15);
+
+ button {
+ @include button;
+ padding: $padding;
+
+ image {
+ font-size: 1.4em;
+ }
+ }
+ }
+
+ .sliders-box {
+ @include widget;
+ padding: $padding;
+
+ button {
+ @include button($flat: true);
+ padding: $padding * .5;
+ }
+
+ .volume button.arrow:last-child {
+ margin-left: $spacing * .4;
+ }
+
+ .volume,
+ .brightness {
+ padding: $padding * .5;
+ }
+
+ scale {
+ @include slider;
+ margin: 0 ($spacing * .5);
+
+ &.muted highlight {
+ background-image: none;
+ background-color: transparentize($fg, $amount: .2);
+ }
+ }
+ }
+
+ .row {
+ @include spacing;
+ }
+
+ .menu {
+ @include unset;
+ @include widget;
+ padding: $padding;
+ margin-top: $spacing;
+
+ .icon {
+ margin: 0 ($spacing * .5);
+ margin-left: $spacing * .2;
+ }
+
+ .title {
+ font-weight: bold;
+ }
+
+ separator {
+ margin: ($radius * .5);
+ background-color: $border-color;
+ }
+
+ button {
+ @include button($flat: true);
+ padding: ($padding * .5);
+
+ image:first-child {
+ margin-right: $spacing * .5;
+ }
+ }
+
+ .bluetooth-devices {
+ @include spacing(.5);
+ }
+
+ switch {
+ @include switch;
+ }
+ }
+
+ .sliders-box .menu {
+ margin: ($spacing * .5) 0;
+
+ &.app-mixer {
+ .mixer-item {
+ padding: $padding * .5;
+ padding-left: 0;
+ padding-right: $padding * 2;
+
+ scale {
+ @include slider($width: .5em);
+ }
+
+ image {
+ font-size: 1.2em;
+ margin: 0 $padding;
+ }
+ }
+ }
+ }
+
+ .toggle-button {
+ @include button;
+ font-weight: bold;
+
+ image {
+ font-size: 1.3em;
+ }
+
+ label {
+ margin-left: $spacing * .3;
+ }
+
+ button {
+ @include button($flat: true);
+
+ &:first-child {
+ padding: $padding * 1.2;
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+
+ &:last-child {
+ padding: $padding * .5;
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ }
+ }
+
+ &.active {
+ background-color: $primary-bg;
+
+ label,
+ image {
+ color: $primary-fg;
+ }
+ }
+ }
+
+ .simple-toggle {
+ @include button;
+ font-weight: bold;
+ padding: $padding * 1.2;
+
+ label {
+ margin-left: $spacing * .3;
+ }
+
+ image {
+ font-size: 1.3em;
+ }
+ }
+
+ .media {
+ @include spacing;
+
+ .player {
+ @include media;
+ }
+ }
+}
diff --git a/.config/ags/widget/quicksettings/widgets/Bluetooth.ts b/.config/ags/widget/quicksettings/widgets/Bluetooth.ts
new file mode 100644
index 0000000..649e654
--- /dev/null
+++ b/.config/ags/widget/quicksettings/widgets/Bluetooth.ts
@@ -0,0 +1,61 @@
+import { type BluetoothDevice } from "types/service/bluetooth"
+import { Menu, ArrowToggleButton } from "../ToggleButton"
+import icons from "lib/icons"
+
+const bluetooth = await Service.import("bluetooth")
+
+export const BluetoothToggle = () => ArrowToggleButton({
+ name: "bluetooth",
+ icon: bluetooth.bind("enabled").as(p => icons.bluetooth[p ? "enabled" : "disabled"]),
+ label: Utils.watch("Disabled", bluetooth, () => {
+ if (!bluetooth.enabled)
+ return "Disabled"
+
+ if (bluetooth.connected_devices.length === 1)
+ return bluetooth.connected_devices[0].alias
+
+ return `${bluetooth.connected_devices.length} Connected`
+ }),
+ connection: [bluetooth, () => bluetooth.enabled],
+ deactivate: () => bluetooth.enabled = false,
+ activate: () => bluetooth.enabled = true,
+})
+
+const DeviceItem = (device: BluetoothDevice) => Widget.Box({
+ children: [
+ Widget.Icon(device.icon_name + "-symbolic"),
+ Widget.Label(device.name),
+ Widget.Label({
+ label: `${device.battery_percentage}%`,
+ visible: device.bind("battery_percentage").as(p => p > 0),
+ }),
+ Widget.Box({ hexpand: true }),
+ Widget.Spinner({
+ active: device.bind("connecting"),
+ visible: device.bind("connecting"),
+ }),
+ Widget.Switch({
+ active: device.connected,
+ visible: device.bind("connecting").as(p => !p),
+ setup: self => self.on("notify::active", () => {
+ device.setConnection(self.active)
+ }),
+ }),
+ ],
+})
+
+export const BluetoothDevices = () => Menu({
+ name: "bluetooth",
+ icon: icons.bluetooth.disabled,
+ title: "Bluetooth",
+ content: [
+ Widget.Box({
+ class_name: "bluetooth-devices",
+ hexpand: true,
+ vertical: true,
+ children: bluetooth.bind("devices").as(ds => ds
+ .filter(d => d.name)
+ .map(DeviceItem)),
+ }),
+ ],
+})
diff --git a/.config/ags/widget/quicksettings/widgets/Brightness.ts b/.config/ags/widget/quicksettings/widgets/Brightness.ts
new file mode 100644
index 0000000..a3ce565
--- /dev/null
+++ b/.config/ags/widget/quicksettings/widgets/Brightness.ts
@@ -0,0 +1,23 @@
+import icons from "lib/icons"
+import brightness from "service/brightness"
+
+const BrightnessSlider = () => Widget.Slider({
+ draw_value: false,
+ hexpand: true,
+ value: brightness.bind("screen"),
+ on_change: ({ value }) => brightness.screen = value,
+})
+
+export const Brightness = () => Widget.Box({
+ class_name: "brightness",
+ children: [
+ Widget.Button({
+ vpack: "center",
+ child: Widget.Icon(icons.brightness.indicator),
+ on_clicked: () => brightness.screen = 0,
+ tooltip_text: brightness.bind("screen").as(v =>
+ `Screen Brightness: ${Math.floor(v * 100)}%`),
+ }),
+ BrightnessSlider(),
+ ],
+})
diff --git a/.config/ags/widget/quicksettings/widgets/DND.ts b/.config/ags/widget/quicksettings/widgets/DND.ts
new file mode 100644
index 0000000..7fc1fd0
--- /dev/null
+++ b/.config/ags/widget/quicksettings/widgets/DND.ts
@@ -0,0 +1,12 @@
+import { SimpleToggleButton } from "../ToggleButton"
+import icons from "lib/icons"
+
+const n = await Service.import("notifications")
+const dnd = n.bind("dnd")
+
+export const DND = () => SimpleToggleButton({
+ icon: dnd.as(dnd => icons.notifications[dnd ? "silent" : "noisy"]),
+ label: dnd.as(dnd => dnd ? "Silent" : "Noisy"),
+ toggle: () => n.dnd = !n.dnd,
+ connection: [n, () => n.dnd],
+})
diff --git a/.config/ags/widget/quicksettings/widgets/DarkMode.ts b/.config/ags/widget/quicksettings/widgets/DarkMode.ts
new file mode 100644
index 0000000..9ec94df
--- /dev/null
+++ b/.config/ags/widget/quicksettings/widgets/DarkMode.ts
@@ -0,0 +1,12 @@
+import { SimpleToggleButton } from "../ToggleButton"
+import icons from "lib/icons"
+import options from "options"
+
+const { scheme } = options.theme
+
+export const DarkModeToggle = () => SimpleToggleButton({
+ icon: scheme.bind().as(s => icons.color[s]),
+ label: scheme.bind().as(s => s === "dark" ? "Dark" : "Light"),
+ toggle: () => scheme.value = scheme.value === "dark" ? "light" : "dark",
+ connection: [scheme, () => scheme.value === "dark"],
+})
diff --git a/.config/ags/widget/quicksettings/widgets/Header.ts b/.config/ags/widget/quicksettings/widgets/Header.ts
new file mode 100644
index 0000000..44c26f2
--- /dev/null
+++ b/.config/ags/widget/quicksettings/widgets/Header.ts
@@ -0,0 +1,69 @@
+import icons from "lib/icons"
+import { uptime } from "lib/variables"
+import options from "options"
+import powermenu, { Action } from "service/powermenu"
+
+const battery = await Service.import("battery")
+const { image, size } = options.quicksettings.avatar
+
+function up(up: number) {
+ const h = Math.floor(up / 60)
+ const m = Math.floor(up % 60)
+ return `${h}h ${m < 10 ? "0" + m : m}m`
+}
+
+const Avatar = () => Widget.Box({
+ class_name: "avatar",
+ css: Utils.merge([image.bind(), size.bind()], (img, size) => `
+ min-width: ${size}px;
+ min-height: ${size}px;
+ background-image: url('${img}');
+ background-size: cover;
+ `),
+})
+
+const SysButton = (action: Action) => Widget.Button({
+ vpack: "center",
+ child: Widget.Icon(icons.powermenu[action]),
+ on_clicked: () => powermenu.action(action),
+})
+
+export const Header = () => Widget.Box(
+ { class_name: "header horizontal" },
+ Avatar(),
+ Widget.Box({
+ vertical: true,
+ vpack: "center",
+ children: [
+ Widget.Box({
+ visible: battery.bind("available"),
+ children: [
+ Widget.Icon({ icon: battery.bind("icon_name") }),
+ Widget.Label({ label: battery.bind("percent").as(p => `${p}%`) }),
+ ],
+ }),
+ Widget.Box([
+ Widget.Icon({ icon: icons.ui.time }),
+ Widget.Label({ label: uptime.bind().as(up) }),
+
+ //Widget.Label({ label: `${user.name}\n` }),
+ // //Widget.Label({ label: uptime.bind().value }),
+ // Widget.Label({ label: `${user.name}\n ${uptime.bind().value}` }),
+ // //Widget.Icon({ icon: icons.ui.time }),
+ ]),
+
+ ],
+ }),
+ Widget.Box({ hexpand: true }),
+ Widget.Button({
+ vpack: "center",
+ child: Widget.Icon(icons.ui.settings),
+ on_clicked: () => {
+ App.closeWindow("quicksettings")
+ App.closeWindow("settings-dialog")
+ App.openWindow("settings-dialog")
+ },
+ }),
+ SysButton("logout"),
+ SysButton("shutdown"),
+)
diff --git a/.config/ags/widget/quicksettings/widgets/Media.ts b/.config/ags/widget/quicksettings/widgets/Media.ts
new file mode 100644
index 0000000..52254ea
--- /dev/null
+++ b/.config/ags/widget/quicksettings/widgets/Media.ts
@@ -0,0 +1,153 @@
+import { type MprisPlayer } from "types/service/mpris"
+import icons from "lib/icons"
+import options from "options"
+import { icon } from "lib/utils"
+
+const mpris = await Service.import("mpris")
+const players = mpris.bind("players")
+const { media } = options.quicksettings
+
+function lengthStr(length: number) {
+ const min = Math.floor(length / 60)
+ const sec = Math.floor(length % 60)
+ const sec0 = sec < 10 ? "0" : ""
+ return `${min}:${sec0}${sec}`
+}
+
+const Player = (player: MprisPlayer) => {
+ const cover = Widget.Box({
+ class_name: "cover",
+ vpack: "start",
+ css: Utils.merge([
+ player.bind("cover_path"),
+ player.bind("track_cover_url"),
+ media.coverSize.bind(),
+ ], (path, url, size) => `
+ min-width: ${size}px;
+ min-height: ${size}px;
+ background-image: url('${path || url}');
+ `),
+ })
+
+ const title = Widget.Label({
+ class_name: "title",
+ max_width_chars: 20,
+ truncate: "end",
+ hpack: "start",
+ label: player.bind("track_title"),
+ })
+
+ const artist = Widget.Label({
+ class_name: "artist",
+ max_width_chars: 20,
+ truncate: "end",
+ hpack: "start",
+ label: player.bind("track_artists").as(a => a.join(", ")),
+ })
+
+ const positionSlider = Widget.Slider({
+ class_name: "position",
+ draw_value: false,
+ on_change: ({ value }) => player.position = value * player.length,
+ setup: self => {
+ const update = () => {
+ const { length, position } = player
+ self.visible = length > 0
+ self.value = length > 0 ? position / length : 0
+ }
+ self.hook(player, update)
+ self.hook(player, update, "position")
+ self.poll(1000, update)
+ },
+ })
+
+ const positionLabel = Widget.Label({
+ class_name: "position",
+ hpack: "start",
+ setup: self => {
+ const update = (_: unknown, time?: number) => {
+ self.label = lengthStr(time || player.position)
+ self.visible = player.length > 0
+ }
+ self.hook(player, update, "position")
+ self.poll(1000, update)
+ },
+ })
+
+ const lengthLabel = Widget.Label({
+ class_name: "length",
+ hpack: "end",
+ visible: player.bind("length").as(l => l > 0),
+ label: player.bind("length").as(lengthStr),
+ })
+
+ const playericon = Widget.Icon({
+ class_name: "icon",
+ hexpand: true,
+ hpack: "end",
+ vpack: "start",
+ tooltip_text: player.identity || "",
+ icon: Utils.merge([player.bind("entry"), media.monochromeIcon.bind()], (e, s) => {
+ const name = `${e}${s ? "-symbolic" : ""}`
+ return icon(name, icons.fallback.audio)
+ }),
+ })
+
+ const playPause = Widget.Button({
+ class_name: "play-pause",
+ on_clicked: () => player.playPause(),
+ visible: player.bind("can_play"),
+ child: Widget.Icon({
+ icon: player.bind("play_back_status").as(s => {
+ switch (s) {
+ case "Playing": return icons.mpris.playing
+ case "Paused":
+ case "Stopped": return icons.mpris.stopped
+ }
+ }),
+ }),
+ })
+
+ const prev = Widget.Button({
+ on_clicked: () => player.previous(),
+ visible: player.bind("can_go_prev"),
+ child: Widget.Icon(icons.mpris.prev),
+ })
+
+ const next = Widget.Button({
+ on_clicked: () => player.next(),
+ visible: player.bind("can_go_next"),
+ child: Widget.Icon(icons.mpris.next),
+ })
+
+ return Widget.Box(
+ { class_name: "player", vexpand: false },
+ cover,
+ Widget.Box(
+ { vertical: true },
+ Widget.Box([
+ title,
+ playericon,
+ ]),
+ artist,
+ Widget.Box({ vexpand: true }),
+ positionSlider,
+ Widget.CenterBox({
+ class_name: "footer horizontal",
+ start_widget: positionLabel,
+ center_widget: Widget.Box([
+ prev,
+ playPause,
+ next,
+ ]),
+ end_widget: lengthLabel,
+ }),
+ ),
+ )
+}
+
+export const Media = () => Widget.Box({
+ vertical: true,
+ class_name: "media vertical",
+ children: players.as(p => p.map(Player)),
+})
diff --git a/.config/ags/widget/quicksettings/widgets/MicMute.ts b/.config/ags/widget/quicksettings/widgets/MicMute.ts
new file mode 100644
index 0000000..b6e9454
--- /dev/null
+++ b/.config/ags/widget/quicksettings/widgets/MicMute.ts
@@ -0,0 +1,18 @@
+import { SimpleToggleButton } from "../ToggleButton"
+import icons from "lib/icons"
+const { microphone } = await Service.import("audio")
+
+const icon = () => microphone.is_muted || microphone.stream?.is_muted
+ ? icons.audio.mic.muted
+ : icons.audio.mic.high
+
+const label = () => microphone.is_muted || microphone.stream?.is_muted
+ ? "Muted"
+ : "Unmuted"
+
+export const MicMute = () => SimpleToggleButton({
+ icon: Utils.watch(icon(), microphone, icon),
+ label: Utils.watch(label(), microphone, label),
+ toggle: () => microphone.is_muted = !microphone.is_muted,
+ connection: [microphone, () => microphone?.is_muted || false],
+})
diff --git a/.config/ags/widget/quicksettings/widgets/Network.ts b/.config/ags/widget/quicksettings/widgets/Network.ts
new file mode 100644
index 0000000..eb14ab4
--- /dev/null
+++ b/.config/ags/widget/quicksettings/widgets/Network.ts
@@ -0,0 +1,61 @@
+import { Menu, ArrowToggleButton } from "../ToggleButton"
+import icons from "lib/icons.js"
+import { dependencies, sh } from "lib/utils"
+import options from "options"
+const { wifi } = await Service.import("network")
+
+export const NetworkToggle = () => ArrowToggleButton({
+ name: "network",
+ icon: wifi.bind("icon_name"),
+ label: wifi.bind("ssid").as(ssid => ssid || "Not Connected"),
+ connection: [wifi, () => wifi.enabled],
+ deactivate: () => wifi.enabled = false,
+ activate: () => {
+ wifi.enabled = true
+ wifi.scan()
+ },
+})
+
+export const WifiSelection = () => Menu({
+ name: "network",
+ icon: wifi.bind("icon_name"),
+ title: "Wifi Selection",
+ content: [
+ Widget.Box({
+ vertical: true,
+ setup: self => self.hook(wifi, () => self.children =
+ wifi.access_points.map(ap => Widget.Button({
+ on_clicked: () => {
+ if (dependencies("nmcli"))
+ Utils.execAsync(`nmcli device wifi connect ${ap.bssid}`)
+ },
+ child: Widget.Box({
+ children: [
+ Widget.Icon(ap.iconName),
+ Widget.Label(ap.ssid || ""),
+ Widget.Icon({
+ icon: icons.ui.tick,
+ hexpand: true,
+ hpack: "end",
+ setup: self => Utils.idle(() => {
+ if (!self.is_destroyed)
+ self.visible = ap.active
+ }),
+ }),
+ ],
+ }),
+ })),
+ ),
+ }),
+ Widget.Separator(),
+ Widget.Button({
+ on_clicked: () => sh(options.quicksettings.networkSettings.value),
+ child: Widget.Box({
+ children: [
+ Widget.Icon(icons.ui.settings),
+ Widget.Label("Network"),
+ ],
+ }),
+ }),
+ ],
+})
diff --git a/.config/ags/widget/quicksettings/widgets/PowerProfile.ts b/.config/ags/widget/quicksettings/widgets/PowerProfile.ts
new file mode 100644
index 0000000..f566aaf
--- /dev/null
+++ b/.config/ags/widget/quicksettings/widgets/PowerProfile.ts
@@ -0,0 +1,99 @@
+import { ArrowToggleButton, Menu } from "../ToggleButton"
+import icons from "lib/icons"
+
+import asusctl from "service/asusctl"
+const asusprof = asusctl.bind("profile")
+
+const AsusProfileToggle = () => ArrowToggleButton({
+ name: "asusctl-profile",
+ icon: asusprof.as(p => icons.asusctl.profile[p]),
+ label: asusprof,
+ connection: [asusctl, () => asusctl.profile !== "Balanced"],
+ activate: () => asusctl.setProfile("Quiet"),
+ deactivate: () => asusctl.setProfile("Balanced"),
+ activateOnArrow: false,
+})
+
+const AsusProfileSelector = () => Menu({
+ name: "asusctl-profile",
+ icon: asusprof.as(p => icons.asusctl.profile[p]),
+ title: "Profile Selector",
+ content: [
+ Widget.Box({
+ vertical: true,
+ hexpand: true,
+ children: [
+ Widget.Box({
+ vertical: true,
+ children: asusctl.profiles.map(prof => Widget.Button({
+ on_clicked: () => asusctl.setProfile(prof),
+ child: Widget.Box({
+ children: [
+ Widget.Icon(icons.asusctl.profile[prof]),
+ Widget.Label(prof),
+ ],
+ }),
+ })),
+ }),
+ ],
+ }),
+ Widget.Separator(),
+ Widget.Button({
+ on_clicked: () => Utils.execAsync("rog-control-center"),
+ child: Widget.Box({
+ children: [
+ Widget.Icon(icons.ui.settings),
+ Widget.Label("Rog Control Center"),
+ ],
+ }),
+ }),
+ ],
+})
+
+
+const pp = await Service.import("powerprofiles")
+const profile = pp.bind("active_profile")
+const profiles = pp.profiles.map(p => p.Profile)
+
+const pretty = (str: string) => str
+ .split("-")
+ .map(str => `${str.at(0)?.toUpperCase()}${str.slice(1)}`)
+ .join(" ")
+
+const PowerProfileToggle = () => ArrowToggleButton({
+ name: "asusctl-profile",
+ icon: profile.as(p => icons.powerprofile[p]),
+ label: profile.as(pretty),
+ connection: [pp, () => pp.active_profile !== profiles[1]],
+ activate: () => pp.active_profile = profiles[0],
+ deactivate: () => pp.active_profile = profiles[1],
+ activateOnArrow: false,
+})
+
+const PowerProfileSelector = () => Menu({
+ name: "asusctl-profile",
+ icon: profile.as(p => icons.powerprofile[p]),
+ title: "Profile Selector",
+ content: [Widget.Box({
+ vertical: true,
+ hexpand: true,
+ child: Widget.Box({
+ vertical: true,
+ children: profiles.map(prof => Widget.Button({
+ on_clicked: () => pp.active_profile = prof,
+ child: Widget.Box({
+ children: [
+ Widget.Icon(icons.powerprofile[prof]),
+ Widget.Label(pretty(prof)),
+ ],
+ }),
+ })),
+ }),
+ })],
+})
+
+export const ProfileToggle = asusctl.available
+ ? AsusProfileToggle : PowerProfileToggle
+
+export const ProfileSelector = asusctl.available
+ ? AsusProfileSelector : PowerProfileSelector
diff --git a/.config/ags/widget/quicksettings/widgets/Volume.ts b/.config/ags/widget/quicksettings/widgets/Volume.ts
new file mode 100644
index 0000000..077439a
--- /dev/null
+++ b/.config/ags/widget/quicksettings/widgets/Volume.ts
@@ -0,0 +1,161 @@
+import { type Stream } from "types/service/audio"
+import { Arrow, Menu } from "../ToggleButton"
+import { dependencies, icon, sh } from "lib/utils"
+import icons from "lib/icons.js"
+const audio = await Service.import("audio")
+
+type Type = "microphone" | "speaker"
+
+const VolumeIndicator = (type: Type = "speaker") => Widget.Button({
+ vpack: "center",
+ on_clicked: () => audio[type].is_muted = !audio[type].is_muted,
+ child: Widget.Icon({
+ icon: audio[type].bind("icon_name")
+ .as(i => icon(i || "", icons.audio.volume.medium)),
+ tooltipText: audio[type].bind("volume")
+ .as(vol => `Volume: ${Math.floor(vol * 100)}%`),
+ }),
+})
+
+const micIndicator = (type: Type = "microphone") => Widget.Button({
+ vpack: "center",
+ on_clicked: () => audio[type].is_muted = !audio[type].is_muted,
+ child: Widget.Icon({
+ icon: audio[type].bind("icon_name")
+ .as(i => icon(i || "", icons.audio.mic.medium)),
+ tooltipText: audio[type].bind("volume")
+ .as(vol => `Volume: ${Math.floor(vol * 100)}%`),
+ }),
+})
+
+const VolumeSlider = (type: Type = "speaker") => Widget.Slider({
+ hexpand: true,
+ draw_value: false,
+ on_change: ({ value, dragging }) => {
+ if (dragging) {
+ audio[type].volume = value
+ audio[type].is_muted = false
+ }
+ },
+ value: audio[type].bind("volume"),
+ class_name: audio[type].bind("is_muted").as(m => m ? "muted" : ""),
+})
+
+export const Volume = () => Widget.Box({
+ class_name: "volume",
+ children: [
+ VolumeIndicator("speaker"),
+ VolumeSlider("speaker"),
+ Widget.Box({
+ vpack: "center",
+ child: Arrow("sink-selector"),
+ }),
+ Widget.Box({
+ vpack: "center",
+ child: Arrow("app-mixer"),
+ visible: audio.bind("apps").as(a => a.length > 0),
+ }),
+ ],
+})
+
+export const Microhone = () => Widget.Box({
+ class_name: "slider horizontal",
+ visible: audio.bind("recorders").as(a => a.length > 0),
+ children: [
+ micIndicator("microphone"),
+ VolumeSlider("microphone"),
+ ],
+})
+
+const MixerItem = (stream: Stream) => Widget.Box(
+ {
+ hexpand: true,
+ class_name: "mixer-item horizontal",
+ },
+ Widget.Icon({
+ tooltip_text: stream.bind("name").as(n => n || ""),
+ icon: stream.bind("name").as(n => {
+ return Utils.lookUpIcon(n || "")
+ ? (n || "")
+ : icons.fallback.audio
+ }),
+ }),
+ Widget.Box(
+ { vertical: true },
+ Widget.Label({
+ xalign: 0,
+ truncate: "end",
+ max_width_chars: 28,
+ label: stream.bind("description").as(d => d || ""),
+ }),
+ Widget.Slider({
+ hexpand: true,
+ draw_value: false,
+ value: stream.bind("volume"),
+ on_change: ({ value }) => stream.volume = value,
+ }),
+ ),
+)
+
+const SinkItem = (stream: Stream) => Widget.Button({
+ hexpand: true,
+ on_clicked: () => audio.speaker = stream,
+ child: Widget.Box({
+ children: [
+ Widget.Icon({
+ icon: icon(stream.icon_name || "", icons.fallback.audio),
+ tooltip_text: stream.icon_name || "",
+ }),
+ Widget.Label((stream.description || "").split(" ").slice(0, 4).join(" ")),
+ Widget.Icon({
+ icon: icons.ui.tick,
+ hexpand: true,
+ hpack: "end",
+ visible: audio.speaker.bind("stream").as(s => s === stream.stream),
+ }),
+ ],
+ }),
+})
+
+const SettingsButton = () => Widget.Button({
+ on_clicked: () => {
+ if (dependencies("pavucontrol"))
+ sh("pavucontrol")
+ },
+ hexpand: true,
+ child: Widget.Box({
+ children: [
+ Widget.Icon(icons.ui.settings),
+ Widget.Label("Settings"),
+ ],
+ }),
+})
+
+export const AppMixer = () => Menu({
+ name: "app-mixer",
+ icon: icons.audio.mixer,
+ title: "App Mixer",
+ content: [
+ Widget.Box({
+ vertical: true,
+ class_name: "vertical mixer-item-box",
+ children: audio.bind("apps").as(a => a.map(MixerItem)),
+ }),
+ Widget.Separator(),
+ SettingsButton(),
+ ],
+})
+
+export const SinkSelector = () => Menu({
+ name: "sink-selector",
+ icon: icons.audio.type.headset,
+ title: "Sink Selector",
+ content: [
+ Widget.Box({
+ vertical: true,
+ children: audio.bind("speakers").as(a => a.map(SinkItem)),
+ }),
+ Widget.Separator(),
+ SettingsButton(),
+ ],
+})
diff --git a/.config/ags/widget/settings/Group.ts b/.config/ags/widget/settings/Group.ts
new file mode 100644
index 0000000..e9356e0
--- /dev/null
+++ b/.config/ags/widget/settings/Group.ts
@@ -0,0 +1,34 @@
+import icons from "lib/icons"
+import Row from "./Row"
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export default (title: string, ...rows: ReturnType<typeof Row<any>>[]) => Widget.Box(
+ {
+ class_name: "group",
+ vertical: true,
+ },
+ Widget.Box([
+ Widget.Label({
+ hpack: "start",
+ vpack: "end",
+ class_name: "group-title",
+ label: title,
+ setup: w => Utils.idle(() => w.visible = !!title),
+ }),
+ title ? Widget.Button({
+ hexpand: true,
+ hpack: "end",
+ child: Widget.Icon(icons.ui.refresh),
+ class_name: "group-reset",
+ sensitive: Utils.merge(
+ rows.map(({ attribute: { opt } }) => opt.bind().as(v => v !== opt.initial)),
+ (...values) => values.some(b => b),
+ ),
+ on_clicked: () => rows.forEach(row => row.attribute.opt.reset()),
+ }) : Widget.Box(),
+ ]),
+ Widget.Box({
+ vertical: true,
+ children: rows,
+ }),
+)
diff --git a/.config/ags/widget/settings/Page.ts b/.config/ags/widget/settings/Page.ts
new file mode 100644
index 0000000..220e560
--- /dev/null
+++ b/.config/ags/widget/settings/Page.ts
@@ -0,0 +1,19 @@
+import Group from "./Group"
+
+export default <T>(
+ name: string,
+ icon: string,
+ ...groups: ReturnType<typeof Group<T>>[]
+) => Widget.Box({
+ class_name: "page",
+ attribute: { name, icon },
+ child: Widget.Scrollable({
+ css: "min-height: 300px;",
+ child: Widget.Box({
+ class_name: "page-content",
+ vexpand: true,
+ vertical: true,
+ children: groups,
+ }),
+ }),
+})
diff --git a/.config/ags/widget/settings/Row.ts b/.config/ags/widget/settings/Row.ts
new file mode 100644
index 0000000..1e17096
--- /dev/null
+++ b/.config/ags/widget/settings/Row.ts
@@ -0,0 +1,55 @@
+import { Opt } from "lib/option"
+import Setter from "./Setter"
+import icons from "lib/icons"
+
+export type RowProps<T> = {
+ opt: Opt<T>
+ title: string
+ note?: string
+ type?:
+ | "number"
+ | "color"
+ | "float"
+ | "object"
+ | "string"
+ | "enum"
+ | "boolean"
+ | "img"
+ | "font"
+ enums?: string[]
+ max?: number
+ min?: number
+}
+
+export default <T>(props: RowProps<T>) => Widget.Box(
+ {
+ attribute: { opt: props.opt },
+ class_name: "row",
+ tooltip_text: props.note ? `note: ${props.note}` : "",
+ },
+ Widget.Box(
+ { vertical: true, vpack: "center" },
+ Widget.Label({
+ xalign: 0,
+ class_name: "row-title",
+ label: props.title,
+ }),
+ Widget.Label({
+ xalign: 0,
+ class_name: "id",
+ label: props.opt.id,
+ }),
+ ),
+ Widget.Box({ hexpand: true }),
+ Widget.Box(
+ { vpack: "center" },
+ Setter(props),
+ ),
+ Widget.Button({
+ vpack: "center",
+ class_name: "reset",
+ child: Widget.Icon(icons.ui.refresh),
+ on_clicked: () => props.opt.reset(),
+ sensitive: props.opt.bind().as(v => v !== props.opt.initial),
+ }),
+)
diff --git a/.config/ags/widget/settings/Setter.ts b/.config/ags/widget/settings/Setter.ts
new file mode 100644
index 0000000..7e455c9
--- /dev/null
+++ b/.config/ags/widget/settings/Setter.ts
@@ -0,0 +1,93 @@
+import { type RowProps } from "./Row"
+import { Opt } from "lib/option"
+import icons from "lib/icons"
+import Gdk from "gi://Gdk"
+
+function EnumSetter(opt: Opt<string>, values: string[]) {
+ const lbl = Widget.Label({ label: opt.bind().as(v => `${v}`) })
+ const step = (dir: 1 | -1) => {
+ const i = values.findIndex(i => i === lbl.label)
+ opt.setValue(dir > 0
+ ? i + dir > values.length - 1 ? values[0] : values[i + dir]
+ : i + dir < 0 ? values[values.length - 1] : values[i + dir],
+ )
+ }
+ const next = Widget.Button({
+ child: Widget.Icon(icons.ui.arrow.right),
+ on_clicked: () => step(+1),
+ })
+ const prev = Widget.Button({
+ child: Widget.Icon(icons.ui.arrow.left),
+ on_clicked: () => step(-1),
+ })
+ return Widget.Box({
+ class_name: "enum-setter",
+ children: [lbl, prev, next],
+ })
+}
+
+export default function Setter<T>({
+ opt,
+ type = typeof opt.value as RowProps<T>["type"],
+ enums,
+ max = 1000,
+ min = 0,
+}: RowProps<T>) {
+ switch (type) {
+ case "number": return Widget.SpinButton({
+ setup(self) {
+ self.set_range(min, max)
+ self.set_increments(1, 5)
+ self.on("value-changed", () => opt.value = self.value as T)
+ self.hook(opt, () => self.value = opt.value as number)
+ },
+ })
+
+ case "float":
+ case "object": return Widget.Entry({
+ on_accept: self => opt.value = JSON.parse(self.text || ""),
+ setup: self => self.hook(opt, () => self.text = JSON.stringify(opt.value)),
+ })
+
+ case "string": return Widget.Entry({
+ on_accept: self => opt.value = self.text as T,
+ setup: self => self.hook(opt, () => self.text = opt.value as string),
+ })
+
+ case "enum": return EnumSetter(opt as unknown as Opt<string>, enums!)
+ case "boolean": return Widget.Switch()
+ .on("notify::active", self => opt.value = self.active as T)
+ .hook(opt, self => self.active = opt.value as boolean)
+
+ case "img": return Widget.FileChooserButton({
+ on_file_set: ({ uri }) => { opt.value = uri!.replace("file://", "") as T },
+ })
+
+ case "font": return Widget.FontButton({
+ show_size: false,
+ use_size: false,
+ setup: self => self
+ .hook(opt, () => self.font = opt.value as string)
+ .on("font-set", ({ font }) => opt.value = font!
+ .split(" ").slice(0, -1).join(" ") as T),
+ })
+
+ case "color": return Widget.ColorButton()
+ .hook(opt, self => {
+ const rgba = new Gdk.RGBA()
+ rgba.parse(opt.value as string)
+ self.rgba = rgba
+ })
+ .on("color-set", ({ rgba: { red, green, blue } }) => {
+ const hex = (n: number) => {
+ const c = Math.floor(255 * n).toString(16)
+ return c.length === 1 ? `0${c}` : c
+ }
+ opt.value = `#${hex(red)}${hex(green)}${hex(blue)}` as T
+ })
+
+ default: return Widget.Label({
+ label: `no setter with type ${type}`,
+ })
+ }
+}
diff --git a/.config/ags/widget/settings/SettingsDialog.ts b/.config/ags/widget/settings/SettingsDialog.ts
new file mode 100644
index 0000000..be0c35e
--- /dev/null
+++ b/.config/ags/widget/settings/SettingsDialog.ts
@@ -0,0 +1,63 @@
+import RegularWindow from "widget/RegularWindow"
+import layout from "./layout"
+import icons from "lib/icons"
+import options from "options"
+
+const current = Variable(layout[0].attribute.name)
+
+const Header = () => Widget.CenterBox({
+ class_name: "header",
+ start_widget: Widget.Button({
+ class_name: "reset",
+ on_clicked: options.reset,
+ hpack: "start",
+ vpack: "start",
+ child: Widget.Icon(icons.ui.refresh),
+ tooltip_text: "Reset",
+ }),
+ center_widget: Widget.Box({
+ class_name: "pager horizontal",
+ children: layout.map(({ attribute: { name, icon } }) => Widget.Button({
+ xalign: 0,
+ class_name: current.bind().as(v => `${v === name ? "active" : ""}`),
+ on_clicked: () => current.value = name,
+ child: Widget.Box([
+ Widget.Icon(icon),
+ Widget.Label(name),
+ ]),
+ })),
+ }),
+ end_widget: Widget.Button({
+ class_name: "close",
+ hpack: "end",
+ vpack: "start",
+ child: Widget.Icon(icons.ui.close),
+ on_clicked: () => App.closeWindow("settings-dialog"),
+ }),
+})
+
+const PagesStack = () => Widget.Stack({
+ transition: "slide_left_right",
+ children: layout.reduce((obj, page) => ({ ...obj, [page.attribute.name]: page }), {}),
+ shown: current.bind() as never,
+})
+
+export default () => RegularWindow({
+ name: "settings-dialog",
+ class_name: "settings-dialog",
+ title: "Settings",
+ setup(win) {
+ win.on("delete-event", () => {
+ win.hide()
+ return true
+ })
+ win.set_default_size(500, 600)
+ },
+ child: Widget.Box({
+ vertical: true,
+ children: [
+ Header(),
+ PagesStack(),
+ ],
+ }),
+})
diff --git a/.config/ags/widget/settings/Wallpaper.ts b/.config/ags/widget/settings/Wallpaper.ts
new file mode 100644
index 0000000..998f3b7
--- /dev/null
+++ b/.config/ags/widget/settings/Wallpaper.ts
@@ -0,0 +1,31 @@
+import wallpaper from "service/wallpaper"
+
+export default () => Widget.Box(
+ { class_name: "row wallpaper" },
+ Widget.Box(
+ { vertical: true },
+ Widget.Label({
+ xalign: 0,
+ class_name: "row-title",
+ label: "Wallpaper",
+ vpack: "start",
+ }),
+ Widget.Button({
+ on_clicked: wallpaper.random,
+ label: "Random",
+ }),
+ Widget.FileChooserButton({
+ on_file_set: ({ uri }) => wallpaper.set(uri!.replace("file://", "")),
+ }),
+ ),
+ Widget.Box({ hexpand: true }),
+ Widget.Box({
+ class_name: "preview",
+ css: wallpaper.bind("wallpaper").as(wp => `
+ min-height: 120px;
+ min-width: 200px;
+ background-image: url('${wp}');
+ background-size: cover;
+ `),
+ }),
+)
diff --git a/.config/ags/widget/settings/layout.ts b/.config/ags/widget/settings/layout.ts
new file mode 100644
index 0000000..2b45810
--- /dev/null
+++ b/.config/ags/widget/settings/layout.ts
@@ -0,0 +1,147 @@
+/* eslint-disable max-len */
+import Row from "./Row"
+import Group from "./Group"
+import Page from "./Page"
+import Wallpaper from "./Wallpaper"
+import options from "options"
+import icons from "lib/icons"
+
+const {
+ autotheme: at,
+ font,
+ theme,
+ bar: b,
+ launcher: l,
+ overview: ov,
+ powermenu: pm,
+ quicksettings: qs,
+ osd,
+ hyprland: h,
+} = options
+
+const {
+ dark,
+ light,
+ blur,
+ scheme,
+ padding,
+ spacing,
+ radius,
+ shadows,
+ widget,
+ border,
+} = theme
+
+export default [
+ Page("Theme", icons.ui.themes,
+ Group("",
+ Wallpaper() as ReturnType<typeof Row>,
+ Row({ opt: at, title: "Auto Generate Color Scheme" }),
+ Row({ opt: scheme, title: "Color Scheme", type: "enum", enums: ["dark", "light"] }),
+ ),
+ Group("Dark Colors",
+ Row({ opt: dark.bg, title: "Background", type: "color" }),
+ Row({ opt: dark.fg, title: "Foreground", type: "color" }),
+ Row({ opt: dark.primary.bg, title: "Primary", type: "color" }),
+ Row({ opt: dark.primary.fg, title: "On Primary", type: "color" }),
+ Row({ opt: dark.error.bg, title: "Error", type: "color" }),
+ Row({ opt: dark.error.fg, title: "On Error", type: "color" }),
+ Row({ opt: dark.widget, title: "Widget", type: "color" }),
+ Row({ opt: dark.border, title: "Border", type: "color" }),
+ ),
+ Group("Light Colors",
+ Row({ opt: light.bg, title: "Background", type: "color" }),
+ Row({ opt: light.fg, title: "Foreground", type: "color" }),
+ Row({ opt: light.primary.bg, title: "Primary", type: "color" }),
+ Row({ opt: light.primary.fg, title: "On Primary", type: "color" }),
+ Row({ opt: light.error.bg, title: "Error", type: "color" }),
+ Row({ opt: light.error.fg, title: "On Error", type: "color" }),
+ Row({ opt: light.widget, title: "Widget", type: "color" }),
+ Row({ opt: light.border, title: "Border", type: "color" }),
+ ),
+ Group("Theme",
+ Row({ opt: shadows, title: "Shadows" }),
+ Row({ opt: widget.opacity, title: "Widget Opacity", max: 100 }),
+ Row({ opt: border.opacity, title: "Border Opacity", max: 100 }),
+ Row({ opt: border.width, title: "Border Width" }),
+ Row({ opt: blur, title: "Blur", note: "0 to disable", max: 70 }),
+ ),
+ Group("UI",
+ Row({ opt: padding, title: "Padding" }),
+ Row({ opt: spacing, title: "Spacing" }),
+ Row({ opt: radius, title: "Roundness" }),
+ Row({ opt: font.size, title: "Font Size" }),
+ Row({ opt: font.name, title: "Font Name", type: "font" }),
+ ),
+ ),
+ Page("Bar", icons.ui.toolbars,
+ Group("General",
+ Row({ opt: b.flatButtons, title: "Flat Buttons" }),
+ Row({ opt: b.position, title: "Position", type: "enum", enums: ["top", "bottom"] }),
+ Row({ opt: b.corners, title: "Corners" }),
+ ),
+ Group("Launcher",
+ Row({ opt: b.launcher.icon.icon, title: "Icon" }),
+ Row({ opt: b.launcher.icon.colored, title: "Colored Icon" }),
+ Row({ opt: b.launcher.label.label, title: "Label" }),
+ Row({ opt: b.launcher.label.colored, title: "Colored Label" }),
+ ),
+ Group("Workspaces",
+ Row({ opt: b.workspaces.workspaces, title: "Number of Workspaces", note: "0 to make it dynamic" }),
+ ),
+ Group("Taskbar",
+ Row({ opt: b.taskbar.iconSize, title: "Icon Size" }),
+ Row({ opt: b.taskbar.monochrome, title: "Monochrome" }),
+ Row({ opt: b.taskbar.exclusive, title: "Exclusive to workspaces" }),
+ ),
+ Group("Date",
+ Row({ opt: b.date.format, title: "Date Format" }),
+ ),
+ Group("Media",
+ Row({ opt: b.media.monochrome, title: "Monochrome" }),
+ Row({ opt: b.media.preferred, title: "Preferred Player" }),
+ Row({ opt: b.media.direction, title: "Slide Direction", type: "enum", enums: ["left", "right"] }),
+ Row({ opt: b.media.format, title: "Format of the Label" }),
+ Row({ opt: b.media.length, title: "Max Length of Label" }),
+ ),
+ Group("Battery",
+ Row({ opt: b.battery.bar, title: "Style", type: "enum", enums: ["hidden", "regular", "whole"] }),
+ Row({ opt: b.battery.blocks, title: "Number of Blocks" }),
+ Row({ opt: b.battery.width, title: "Width of Bar" }),
+ Row({ opt: b.battery.charging, title: "Charging Color", type: "color" }),
+ ),
+ Group("Powermenu",
+ Row({ opt: b.powermenu.monochrome, title: "Monochrome" }),
+ ),
+ ),
+ Page("General", icons.ui.settings,
+ Group("Hyprland",
+ Row({ opt: h.gapsWhenOnly, title: "Gaps When Only" }),
+ ),
+ Group("Launcher",
+ Row({ opt: l.width, title: "Width" }),
+ Row({ opt: l.apps.iconSize, title: "Icon Size" }),
+ Row({ opt: l.apps.max, title: "Max Items" }),
+ ),
+ Group("Overview",
+ Row({ opt: ov.scale, title: "Scale", max: 100 }),
+ Row({ opt: ov.workspaces, title: "Workspaces", max: 11, note: "set this to 0 to make it dynamic" }),
+ Row({ opt: ov.monochromeIcon, title: "Monochrome Icons" }),
+ ),
+ Group("Powermenu",
+ Row({ opt: pm.layout, title: "Layout", type: "enum", enums: ["box", "line"] }),
+ Row({ opt: pm.labels, title: "Show Labels" }),
+ ),
+ Group("Quicksettings",
+ Row({ opt: qs.avatar.image, title: "Avatar", type: "img" }),
+ Row({ opt: qs.avatar.size, title: "Avatar Size" }),
+ Row({ opt: qs.media.monochromeIcon, title: "Media Monochrome Icons" }),
+ Row({ opt: qs.media.coverSize, title: "Media Cover Art Size" }),
+ ),
+ Group("On Screen Indicator",
+ Row({ opt: osd.progress.vertical, title: "Vertical" }),
+ Row({ opt: osd.progress.pack.h, title: "Horizontal Alignment", type: "enum", enums: ["start", "center", "end"] }),
+ Row({ opt: osd.progress.pack.v, title: "Vertical Alignment", type: "enum", enums: ["start", "center", "end"] }),
+ ),
+ ),
+] as const
diff --git a/.config/ags/widget/settings/settingsdialog.scss b/.config/ags/widget/settings/settingsdialog.scss
new file mode 100644
index 0000000..b8c9820
--- /dev/null
+++ b/.config/ags/widget/settings/settingsdialog.scss
@@ -0,0 +1,144 @@
+window.settings-dialog {
+ background-color: $bg;
+ color: $fg;
+
+ .header {
+ .pager {
+ @include spacing(.5);
+ }
+
+ padding: $padding;
+
+ button {
+ @include button;
+ font-weight: bold;
+ padding: $padding*.5 $padding;
+
+ box {
+ @include spacing($spacing: .3em);
+ }
+ }
+
+ button.close {
+ padding: $padding * .5;
+ }
+
+ button.reset {
+ @include button($flat: true);
+ padding: $padding*.5;
+ }
+ }
+
+ .page {
+ @include scrollable($top: true);
+
+ .page-content {
+ padding: $padding*2;
+ padding-top: 0;
+ }
+ }
+
+ .group {
+ .group-title {
+ color: $primary-bg;
+ margin-bottom: $spacing*.5;
+ }
+
+ .group-reset {
+ @include button($flat: true);
+ margin: $spacing * .5;
+ padding: $padding * .5;
+
+ &:disabled {
+ color: transparent;
+ }
+ }
+
+ &:not(:first-child) {
+ margin-top: $spacing;
+ }
+ }
+
+ .row {
+ background-color: $widget-bg;
+ padding: $padding;
+ border: $border;
+ border-top: none;
+
+ &:first-child {
+ border-radius: $radius $radius 0 0;
+ border: $border;
+ }
+
+ &:last-child {
+ border-radius: 0 0 $radius $radius;
+ }
+
+ &:first-child:last-child {
+ border-radius: $radius;
+ border: $border;
+ }
+
+ button.reset {
+ margin-left: $spacing;
+ }
+
+ label.id,
+ label.note {
+ color: transparentize($fg, .4)
+ }
+
+ entry,
+ button {
+ @include button;
+ padding: $padding;
+ }
+
+ switch {
+ @include switch;
+ }
+
+ spinbutton {
+ @include unset;
+
+ entry {
+ border-radius: $radius 0 0 $radius;
+ }
+
+ button {
+ border-radius: 0;
+ }
+
+ button:last-child {
+ border-radius: 0 $radius $radius 0;
+ }
+ }
+
+ .enum-setter {
+ label {
+ background-color: $widget-bg;
+ border: $border;
+ padding: 0 $padding;
+ border-radius: $radius 0 0 $radius;
+ }
+
+ button {
+ border-radius: 0;
+ }
+
+ button:last-child {
+ border-radius: 0 $radius $radius 0;
+ }
+ }
+
+ &.wallpaper {
+ button {
+ margin-top: $spacing * .5;
+ }
+
+ .preview {
+ border-radius: $radius;
+ }
+ }
+ }
+}