diff --git a/ags/bar/widgets/time.ts b/ags/bar/widgets/time.ts index 3424d87..ef16647 100644 --- a/ags/bar/widgets/time.ts +++ b/ags/bar/widgets/time.ts @@ -11,5 +11,5 @@ export const Time = () => className: "time", label: time.bind(), }), - onClicked: () => Utils.execAsync("swaync-client -t"), + on_clicked: () => App.toggleWindow("timemenu"), }); diff --git a/ags/config.js b/ags/config.js index 9c091d8..26a4fd5 100644 --- a/ags/config.js +++ b/ags/config.js @@ -35,6 +35,24 @@ async function compileMain(dest) { await compileStyles(dest); await compileMain(dest); + // Set up hot reloading of styles. + Utils.monitorFile(`${App.configDir}/style.scss`, (_, event) => { + if (event !== 0) { + // Not a changed event. + return; + } + + print("Hot reloading styles."); + Promise.resolve(compileStyles(dest)) + .then((_) => { + App.resetCss(); + App.applyCss(`${dest}/style.css`); + }) + .catch((reason) => { + print(`Hot reloading error: ${reason}`); + }); + }); + (await import(`file://${dest}/main.js`)).main(dest); } catch (err) { console.error(err); diff --git a/ags/lib/develop.ts b/ags/lib/develop.ts new file mode 100644 index 0000000..cb2c53b --- /dev/null +++ b/ags/lib/develop.ts @@ -0,0 +1,18 @@ +export function SetupCssHotReload(dest: string) { + Utils.monitorFile(`${App.configDir}/style.scss`, (_, event) => { + if (event !== 0) { + // Not a changed event. + return; + } + + print("Hot reloading styles."); + Promise.resolve(compileStyles(dest)) + .then((_) => { + App.resetCss(); + App.applyCss(`${dest}/style.css`); + }) + .catch((reason) => { + print(`Hot reloading error: ${reason}`); + }); + }); +} diff --git a/ags/lib/format.ts b/ags/lib/format.ts new file mode 100644 index 0000000..1ebee72 --- /dev/null +++ b/ags/lib/format.ts @@ -0,0 +1,9 @@ +import GLib from "gi://GLib"; + +export function formatTime(value: number, format = "%H:%M"): string { + const result = GLib.DateTime.new_from_unix_local(value).format(format); + if (result === null) { + throw new Error(`Failed to format time using \`${format}\` format.`); + } + return result; +} diff --git a/ags/lib/option-rs.ts b/ags/lib/option-rs.ts new file mode 100644 index 0000000..4916d90 --- /dev/null +++ b/ags/lib/option-rs.ts @@ -0,0 +1,49 @@ +export abstract class Option { + abstract orElse(fn: () => Option): Option; + abstract andThen(fn: (value: T) => Option): Option; + abstract unwrapOr(value: T): T; + + static from(value: T | null | undefined): Some | None { + return value !== null && value !== undefined ? new Some(value) : new None(); + } + + isSome(): boolean { + return this instanceof Some; + } + + isNone(): boolean { + return this instanceof None; + } +} + +export class Some extends Option { + constructor(public value: T) { + super(); + } + + orElse(_fn: () => Option): Option { + return this; + } + + andThen(fn: (value: T) => Option): Option { + return fn(this.value); + } + + unwrapOr(_value: T): T { + return this.value; + } +} + +export class None extends Option { + orElse(fn: () => Option): Option { + return fn(); + } + + andThen(_fn: (value: T) => Option): Option { + return new None(); + } + + unwrapOr(value: T): T { + return value; + } +} diff --git a/ags/lib/settings.ts b/ags/lib/settings.ts index 86c16ff..bf94d44 100644 --- a/ags/lib/settings.ts +++ b/ags/lib/settings.ts @@ -29,6 +29,7 @@ export let config = { enable: opt(true), }, }, + development: opt(false), }; /** diff --git a/ags/lib/widgets.ts b/ags/lib/widgets.ts index 722ec38..68633b0 100644 --- a/ags/lib/widgets.ts +++ b/ags/lib/widgets.ts @@ -1,5 +1,4 @@ import type Gtk from "gi://Gtk?version=3.0"; -import { Binding } from "types/service"; /** Return only the children that are not equal to null. */ export function conditionalChildren(children: (Gtk.Widget | null)[]): Gtk.Widget[] { diff --git a/ags/main.ts b/ags/main.ts index d1ca609..e2a654d 100644 --- a/ags/main.ts +++ b/ags/main.ts @@ -1,10 +1,12 @@ import Gdk from "gi://Gdk"; import type Gtk from "gi://Gtk?version=3.0"; +import { SetupCssHotReload } from "lib/develop"; import { VolumePopup } from "osd-popup/osd-popup.js"; import { Bar } from "./bar/bar.js"; import { config, readConfig } from "./lib/settings.js"; import { Quicksettings } from "./quicksettings/quicksettings.js"; +import TimeMenu from "./timemenu/timemenu"; function forMonitors(widget: (monitor: number) => Gtk.Window) { const n = Gdk.Display.get_default()?.get_n_monitors() || 1; @@ -13,10 +15,13 @@ function forMonitors(widget: (monitor: number) => Gtk.Window) { export function main(dest: string): void { readConfig(); + + if (config.development) SetupCssHotReload(dest); + App.config({ style: `${dest}/style.css`, windows: () => { - const windows = [...forMonitors(Bar), Quicksettings()]; + const windows = [...forMonitors(Bar), Quicksettings(), TimeMenu()]; if (config.popups?.volumePopup?.enable) { windows.push(VolumePopup()); diff --git a/ags/style.scss b/ags/style.scss index 54343c3..932c874 100644 --- a/ags/style.scss +++ b/ags/style.scss @@ -346,7 +346,6 @@ window.darkened { } } } - } .osd-popup { @@ -385,4 +384,110 @@ window.darkened { } } +} + +.timemenu { + @include space-between-y(1.5em); + + padding: 1em; + min-width: 28em; + min-height: 26em; + + .notifications { + + .placeholder { + @include space-between-y(1em); + + >.icon, + >label { + color: mix($background1, $text, $weight: 25%); + font-size: 1.25em; + font-weight: bold; + } + } + + .list { + @include space-between-y(0.75em); + + .notification>.event-box { + border-radius: 1em; + background-color: $background2; + + .notification-inner { + padding: 1em; + @include space-between-y(0.25em); + + .header { + @include space-between-x(0.75em); + + .app-icon { + color: mix($background2, $text, 15%); + font-weight: bold; + font-size: 13px; + } + + .app-name { + color: mix($background2, $text, 15%); + font-weight: bold; + font-size: 13px; + } + + .time { + padding-top: 0.15em; + color: mix($background2, $text, 25%); + font-size: 12px; + } + + .buttons { + margin-top: -0.3em; + margin-right: -0.3em; + + .close-button { + border-radius: 9999px; + background-color: $surface0; + padding: 0.325em; + } + } + } + + .content { + @include space-between-x(0.75em); + + .image { + $icon-width: 3.25em; + + min-width: $icon-width; + min-height: $icon-width; + + border-radius: 9999px; + + background-size: cover; + background-repeat: no-repeat; + background-position: center; + } + + .text { + @include space-between-y(0.4em); + + .title { + font-weight: bold; + font-size: 14px; + } + + .description { + font-size: 14px; + } + } + } + } + + /* box-shadow: 0 0.1em 0.25em rgba(20, 20, 20, 0.25); */ + + &:hover { + /* box-shadow: inset 1px 1px 1px 1px $s; */ + /* background-color: hover-color($background2); */ + } + } + } + } } \ No newline at end of file diff --git a/ags/theme.scss b/ags/theme.scss index e30c6f3..9e33125 100644 --- a/ags/theme.scss +++ b/ags/theme.scss @@ -5,6 +5,7 @@ $primary: #94e2d5; $text: #cdd6f4; -$background0: #181825; -$background1: #1e1e2e; +$background0: #11111b; +$background1: #181825; +$background2: #1e1e2e; $surface0: #313244; \ No newline at end of file diff --git a/ags/timemenu/notification-list.ts b/ags/timemenu/notification-list.ts new file mode 100644 index 0000000..55fa270 --- /dev/null +++ b/ags/timemenu/notification-list.ts @@ -0,0 +1,96 @@ +import type { Variable as VariableType } from "types/variable"; + +import Notification from "./notification"; + +const notificationService = await Service.import("notifications"); + +type NotificationMap = VariableType>>; + +const Placeholder = (notifications: NotificationMap) => + Widget.Box({ + visible: notifications.bind().as((m) => m.size === 0), + className: "placeholder", + hexpand: true, + vexpand: true, + vertical: true, + hpack: "center", + vpack: "center", + children: [ + Widget.Icon({ + className: "icon", + icon: "org.gnome.Settings-notifications-symbolic", + size: 80, + }), + Widget.Label("No notifications"), + ], + }); + +const NotificationList = (notifications: NotificationMap) => { + const notify = (self: ReturnType, id: number | undefined) => { + if (id === undefined) return; + const result = notifications.getValue(); + + // Remove notification with the same id. + // These are notifications that are meant to replace other notifications + // (e.g., currently playing song). + const replacedNotification = result.get(id); + if (replacedNotification !== undefined) { + result.delete(id); + replacedNotification.destroy(); + } + + const info = notificationService.getNotification(id); + if (info === undefined) return; + + const notification = Notification(info); + notifications.setValue(result.set(id, notification)); + self.children = [notification, ...self.children]; + }; + + const remove = (id: number | undefined) => { + if (id === undefined) return; + + const m = notifications.getValue(); + const notification = m.get(id); + if (notification === undefined) { + return; + } + + m.delete(id); + notification.destroy(); + notifications.setValue(m); + }; + + return Widget.Scrollable({ + visible: notifications.bind().as((n) => n.size > 0), + vexpand: true, + hscroll: "never", + child: Widget.Box({ + className: "list", + vertical: true, + children: notificationService.notifications.map((info) => { + const notification = Notification(info); + notifications.setValue(notifications.getValue().set(info.id, notification)); + return notification; + }), + setup(self) { + self + .hook( + notificationService, + (_: unknown, id: number | undefined) => notify(self, id), + "notified", + ) + .hook(notificationService, (_: unknown, id: number | undefined) => remove(id), "closed"); + }, + }), + }); +}; + +export default () => { + // Keep track of registered notifications. + const notifications: NotificationMap = Variable(new Map()); + return Widget.Box({ + className: "notifications", + children: [Placeholder(notifications), NotificationList(notifications)], + }); +}; diff --git a/ags/timemenu/notification.ts b/ags/timemenu/notification.ts new file mode 100644 index 0000000..769a398 --- /dev/null +++ b/ags/timemenu/notification.ts @@ -0,0 +1,116 @@ +import type { Notification as NotificationInfo } from "types/service/notifications"; + +import { formatTime } from "lib/format"; +import { conditionalChildren } from "lib/widgets"; + +export default (notification: NotificationInfo) => { + const content = Widget.Box({ + className: "notification-inner", + vertical: true, + children: [ + Widget.Box({ + className: "header", + hexpand: true, + vertical: false, + children: conditionalChildren([ + notification.app_icon + ? Widget.Icon({ + className: "app-icon", + vpack: "start", + icon: notification.app_icon, + size: 12, + }) + : null, + Widget.Label({ + className: "app-name", + vpack: "start", + label: notification.app_name, + }), + Widget.Label({ + className: "time", + vpack: "start", + label: formatTime(notification.time), + }), + Widget.Box({ + className: "buttons", + vpack: "center", + hpack: "end", + hexpand: true, + children: [ + Widget.Button({ + className: "close-button", + child: Widget.Icon("window-close-symbolic"), + onClicked: notification.close, + }), + ], + }), + ]), + }), + Widget.Box({ + class_name: "content", + vertical: false, + children: conditionalChildren([ + notification.image + ? Widget.Box({ + vertical: true, + children: [ + Widget.Box({ + className: "image", + vexpand: false, + css: `background-image: url("${notification.image}");`, + }), + // Needed so the icon does not get stretched vertically. + Widget.Box({ + vexpand: true, + }), + ], + }) + : null, + Widget.Box({ + vertical: true, + hexpand: true, + hpack: "start", + className: "text", + vpack: "center", + children: [ + Widget.Label({ + className: "title", + hpack: "start", + vpack: "end", + justification: "left", + xalign: 0, + truncate: "end", + hexpand: true, + wrap: true, + label: notification.summary.trim(), + useMarkup: true, + }), + Widget.Label({ + className: "description", + justification: "left", + xalign: 0, + useMarkup: true, + hpack: "start", + vpack: "end", + label: notification.body.trim(), + wrap: true, + }), + ], + }), + ]), + }), + ], + }); + + return Widget.Box({ + className: `notification ${notification.urgency}`, + child: Widget.EventBox({ + className: "event-box", + vexpand: false, + child: Widget.Box({ + vertical: true, + children: [content], + }), + }), + }); +}; diff --git a/ags/timemenu/timemenu.ts b/ags/timemenu/timemenu.ts new file mode 100644 index 0000000..abe5d4c --- /dev/null +++ b/ags/timemenu/timemenu.ts @@ -0,0 +1,16 @@ +import { PopupWindow } from "window"; +import NotificationList from "./notification-list"; + +export default () => + PopupWindow({ + name: "timemenu", + location: "top-center", + + child: Widget.Box({ + className: "timemenu popup", + vertical: true, + hexpand: false, + vexpand: false, + children: [NotificationList()], + }), + });