From a4daa86fca4e78ef9bc63ee70b42c311ae64f395 Mon Sep 17 00:00:00 2001 From: joygnu Date: Wed, 21 Aug 2024 21:30:11 +0200 Subject: [PATCH] stealling bar --- home/ags/README.md | 15 ++ home/ags/applauncher.ts | 107 +++++++++++ home/ags/bar/README.md | 15 ++ home/ags/bar/applauncher.ts | 107 +++++++++++ home/ags/bar/config.js | 230 ++-------------------- home/ags/bar/main.ts | 348 ++++++++++++++++++++++++++++++++++ home/ags/bar/mic.ts | 61 ++++++ home/ags/bar/notifications.ts | 130 +++++++++++++ home/ags/bar/style.scss | 183 ++++++++++++++++++ home/ags/bar/tsconfig.json | 21 ++ home/ags/config.js | 15 ++ home/ags/main.ts | 348 ++++++++++++++++++++++++++++++++++ home/ags/mic.ts | 61 ++++++ home/ags/notifications.ts | 130 +++++++++++++ home/ags/style.scss | 183 ++++++++++++++++++ home/ags/tsconfig.json | 21 ++ 16 files changed, 1758 insertions(+), 217 deletions(-) create mode 100644 home/ags/README.md create mode 100644 home/ags/applauncher.ts create mode 100644 home/ags/bar/README.md create mode 100644 home/ags/bar/applauncher.ts create mode 100644 home/ags/bar/main.ts create mode 100644 home/ags/bar/mic.ts create mode 100644 home/ags/bar/notifications.ts create mode 100644 home/ags/bar/style.scss create mode 100644 home/ags/bar/tsconfig.json create mode 100644 home/ags/config.js create mode 100644 home/ags/main.ts create mode 100644 home/ags/mic.ts create mode 100644 home/ags/notifications.ts create mode 100644 home/ags/style.scss create mode 100644 home/ags/tsconfig.json diff --git a/home/ags/README.md b/home/ags/README.md new file mode 100644 index 0000000..e311392 --- /dev/null +++ b/home/ags/README.md @@ -0,0 +1,15 @@ + +# Starter Config + +if suggestions don't work, first make sure +you have TypeScript LSP working in your editor + +if you do not want typechecking only suggestions + +```json +// tsconfig.json +"checkJs": false +``` + +types are symlinked to: +/nix/store/4rpg1hbvvfb8wpxf1a6ljbm390wfcwcd-ags-1.8.2/share/com.github.Aylur.ags/types diff --git a/home/ags/applauncher.ts b/home/ags/applauncher.ts new file mode 100644 index 0000000..23dfa40 --- /dev/null +++ b/home/ags/applauncher.ts @@ -0,0 +1,107 @@ +const { query } = await Service.import("applications") +const WINDOW_NAME = "applauncher" + +/** @param {import('resource:///com/github/Aylur/ags/service/applications.js').Application} app */ +const AppItem = app => Widget.Button({ + on_clicked: () => { + App.closeWindow(WINDOW_NAME) + app.launch() + }, + attribute: { app }, + child: Widget.Box({ + children: [ + Widget.Icon({ + icon: app.icon_name || "", + size: 42, + }), + Widget.Label({ + class_name: "title", + label: app.name, + xalign: 0, + vpack: "center", + truncate: "end", + }), + ], + }), +}) + +const Applauncher = ({ width = 500, height = 500, spacing = 12 }) => { + // list of application buttons + let applications = query("").map(AppItem) + + // container holding the buttons + const list = Widget.Box({ + vertical: true, + children: applications, + spacing, + }) + + // repopulate the box, so the most frequent apps are on top of the list + function repopulate() { + applications = query("").map(AppItem) + list.children = applications + } + + // search entry + const entry = Widget.Entry({ + hexpand: true, + css: `margin-bottom: ${spacing}px;`, + + // to launch the first item on Enter + on_accept: () => { + // make sure we only consider visible (searched for) applications + const results = applications.filter((item) => item.visible); + if (results[0]) { + App.toggleWindow(WINDOW_NAME) + results[0].attribute.app.launch() + } + }, + + // filter out the list + on_change: ({ text }) => applications.forEach(item => { + item.visible = item.attribute.app.match(text ?? "") + }), + }) + + return Widget.Box({ + vertical: true, + css: `margin: ${spacing * 2}px;`, + children: [ + entry, + + // wrap the list in a scrollable + Widget.Scrollable({ + hscroll: "never", + css: `min-width: ${width}px;` + + `min-height: ${height}px;`, + child: list, + }), + ], + setup: self => self.hook(App, (_, windowName, visible) => { + if (windowName !== WINDOW_NAME) + return + + // when the applauncher shows up + if (visible) { + repopulate() + entry.text = "" + entry.grab_focus() + } + }), + }) +} + +// there needs to be only one instance +export const applauncher = Widget.Window({ + name: WINDOW_NAME, + setup: self => self.keybind("Escape", () => { + App.closeWindow(WINDOW_NAME) + }), + visible: false, + keymode: "exclusive", + child: Applauncher({ + width: 500, + height: 500, + spacing: 12, + }), +}) diff --git a/home/ags/bar/README.md b/home/ags/bar/README.md new file mode 100644 index 0000000..e311392 --- /dev/null +++ b/home/ags/bar/README.md @@ -0,0 +1,15 @@ + +# Starter Config + +if suggestions don't work, first make sure +you have TypeScript LSP working in your editor + +if you do not want typechecking only suggestions + +```json +// tsconfig.json +"checkJs": false +``` + +types are symlinked to: +/nix/store/4rpg1hbvvfb8wpxf1a6ljbm390wfcwcd-ags-1.8.2/share/com.github.Aylur.ags/types diff --git a/home/ags/bar/applauncher.ts b/home/ags/bar/applauncher.ts new file mode 100644 index 0000000..23dfa40 --- /dev/null +++ b/home/ags/bar/applauncher.ts @@ -0,0 +1,107 @@ +const { query } = await Service.import("applications") +const WINDOW_NAME = "applauncher" + +/** @param {import('resource:///com/github/Aylur/ags/service/applications.js').Application} app */ +const AppItem = app => Widget.Button({ + on_clicked: () => { + App.closeWindow(WINDOW_NAME) + app.launch() + }, + attribute: { app }, + child: Widget.Box({ + children: [ + Widget.Icon({ + icon: app.icon_name || "", + size: 42, + }), + Widget.Label({ + class_name: "title", + label: app.name, + xalign: 0, + vpack: "center", + truncate: "end", + }), + ], + }), +}) + +const Applauncher = ({ width = 500, height = 500, spacing = 12 }) => { + // list of application buttons + let applications = query("").map(AppItem) + + // container holding the buttons + const list = Widget.Box({ + vertical: true, + children: applications, + spacing, + }) + + // repopulate the box, so the most frequent apps are on top of the list + function repopulate() { + applications = query("").map(AppItem) + list.children = applications + } + + // search entry + const entry = Widget.Entry({ + hexpand: true, + css: `margin-bottom: ${spacing}px;`, + + // to launch the first item on Enter + on_accept: () => { + // make sure we only consider visible (searched for) applications + const results = applications.filter((item) => item.visible); + if (results[0]) { + App.toggleWindow(WINDOW_NAME) + results[0].attribute.app.launch() + } + }, + + // filter out the list + on_change: ({ text }) => applications.forEach(item => { + item.visible = item.attribute.app.match(text ?? "") + }), + }) + + return Widget.Box({ + vertical: true, + css: `margin: ${spacing * 2}px;`, + children: [ + entry, + + // wrap the list in a scrollable + Widget.Scrollable({ + hscroll: "never", + css: `min-width: ${width}px;` + + `min-height: ${height}px;`, + child: list, + }), + ], + setup: self => self.hook(App, (_, windowName, visible) => { + if (windowName !== WINDOW_NAME) + return + + // when the applauncher shows up + if (visible) { + repopulate() + entry.text = "" + entry.grab_focus() + } + }), + }) +} + +// there needs to be only one instance +export const applauncher = Widget.Window({ + name: WINDOW_NAME, + setup: self => self.keybind("Escape", () => { + App.closeWindow(WINDOW_NAME) + }), + visible: false, + keymode: "exclusive", + child: Applauncher({ + width: 500, + height: 500, + spacing: 12, + }), +}) diff --git a/home/ags/bar/config.js b/home/ags/bar/config.js index 4c0a30b..d8d6444 100644 --- a/home/ags/bar/config.js +++ b/home/ags/bar/config.js @@ -1,219 +1,15 @@ -const hyprland = await Service.import("hyprland") -const notifications = await Service.import("notifications") -const mpris = await Service.import("mpris") -const audio = await Service.import("audio") -const battery = await Service.import("battery") -const systemtray = await Service.import("systemtray") +const main = '/tmp/ags/main.js'; -const date = Variable("", { - poll: [1000, 'date "+%H:%M:%S %b %e."'], -}) - -// widgets can be only assigned as a child in one container -// so to make a reuseable widget, make it a function -// then you can simply instantiate one by calling it - -function Workspaces() { - const activeId = hyprland.active.workspace.bind("id") - const workspaces = hyprland.bind("workspaces") - .as(ws => ws.map(({ id }) => Widget.Button({ - on_clicked: () => hyprland.messageAsync(`dispatch workspace ${id}`), - child: Widget.Label(`${id}`), - class_name: activeId.as(i => `${i === id ? "focused" : ""}`), - }))) - - return Widget.Box({ - class_name: "workspaces", - children: workspaces, - }) +try { + await Utils.execAsync([ + 'bun', 'build', `${App.configDir}/main.ts`, + '--outfile', main, + '--external', 'resource://*', + '--external', 'gi://*', + '--external', 'file://*', + ]); + await import(`file://${main}`); +} catch (error) { + console.error(error); + App.quit(); } - - -function ClientTitle() { - return Widget.Label({ - class_name: "client-title", - label: hyprland.active.client.bind("title"), - }) -} - - -function Clock() { - return Widget.Label({ - class_name: "clock", - label: date.bind(), - }) -} - - -// we don't need dunst or any other notification daemon -// because the Notifications module is a notification daemon itself -function Notification() { - const popups = notifications.bind("popups") - return Widget.Box({ - class_name: "notification", - visible: popups.as(p => p.length > 0), - children: [ - Widget.Icon({ - icon: "preferences-system-notifications-symbolic", - }), - Widget.Label({ - label: popups.as(p => p[0]?.summary || ""), - }), - ], - }) -} - - -function Media() { - const label = Utils.watch("", mpris, "player-changed", () => { - if (mpris.players[0]) { - const { track_artists, track_title } = mpris.players[0] - return `${track_artists.join(", ")} - ${track_title}` - } else { - return "Nothing is playing" - } - }) - - return Widget.Button({ - class_name: "media", - on_primary_click: () => mpris.getPlayer("")?.playPause(), - on_scroll_up: () => mpris.getPlayer("")?.next(), - on_scroll_down: () => mpris.getPlayer("")?.previous(), - child: Widget.Label({ label }), - }) -} - - -function Volume() { - const icons = { - 101: "overamplified", - 67: "high", - 34: "medium", - 1: "low", - 0: "muted", - } - - function getIcon() { - const icon = audio.speaker.is_muted ? 0 : [101, 67, 34, 1, 0].find( - threshold => threshold <= audio.speaker.volume * 100) - - return `audio-volume-${icons[icon]}-symbolic` - } - - const icon = Widget.Icon({ - icon: Utils.watch(getIcon(), audio.speaker, getIcon), - }) - - const slider = Widget.Slider({ - hexpand: true, - draw_value: false, - on_change: ({ value }) => audio.speaker.volume = value, - setup: self => self.hook(audio.speaker, () => { - self.value = audio.speaker.volume || 0 - }), - }) - - return Widget.Box({ - class_name: "volume", - css: "min-width: 180px", - children: [icon, slider], - }) -} - - -function BatteryLabel() { - const value = battery.bind("percent").as(p => p > 0 ? p / 100 : 0) - const icon = battery.bind("percent").as(p => - `battery-level-${Math.floor(p / 10) * 10}-symbolic`) - - return Widget.Box({ - class_name: "battery", - visible: battery.bind("available"), - children: [ - Widget.Icon({ icon }), - Widget.LevelBar({ - widthRequest: 140, - vpack: "center", - value, - }), - ], - }) -} - - -function SysTray() { - const items = systemtray.bind("items") - .as(items => items.map(item => Widget.Button({ - child: Widget.Icon({ icon: item.bind("icon") }), - on_primary_click: (_, event) => item.activate(event), - on_secondary_click: (_, event) => item.openMenu(event), - tooltip_markup: item.bind("tooltip_markup"), - }))) - - return Widget.Box({ - children: items, - }) -} - - -// layout of the bar -function Left() { - return Widget.Box({ - spacing: 8, - children: [ - Workspaces(), - ClientTitle(), - ], - }) -} - -function Center() { - return Widget.Box({ - spacing: 8, - children: [ - Media(), - Notification(), - ], - }) -} - -function Right() { - return Widget.Box({ - hpack: "end", - spacing: 8, - children: [ - Volume(), - BatteryLabel(), - Clock(), - SysTray(), - ], - }) -} - -function Bar(monitor = 0) { - return Widget.Window({ - name: `bar-${monitor}`, // name has to be unique - class_name: "bar", - monitor, - anchor: ["top", "left", "right"], - exclusivity: "exclusive", - child: Widget.CenterBox({ - start_widget: Left(), - center_widget: Center(), - end_widget: Right(), - }), - }) -} - -App.config({ - style: "./style.css", - windows: [ - Bar(), - - // you can call it, for each monitor - // Bar(0), - // Bar(1) - ], -}) - -export { } diff --git a/home/ags/bar/main.ts b/home/ags/bar/main.ts new file mode 100644 index 0000000..7040972 --- /dev/null +++ b/home/ags/bar/main.ts @@ -0,0 +1,348 @@ +import micPopup from "mic" +import { applauncher } from "applauncher" +import { NotificationPopups } from "notifications" +// import Gtk from "gi://Gtk?version=3.0" + +const hyprland = await Service.import("hyprland") +const notifications = await Service.import("notifications") +const mpris = await Service.import("mpris") +const audio = await Service.import("audio") +const battery = await Service.import("battery") +const systemtray = await Service.import("systemtray") + +const date = Variable("", { + poll: [5000, `bash -c 'LANG=en_us_8859_1 date "+%H:%M %b %e."'`], +}) +const iconSize = 14; + +function Workspaces() { + const activeId = hyprland.active.workspace.bind("id"); + const workspaces = hyprland.bind("workspaces").as((ws) => + ws + .filter(({ id }) => id > 0) + .sort((a, b) => a.id - b.id) + .map(({ id }) => + Widget.Button({ + on_clicked: () => hyprland.messageAsync(`dispatch workspace ${id}`), + class_name: activeId.as((i) => `${i === id ? "focused" : ""}`), + }) + ) + ); + + return Widget.Box({ + class_name: "workspaces", + children: workspaces, + }); +} + + +function ClientTitle() { + return Widget.Label({ + class_name: "client-title", + label: hyprland.active.client.bind("title"), + }) +} + + +function Clock() { + return Widget.Label({ + class_name: "yellow container", + label: date.bind(), + }) +} + + +// we don't need dunst or any other notification daemon +// because the Notifications module is a notification daemon itself +function Notification() { + const popups = notifications.bind("popups") + return Widget.Box({ + class_name: "notification", + visible: popups.as(p => p.length > 0), + children: [ + Widget.Icon({ + icon: "preferences-system-notifications-symbolic", + size: iconSize, + + className: "icon" + }), + Widget.Label({ + label: popups.as(p => p[0]?.summary || ""), + }), + ], + }) +} + + +function Media() { + const label = Utils.watch("", mpris, "player-changed", () => { + if (mpris.players[0]) { + const { track_artists, track_title } = mpris.players[0] + return `${track_artists.join(", ")} - ${track_title}` + } else { + return "Nothing is playing" + } + }) + + return Widget.Button({ + class_name: "media", + on_primary_click: () => mpris.getPlayer("")?.playPause(), + on_scroll_up: () => mpris.getPlayer("")?.next(), + on_scroll_down: () => mpris.getPlayer("")?.previous(), + child: Widget.Label({ label }), + }) +} + + +function Volume() { + const icons = { + 101: "overamplified", + 67: "high", + 34: "medium", + 1: "low", + 0: "muted", + } + + function getIcon() { + const icon = audio.speaker.is_muted ? 0 : [101, 67, 34, 1, 0].find( + threshold => threshold <= audio.speaker.volume * 100) + + return `audio-volume-${icons[icon!]}-symbolic` + } + + const icon = Widget.Icon({ + icon: Utils.watch(getIcon(), audio.speaker, getIcon), + + size: iconSize, + className: "icon" + }) + + const slider = Widget.Slider({ + hexpand: true, + draw_value: false, + on_change: ({ value }) => audio.speaker.volume = value, + setup: self => self.hook(audio.speaker, () => { + self.value = audio.speaker.volume || 0 + }), + }) + + return Widget.Box({ + class_name: "yellow container", + css: "min-width: 120px", + children: [icon, slider], + }) +} + +function Microphone() { + + function getIcon() { + + if (audio.microphone.is_muted || audio.microphone.volume == 0) { + return "microphone-sensitivity-muted-symbolic" + } else { + return "microphone-sensitivity-high-symbolic"; + } + } + + function getTextStatus() { + if (audio.microphone.is_muted || audio.microphone.volume == 0) { + return "off" + } else { + return "on "; + } + } + + const icon = Widget.Icon({ + icon: Utils.watch(getIcon(), audio.microphone, getIcon), + + className: "icon", + size: iconSize, + }) + + const label = Widget.Label({ + label: Utils.watch(getTextStatus(), audio.microphone, getTextStatus), + }) + + return Widget.Box({ + class_name: "red container", + css: "min-width: 30px", + children: [icon, label], + }) + +} + +function Kblayout() { + + const languages = { + "English": "english", + "Ukrainian": "державна", + "Russian": "русский", + } + + var label = Widget.Label({ + label: "english", + }) + + const icon = Widget.Icon({ + icon: "transporter-symbolic", + + className: "icon", + size: iconSize, + }) + + label.hook(hyprland, (self: any, keyboardname: string, layoutname: string) => { + var maskedName = layoutname; + for (const [key, value] of Object.entries(languages)) { + if (layoutname.includes(key)) + maskedName = value; + } + label.label = maskedName; + }, "keyboard-layout"); + + + return Widget.Box({ + class_name: "orange container", + children: [icon, label], + }) +} + +function Battery() { + const value = battery.bind("percent").as(p => p > 0 ? p / 100 : 0) + // const icon = battery.bind("percent").as(p => + // `battery-${Math.floor(p / 10) * 10}`) + + const icon = battery.bind("percent").as(p => + `battery-full-charging-symbolic`) + + return Widget.Box({ + class_name: "battery container green", + visible: battery.bind("available"), + children: [ + Widget.Icon({ + icon: icon, + icon_size: iconSize, + + className: "icon" + }), + Widget.LevelBar({ + widthRequest: 140, + vpack: "center", + value, + }), + ], + }) +} + + +function SysTray() { + const items = systemtray.bind("items") + .as(items => items.map(item => Widget.Button({ + child: Widget.Icon({ + icon: item.bind("icon"), + size: iconSize, + + className: "icon" + }), + on_primary_click: (_, event) => item.activate(event), + on_secondary_click: (_, event) => item.openMenu(event), + tooltip_markup: item.bind("tooltip_markup"), + }))) + + return Widget.Box({ + children: items, + }) +} + +function NixLogo() { + return Widget.Label({ + label: "", + css: ` + padding-left: 7px; + padding-right: 7px; + color: @blue_1 + `, + }) +} + + +// layout of the bar +function Left() { + return Widget.Box({ + spacing: 8, + children: [ + NixLogo(), + Workspaces(), + // ClientTitle(), + ], + }) +} + +function Center() { + return Widget.Box({ + spacing: 8, + children: [ + Media(), + Notification(), + ], + }) +} + +function Right() { + return Widget.Box({ + hpack: "end", + spacing: 8, + children: [ + Volume(), + Microphone(), + Kblayout(), + Battery(), + // BatteryLabel(), + Clock(), + SysTray(), + ], + }) +} + +function Bar(monitor = 0) { + return Widget.Window({ + name: `bar-${monitor}`, // name has to be unique + class_name: "bar bottombarshadow", + monitor, + anchor: ["top", "left", "right"], + exclusivity: "exclusive", + child: Widget.CenterBox({ + start_widget: Left(), + center_widget: Center(), + end_widget: Right(), + }), + }) +} + +// function Exclusivity(monitor = 0) { +// return Widget.Window({ +// name: `exclusivity-${monitor}`, // name has to be unique +// class_name: "bar bottombarshadow", +// monitor, +// anchor: ["top", "left", "right"], +// exclusivity: "exclusive", +// css: "background-color:transparent;", +// height_request: 35, +// }) +// } + +var bars = hyprland.monitors.map((m, i) => Bar(i)) +// var exclusivity = hyprland.monitors.map((m, i) => Exclusivity(i)) +var micPopups = hyprland.monitors.map((m, i) => micPopup(i)) + +App.config({ + style: "./style.scss", + windows: [ + // ...exclusivity, + ...bars, + ...micPopups, + applauncher, + NotificationPopups() + ], +}) + +export { } diff --git a/home/ags/bar/mic.ts b/home/ags/bar/mic.ts new file mode 100644 index 0000000..cccf6d6 --- /dev/null +++ b/home/ags/bar/mic.ts @@ -0,0 +1,61 @@ +const audio = await Service.import("audio") + +import Gtk from "gi://Gtk?version=3.0" + +const DELAY = 4000 + +function MicrophoneMute() { + const icon = Widget.Icon({ + class_name: "microphone", + vexpand: true, + hexpand: true, + }) + + const box = Widget.Box({ + child: icon, + class_name: "microphone_box", + }) + + const outside_box = Widget.Box({ + child: box, + css: "margin-bottom:100px;" + }) + + const revealer = Widget.Revealer({ + transition: "slide_up", + child: outside_box, + }) + + 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 = mute ? "microphone-sensitivity-muted-symbolic" : "microphone-sensitivity-high-symbolic" + App.applyCss(mute ? `.microphone_box { color: @red_1; }` : `.microphone_box { color: @green_1; }`) + revealer.reveal_child = true + count++ + + Utils.timeout(DELAY, () => { + count-- + if (count === 0) + revealer.reveal_child = false + }) + } + })) +} + +export default (monitor) => Widget.Window({ + monitor, + name: `indicator${monitor}`, + class_name: "indicator", + layer: "overlay", + anchor: ["bottom"], + click_through: true, + child: Widget.Box({ + css: "padding: 2px;", + expand: true, + child: MicrophoneMute() + }), +}) diff --git a/home/ags/bar/notifications.ts b/home/ags/bar/notifications.ts new file mode 100644 index 0000000..f360d3c --- /dev/null +++ b/home/ags/bar/notifications.ts @@ -0,0 +1,130 @@ +const notifications = await Service.import("notifications") + +/** @param {import('resource:///com/github/Aylur/ags/service/notifications.js').Notification} n */ +function NotificationIcon({ app_entry, app_icon, image }) { + if (image) { + return Widget.Box({ + css: `background-image: url("${image}");` + + "background-size: contain;" + + "background-repeat: no-repeat;" + + "background-position: center;", + }) + } + + let icon = "dialog-information-symbolic" + if (Utils.lookUpIcon(app_icon)) + icon = app_icon + + if (app_entry && Utils.lookUpIcon(app_entry)) + icon = app_entry + + return Widget.Box({ + child: Widget.Icon(icon), + }) +} + +/** @param {import('resource:///com/github/Aylur/ags/service/notifications.js').Notification} n */ +function Notification(n) { + const icon = Widget.Box({ + vpack: "start", + class_name: "icon", + child: NotificationIcon(n), + }) + + const title = Widget.Label({ + class_name: "title", + xalign: 0, + justification: "left", + hexpand: true, + max_width_chars: 24, + truncate: "end", + wrap: true, + label: n.summary, + use_markup: true, + }) + + const body = Widget.Label({ + class_name: "body", + hexpand: true, + use_markup: true, + xalign: 0, + justification: "left", + label: n.body, + wrap: true, + }) + + const actions = Widget.Box({ + class_name: "actions", + children: n.actions.map(({ id, label }) => Widget.Button({ + class_name: "action-button", + on_clicked: () => { + n.invoke(id) + n.dismiss() + }, + hexpand: true, + child: Widget.Label(label), + })), + }) + + return Widget.EventBox( + { + attribute: { id: n.id }, + on_primary_click: n.dismiss, + }, + Widget.Box( + { + class_name: `notification ${n.urgency}`, + vertical: true, + }, + Widget.Box([ + icon, + Widget.Box( + { vertical: true }, + title, + body, + ), + ]), + actions, + ), + ) +} + +export function NotificationPopups(monitor = 0) { + const list = Widget.Box({ + vertical: true, + children: notifications.popups.map(Notification), + }) + + function onNotified(_, /** @type {number} */ id) { + const n = notifications.getNotification(id) + if (n) + list.children = [Notification(n), ...list.children] + } + + function onDismissed(_, /** @type {number} */ id) { + list.children.find(n => n.attribute.id === id)?.destroy() + } + + list.hook(notifications, onNotified, "notified") + .hook(notifications, onDismissed, "dismissed") + + return Widget.Window({ + monitor, + name: `notifications${monitor}`, + class_name: "notification-popups", + anchor: ["top", "right"], + child: Widget.Box({ + css: "min-width: 2px; min-height: 2px;", + class_name: "notifications", + vertical: true, + child: list, + + /** this is a simple one liner that could be used instead of + hooking into the 'notified' and 'dismissed' signals. + but its not very optimized becuase it will recreate + the whole list everytime a notification is added or dismissed */ + // children: notifications.bind('popups') + // .as(popups => popups.map(Notification)) + }), + }) +} diff --git a/home/ags/bar/style.scss b/home/ags/bar/style.scss new file mode 100644 index 0000000..34b028d --- /dev/null +++ b/home/ags/bar/style.scss @@ -0,0 +1,183 @@ +window.bar { + background-color: @theme_bg_color; + color: @theme_fg_color; + box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.84); +} + +* { + margin: 0px; + padding: 0px; +} + +.magenta { color: @purple_1; } +.magenta highlight { background-color: @purple_1; } + +.yellow { color: @yellow_1; } +.yellow highlight { background-color: @yellow_1; } + +.blue { color: @blue_1; } +.blue highlight {background-color: @blue_1; } + +.red { color: @red_1; } +.red highlight {background-color: @red_1; } + +.green { color: @green_1; } +.green highlight {background-color: @green_1; } + +.magenta { color: @purple_1;} +.magenta highlight {background-color: @purple_1; } + +.orange { color: @orange_1; } +.orange highlight {background-color: @orange_1; } + +button { + min-width: 0; + padding-top: 0; + padding-bottom: 0; + background-color: transparent; +} + +button:active { + background-color: @theme_selected_bg_color; +} + +button:hover { + border-bottom: 3px solid @theme_fg_color; +} + +label { + font-weight: bold; +} + +highlight { + margin: 0px; + padding:0px; +} + +trough { + padding:0px; +} + + +.container { + background-color: lighter(lighter(@theme_bg_color)); + border-radius: 13px; + padding: 0px 10px; + margin-top: 7px; + margin-bottom: 7px; +} + +.container .icon { + padding-right: 10px; +} + +slider { + background-color: transparent; + box-shadow: none; + margin: -100px; +} + +.workspaces { + background-color: lighter(lighter(@theme_bg_color)); + border-radius: 13px; + padding: 3px 10px; + margin: 7px 7px; +} + +.workspaces button { + background-color: @window_fg_color; + min-width: 6px; + min-height: 6px; + padding: 3px 3px; + margin:0px 5px; + border-radius:9999px; +} + +.workspaces button.focused { + background-color: @blue_1; +} + +.client-title { + color: @theme_selected_bg_color; +} + +.notification { + color: yellow; +} + +levelbar block, +highlight { + min-height: 10px; +} + +.microphone_box { + background-color: @theme_bg_color; + min-width: 65px; + min-height: 65px; + margin: 30px; + font-size: 25px; + border-radius: 10px; + box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.84); +} + + +/* Notifications + +window.notification-popups box.notifications { + padding: .5em; +} + +.icon { + min-width: 68px; + min-height: 68px; + margin-right: 1em; +} + +.icon image { + font-size: 58px; + margin: 5px; + color: @theme_fg_color; +} + +.icon box { + min-width: 68px; + min-height: 68px; + border-radius: 7px; +} + +.notification { + min-width: 350px; + border-radius: 11px; + padding: 1em; + margin: .5em; + border: 1px solid @wm_borders_edge; + background-color: @theme_bg_color; +} + +.notification.critical { + border: 1px solid lightcoral; +} + +.title { + color: @theme_fg_color; + font-size: 1.4em; +} + +.body { + color: @theme_unfocused_fg_color; +} + +.actions .action-button { + margin: 0 .4em; + margin-top: .8em; +} + +.actions .action-button:first-child { + margin-left: 0; +} + +.actions .action-button:last-child { + margin-right: 0; +} + +*/ diff --git a/home/ags/bar/tsconfig.json b/home/ags/bar/tsconfig.json new file mode 100644 index 0000000..bdd7690 --- /dev/null +++ b/home/ags/bar/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "lib": [ + "ES2022" + ], + "allowJs": true, + "checkJs": true, + "strict": true, + "noImplicitAny": false, + "baseUrl": ".", + "typeRoots": [ + "./types" + ], + "skipLibCheck": true + } + // "include": [ + // "*/*.ts" + // ] +} diff --git a/home/ags/config.js b/home/ags/config.js new file mode 100644 index 0000000..d8d6444 --- /dev/null +++ b/home/ags/config.js @@ -0,0 +1,15 @@ +const main = '/tmp/ags/main.js'; + +try { + await Utils.execAsync([ + 'bun', 'build', `${App.configDir}/main.ts`, + '--outfile', main, + '--external', 'resource://*', + '--external', 'gi://*', + '--external', 'file://*', + ]); + await import(`file://${main}`); +} catch (error) { + console.error(error); + App.quit(); +} diff --git a/home/ags/main.ts b/home/ags/main.ts new file mode 100644 index 0000000..7040972 --- /dev/null +++ b/home/ags/main.ts @@ -0,0 +1,348 @@ +import micPopup from "mic" +import { applauncher } from "applauncher" +import { NotificationPopups } from "notifications" +// import Gtk from "gi://Gtk?version=3.0" + +const hyprland = await Service.import("hyprland") +const notifications = await Service.import("notifications") +const mpris = await Service.import("mpris") +const audio = await Service.import("audio") +const battery = await Service.import("battery") +const systemtray = await Service.import("systemtray") + +const date = Variable("", { + poll: [5000, `bash -c 'LANG=en_us_8859_1 date "+%H:%M %b %e."'`], +}) +const iconSize = 14; + +function Workspaces() { + const activeId = hyprland.active.workspace.bind("id"); + const workspaces = hyprland.bind("workspaces").as((ws) => + ws + .filter(({ id }) => id > 0) + .sort((a, b) => a.id - b.id) + .map(({ id }) => + Widget.Button({ + on_clicked: () => hyprland.messageAsync(`dispatch workspace ${id}`), + class_name: activeId.as((i) => `${i === id ? "focused" : ""}`), + }) + ) + ); + + return Widget.Box({ + class_name: "workspaces", + children: workspaces, + }); +} + + +function ClientTitle() { + return Widget.Label({ + class_name: "client-title", + label: hyprland.active.client.bind("title"), + }) +} + + +function Clock() { + return Widget.Label({ + class_name: "yellow container", + label: date.bind(), + }) +} + + +// we don't need dunst or any other notification daemon +// because the Notifications module is a notification daemon itself +function Notification() { + const popups = notifications.bind("popups") + return Widget.Box({ + class_name: "notification", + visible: popups.as(p => p.length > 0), + children: [ + Widget.Icon({ + icon: "preferences-system-notifications-symbolic", + size: iconSize, + + className: "icon" + }), + Widget.Label({ + label: popups.as(p => p[0]?.summary || ""), + }), + ], + }) +} + + +function Media() { + const label = Utils.watch("", mpris, "player-changed", () => { + if (mpris.players[0]) { + const { track_artists, track_title } = mpris.players[0] + return `${track_artists.join(", ")} - ${track_title}` + } else { + return "Nothing is playing" + } + }) + + return Widget.Button({ + class_name: "media", + on_primary_click: () => mpris.getPlayer("")?.playPause(), + on_scroll_up: () => mpris.getPlayer("")?.next(), + on_scroll_down: () => mpris.getPlayer("")?.previous(), + child: Widget.Label({ label }), + }) +} + + +function Volume() { + const icons = { + 101: "overamplified", + 67: "high", + 34: "medium", + 1: "low", + 0: "muted", + } + + function getIcon() { + const icon = audio.speaker.is_muted ? 0 : [101, 67, 34, 1, 0].find( + threshold => threshold <= audio.speaker.volume * 100) + + return `audio-volume-${icons[icon!]}-symbolic` + } + + const icon = Widget.Icon({ + icon: Utils.watch(getIcon(), audio.speaker, getIcon), + + size: iconSize, + className: "icon" + }) + + const slider = Widget.Slider({ + hexpand: true, + draw_value: false, + on_change: ({ value }) => audio.speaker.volume = value, + setup: self => self.hook(audio.speaker, () => { + self.value = audio.speaker.volume || 0 + }), + }) + + return Widget.Box({ + class_name: "yellow container", + css: "min-width: 120px", + children: [icon, slider], + }) +} + +function Microphone() { + + function getIcon() { + + if (audio.microphone.is_muted || audio.microphone.volume == 0) { + return "microphone-sensitivity-muted-symbolic" + } else { + return "microphone-sensitivity-high-symbolic"; + } + } + + function getTextStatus() { + if (audio.microphone.is_muted || audio.microphone.volume == 0) { + return "off" + } else { + return "on "; + } + } + + const icon = Widget.Icon({ + icon: Utils.watch(getIcon(), audio.microphone, getIcon), + + className: "icon", + size: iconSize, + }) + + const label = Widget.Label({ + label: Utils.watch(getTextStatus(), audio.microphone, getTextStatus), + }) + + return Widget.Box({ + class_name: "red container", + css: "min-width: 30px", + children: [icon, label], + }) + +} + +function Kblayout() { + + const languages = { + "English": "english", + "Ukrainian": "державна", + "Russian": "русский", + } + + var label = Widget.Label({ + label: "english", + }) + + const icon = Widget.Icon({ + icon: "transporter-symbolic", + + className: "icon", + size: iconSize, + }) + + label.hook(hyprland, (self: any, keyboardname: string, layoutname: string) => { + var maskedName = layoutname; + for (const [key, value] of Object.entries(languages)) { + if (layoutname.includes(key)) + maskedName = value; + } + label.label = maskedName; + }, "keyboard-layout"); + + + return Widget.Box({ + class_name: "orange container", + children: [icon, label], + }) +} + +function Battery() { + const value = battery.bind("percent").as(p => p > 0 ? p / 100 : 0) + // const icon = battery.bind("percent").as(p => + // `battery-${Math.floor(p / 10) * 10}`) + + const icon = battery.bind("percent").as(p => + `battery-full-charging-symbolic`) + + return Widget.Box({ + class_name: "battery container green", + visible: battery.bind("available"), + children: [ + Widget.Icon({ + icon: icon, + icon_size: iconSize, + + className: "icon" + }), + Widget.LevelBar({ + widthRequest: 140, + vpack: "center", + value, + }), + ], + }) +} + + +function SysTray() { + const items = systemtray.bind("items") + .as(items => items.map(item => Widget.Button({ + child: Widget.Icon({ + icon: item.bind("icon"), + size: iconSize, + + className: "icon" + }), + on_primary_click: (_, event) => item.activate(event), + on_secondary_click: (_, event) => item.openMenu(event), + tooltip_markup: item.bind("tooltip_markup"), + }))) + + return Widget.Box({ + children: items, + }) +} + +function NixLogo() { + return Widget.Label({ + label: "", + css: ` + padding-left: 7px; + padding-right: 7px; + color: @blue_1 + `, + }) +} + + +// layout of the bar +function Left() { + return Widget.Box({ + spacing: 8, + children: [ + NixLogo(), + Workspaces(), + // ClientTitle(), + ], + }) +} + +function Center() { + return Widget.Box({ + spacing: 8, + children: [ + Media(), + Notification(), + ], + }) +} + +function Right() { + return Widget.Box({ + hpack: "end", + spacing: 8, + children: [ + Volume(), + Microphone(), + Kblayout(), + Battery(), + // BatteryLabel(), + Clock(), + SysTray(), + ], + }) +} + +function Bar(monitor = 0) { + return Widget.Window({ + name: `bar-${monitor}`, // name has to be unique + class_name: "bar bottombarshadow", + monitor, + anchor: ["top", "left", "right"], + exclusivity: "exclusive", + child: Widget.CenterBox({ + start_widget: Left(), + center_widget: Center(), + end_widget: Right(), + }), + }) +} + +// function Exclusivity(monitor = 0) { +// return Widget.Window({ +// name: `exclusivity-${monitor}`, // name has to be unique +// class_name: "bar bottombarshadow", +// monitor, +// anchor: ["top", "left", "right"], +// exclusivity: "exclusive", +// css: "background-color:transparent;", +// height_request: 35, +// }) +// } + +var bars = hyprland.monitors.map((m, i) => Bar(i)) +// var exclusivity = hyprland.monitors.map((m, i) => Exclusivity(i)) +var micPopups = hyprland.monitors.map((m, i) => micPopup(i)) + +App.config({ + style: "./style.scss", + windows: [ + // ...exclusivity, + ...bars, + ...micPopups, + applauncher, + NotificationPopups() + ], +}) + +export { } diff --git a/home/ags/mic.ts b/home/ags/mic.ts new file mode 100644 index 0000000..cccf6d6 --- /dev/null +++ b/home/ags/mic.ts @@ -0,0 +1,61 @@ +const audio = await Service.import("audio") + +import Gtk from "gi://Gtk?version=3.0" + +const DELAY = 4000 + +function MicrophoneMute() { + const icon = Widget.Icon({ + class_name: "microphone", + vexpand: true, + hexpand: true, + }) + + const box = Widget.Box({ + child: icon, + class_name: "microphone_box", + }) + + const outside_box = Widget.Box({ + child: box, + css: "margin-bottom:100px;" + }) + + const revealer = Widget.Revealer({ + transition: "slide_up", + child: outside_box, + }) + + 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 = mute ? "microphone-sensitivity-muted-symbolic" : "microphone-sensitivity-high-symbolic" + App.applyCss(mute ? `.microphone_box { color: @red_1; }` : `.microphone_box { color: @green_1; }`) + revealer.reveal_child = true + count++ + + Utils.timeout(DELAY, () => { + count-- + if (count === 0) + revealer.reveal_child = false + }) + } + })) +} + +export default (monitor) => Widget.Window({ + monitor, + name: `indicator${monitor}`, + class_name: "indicator", + layer: "overlay", + anchor: ["bottom"], + click_through: true, + child: Widget.Box({ + css: "padding: 2px;", + expand: true, + child: MicrophoneMute() + }), +}) diff --git a/home/ags/notifications.ts b/home/ags/notifications.ts new file mode 100644 index 0000000..f360d3c --- /dev/null +++ b/home/ags/notifications.ts @@ -0,0 +1,130 @@ +const notifications = await Service.import("notifications") + +/** @param {import('resource:///com/github/Aylur/ags/service/notifications.js').Notification} n */ +function NotificationIcon({ app_entry, app_icon, image }) { + if (image) { + return Widget.Box({ + css: `background-image: url("${image}");` + + "background-size: contain;" + + "background-repeat: no-repeat;" + + "background-position: center;", + }) + } + + let icon = "dialog-information-symbolic" + if (Utils.lookUpIcon(app_icon)) + icon = app_icon + + if (app_entry && Utils.lookUpIcon(app_entry)) + icon = app_entry + + return Widget.Box({ + child: Widget.Icon(icon), + }) +} + +/** @param {import('resource:///com/github/Aylur/ags/service/notifications.js').Notification} n */ +function Notification(n) { + const icon = Widget.Box({ + vpack: "start", + class_name: "icon", + child: NotificationIcon(n), + }) + + const title = Widget.Label({ + class_name: "title", + xalign: 0, + justification: "left", + hexpand: true, + max_width_chars: 24, + truncate: "end", + wrap: true, + label: n.summary, + use_markup: true, + }) + + const body = Widget.Label({ + class_name: "body", + hexpand: true, + use_markup: true, + xalign: 0, + justification: "left", + label: n.body, + wrap: true, + }) + + const actions = Widget.Box({ + class_name: "actions", + children: n.actions.map(({ id, label }) => Widget.Button({ + class_name: "action-button", + on_clicked: () => { + n.invoke(id) + n.dismiss() + }, + hexpand: true, + child: Widget.Label(label), + })), + }) + + return Widget.EventBox( + { + attribute: { id: n.id }, + on_primary_click: n.dismiss, + }, + Widget.Box( + { + class_name: `notification ${n.urgency}`, + vertical: true, + }, + Widget.Box([ + icon, + Widget.Box( + { vertical: true }, + title, + body, + ), + ]), + actions, + ), + ) +} + +export function NotificationPopups(monitor = 0) { + const list = Widget.Box({ + vertical: true, + children: notifications.popups.map(Notification), + }) + + function onNotified(_, /** @type {number} */ id) { + const n = notifications.getNotification(id) + if (n) + list.children = [Notification(n), ...list.children] + } + + function onDismissed(_, /** @type {number} */ id) { + list.children.find(n => n.attribute.id === id)?.destroy() + } + + list.hook(notifications, onNotified, "notified") + .hook(notifications, onDismissed, "dismissed") + + return Widget.Window({ + monitor, + name: `notifications${monitor}`, + class_name: "notification-popups", + anchor: ["top", "right"], + child: Widget.Box({ + css: "min-width: 2px; min-height: 2px;", + class_name: "notifications", + vertical: true, + child: list, + + /** this is a simple one liner that could be used instead of + hooking into the 'notified' and 'dismissed' signals. + but its not very optimized becuase it will recreate + the whole list everytime a notification is added or dismissed */ + // children: notifications.bind('popups') + // .as(popups => popups.map(Notification)) + }), + }) +} diff --git a/home/ags/style.scss b/home/ags/style.scss new file mode 100644 index 0000000..34b028d --- /dev/null +++ b/home/ags/style.scss @@ -0,0 +1,183 @@ +window.bar { + background-color: @theme_bg_color; + color: @theme_fg_color; + box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.84); +} + +* { + margin: 0px; + padding: 0px; +} + +.magenta { color: @purple_1; } +.magenta highlight { background-color: @purple_1; } + +.yellow { color: @yellow_1; } +.yellow highlight { background-color: @yellow_1; } + +.blue { color: @blue_1; } +.blue highlight {background-color: @blue_1; } + +.red { color: @red_1; } +.red highlight {background-color: @red_1; } + +.green { color: @green_1; } +.green highlight {background-color: @green_1; } + +.magenta { color: @purple_1;} +.magenta highlight {background-color: @purple_1; } + +.orange { color: @orange_1; } +.orange highlight {background-color: @orange_1; } + +button { + min-width: 0; + padding-top: 0; + padding-bottom: 0; + background-color: transparent; +} + +button:active { + background-color: @theme_selected_bg_color; +} + +button:hover { + border-bottom: 3px solid @theme_fg_color; +} + +label { + font-weight: bold; +} + +highlight { + margin: 0px; + padding:0px; +} + +trough { + padding:0px; +} + + +.container { + background-color: lighter(lighter(@theme_bg_color)); + border-radius: 13px; + padding: 0px 10px; + margin-top: 7px; + margin-bottom: 7px; +} + +.container .icon { + padding-right: 10px; +} + +slider { + background-color: transparent; + box-shadow: none; + margin: -100px; +} + +.workspaces { + background-color: lighter(lighter(@theme_bg_color)); + border-radius: 13px; + padding: 3px 10px; + margin: 7px 7px; +} + +.workspaces button { + background-color: @window_fg_color; + min-width: 6px; + min-height: 6px; + padding: 3px 3px; + margin:0px 5px; + border-radius:9999px; +} + +.workspaces button.focused { + background-color: @blue_1; +} + +.client-title { + color: @theme_selected_bg_color; +} + +.notification { + color: yellow; +} + +levelbar block, +highlight { + min-height: 10px; +} + +.microphone_box { + background-color: @theme_bg_color; + min-width: 65px; + min-height: 65px; + margin: 30px; + font-size: 25px; + border-radius: 10px; + box-shadow: 0px 0px 10px 0px rgba(0,0,0,0.84); +} + + +/* Notifications + +window.notification-popups box.notifications { + padding: .5em; +} + +.icon { + min-width: 68px; + min-height: 68px; + margin-right: 1em; +} + +.icon image { + font-size: 58px; + margin: 5px; + color: @theme_fg_color; +} + +.icon box { + min-width: 68px; + min-height: 68px; + border-radius: 7px; +} + +.notification { + min-width: 350px; + border-radius: 11px; + padding: 1em; + margin: .5em; + border: 1px solid @wm_borders_edge; + background-color: @theme_bg_color; +} + +.notification.critical { + border: 1px solid lightcoral; +} + +.title { + color: @theme_fg_color; + font-size: 1.4em; +} + +.body { + color: @theme_unfocused_fg_color; +} + +.actions .action-button { + margin: 0 .4em; + margin-top: .8em; +} + +.actions .action-button:first-child { + margin-left: 0; +} + +.actions .action-button:last-child { + margin-right: 0; +} + +*/ diff --git a/home/ags/tsconfig.json b/home/ags/tsconfig.json new file mode 100644 index 0000000..bdd7690 --- /dev/null +++ b/home/ags/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "lib": [ + "ES2022" + ], + "allowJs": true, + "checkJs": true, + "strict": true, + "noImplicitAny": false, + "baseUrl": ".", + "typeRoots": [ + "./types" + ], + "skipLibCheck": true + } + // "include": [ + // "*/*.ts" + // ] +}