feat(ags): update to official agsV2

This commit is contained in:
matt1432 2024-11-13 19:39:01 -05:00
parent 5d27b3d975
commit f3e06554e4
105 changed files with 245 additions and 254 deletions

View file

@ -0,0 +1,6 @@
.applauncher {
.app {
margin: 20px;
font-size: 16px;
}
}

View file

@ -0,0 +1,65 @@
import { Gtk, Widget } from 'astal/gtk3';
import { register } from 'astal/gobject';
/* Types */
import AstalApps from 'gi://AstalApps';
type AppItemProps = Widget.BoxProps & {
app: AstalApps.Application
};
@register()
export class AppItem extends Widget.Box {
readonly app: AstalApps.Application;
constructor({
app,
hexpand = true,
className = '',
...rest
}: AppItemProps) {
super({
...rest,
className: `app ${className}`,
hexpand,
});
this.app = app;
const icon = (
<icon
icon={this.app.iconName}
css="font-size: 42px; margin-right: 25px;"
/>
);
const textBox = (
<box
vertical
>
<label
className="title"
label={app.name}
xalign={0}
truncate
valign={Gtk.Align.CENTER}
/>
{app.description !== '' && (
<label
className="description"
label={app.description}
wrap
xalign={0}
justify={Gtk.Justification.LEFT}
valign={Gtk.Align.CENTER}
/>
)}
</box>
);
this.add(icon);
this.add(textBox);
}
}
export default AppItem;

View file

@ -0,0 +1,27 @@
import { execAsync } from 'astal';
import AstalApps from 'gi://AstalApps';
const bash = async(strings: TemplateStringsArray | string, ...values: unknown[]) => {
const cmd = typeof strings === 'string' ?
strings :
strings.flatMap((str, i) => `${str}${values[i] ?? ''}`)
.join('');
return execAsync(['bash', '-c', cmd]).catch((err) => {
console.error(cmd, err);
return '';
});
};
export const launchApp = (app: AstalApps.Application) => {
const exe = app.executable
.split(/\s+/)
.filter((str) => !str.startsWith('%') && !str.startsWith('@'))
.join(' ');
bash(`${exe} &`);
app.frequency += 1;
};

View file

@ -0,0 +1,55 @@
import { App } from 'astal/gtk3';
import AstalApps from 'gi://AstalApps';
import SortedList from '../misc/sorted-list';
import { launchApp } from './launch';
import AppItem from './app-item';
export default () => SortedList({
name: 'applauncher',
create_list: () => AstalApps.Apps.new().get_list(),
create_row: (app) => <AppItem app={app} />,
fzf_options: {
selector: (app) => app.name + app.executable,
tiebreakers: [
(a, b) => b.item.frequency - a.item.frequency,
],
},
unique_props: ['name', 'executable'],
on_row_activated: (row) => {
const app = (row.get_children()[0] as AppItem).app;
launchApp(app);
App.get_window('win-applauncher')?.set_visible(false);
},
sort_func: (a, b, entry, fzfResults) => {
const row1 = (a.get_children()[0] as AppItem).app;
const row2 = (b.get_children()[0] as AppItem).app;
if (entry.text === '' || entry.text === '-') {
a.set_visible(true);
b.set_visible(true);
return row2.frequency - row1.frequency;
}
else {
const s1 = fzfResults.find((r) => r.item.name === row1.name)?.score ?? 0;
const s2 = fzfResults.find((r) => r.item.name === row2.name)?.score ?? 0;
a.set_visible(s1 !== 0);
b.set_visible(s2 !== 0);
return s2 - s1;
}
},
});

View file

@ -0,0 +1,77 @@
@use 'sass:color';
@use '../../style/colors';
.bar {
margin-left: 5px;
margin-right: 15px;
margin-bottom: 13px;
.bar-item {
padding: 5px 10px 5px 10px;
border-radius: 7px;
background-color: color.adjust(colors.$window_bg_color, $lightness: -3%);
font-size: 20px;
min-height: 35px;
transition: background-color 300ms;
&:hover {
background-color: color.adjust(colors.$window_bg_color, $lightness: 3%);
}
&.network icon {
min-width: 30px;
}
&.battery icon {
&.charging {
color: green;
}
&.low {
color: red;
}
}
.workspaces {
.button {
margin: 0 2.5px;
min-height: 22px;
min-width: 22px;
border-radius: 100%;
border: 2px solid transparent;
}
.occupied {
border: 2px solid colors.$window_bg_color;
background: colors.$accent_color;
transition: background-color 0.3s ease-in-out;
}
.urgent {
border: 2px solid colors.$window_bg_color;
background: red;
transition: background-color 0.3s ease-in-out;
}
.active {
border: 2px solid #50fa7b;
transition: margin-left 0.3s cubic-bezier(0.165, 0.84, 0.44, 1);
}
}
&.system-tray {
.tray-item {
all: unset;
font-size: 30px;
min-width: 36px;
border-radius: 100%;
transition: background-color 300ms;
&:hover {
background: colors.$window_bg_color;
}
}
}
}
}

View file

@ -0,0 +1,61 @@
import { Astal, Gtk } from 'astal/gtk3';
import Audio from './items/audio';
import Clock from './items/clock';
import CurrentClient from './items/current-client';
import Network from './items/network';
import NotifButton from './items/notif-button';
import SysTray from './items/tray';
import Workspaces from './items/workspaces';
import BarRevealer from './fullscreen';
import Separator from '../misc/separator';
import { get_gdkmonitor_from_desc } from '../../lib';
export default () => (
<BarRevealer
gdkmonitor={get_gdkmonitor_from_desc('desc:Acer Technologies Acer K212HQL T3EAA0014201')}
exclusivity={Astal.Exclusivity.EXCLUSIVE}
anchor={
Astal.WindowAnchor.BOTTOM |
Astal.WindowAnchor.LEFT |
Astal.WindowAnchor.RIGHT
}
>
<centerbox className="bar widget">
<box hexpand halign={Gtk.Align.START}>
<Workspaces />
<Separator size={8} />
<CurrentClient />
<Separator size={8} />
</box>
<box>
<Clock />
</box>
<box hexpand halign={Gtk.Align.END}>
<SysTray />
<Separator size={8} />
<Network />
<Separator size={8} />
<NotifButton />
<Separator size={8} />
<Audio />
<Separator size={2} />
</box>
</centerbox>
</BarRevealer>
);

View file

@ -0,0 +1,187 @@
import { App, Astal, Gdk, Gtk, Widget } from 'astal/gtk3';
import { bind, Variable } from 'astal';
import AstalHyprland from 'gi://AstalHyprland';
const Hyprland = AstalHyprland.get_default();
import { get_hyprland_monitor_desc, get_monitor_desc, hyprMessage } from '../../lib';
const FullscreenState = Variable({
monitors: [] as string[],
clientAddrs: new Map() as Map<string, string>,
});
Hyprland.connect('event', async() => {
const arrayEquals = (a1: unknown[], a2: unknown[]) =>
a1.sort().toString() === a2.sort().toString();
const mapEquals = (m1: Map<string, string>, m2: Map<string, string>) =>
m1.size === m2.size &&
Array.from(m1.keys()).every((key) => m1.get(key) === m2.get(key));
try {
const newMonitors = JSON.parse(await hyprMessage('j/monitors')) as AstalHyprland.Monitor[];
const fs = FullscreenState.get();
const fsClients = Hyprland.get_clients().filter((c) => {
const mon = newMonitors.find((monitor) => monitor.id === c.get_monitor()?.id);
return c.fullscreenClient !== 0 &&
c.workspace.id === mon?.activeWorkspace.id;
});
const monitors = fsClients.map((c) =>
get_monitor_desc(c.monitor));
const clientAddrs = new Map(fsClients.map((c) => [
get_monitor_desc(c.monitor),
c.address ?? '',
]));
const hasChanged =
!arrayEquals(monitors, fs.monitors) ||
!mapEquals(clientAddrs, fs.clientAddrs);
if (hasChanged) {
FullscreenState.set({
monitors,
clientAddrs,
});
}
}
catch (e) {
console.log(e);
}
});
export default ({
anchor,
gdkmonitor = Gdk.Display.get_default()?.get_monitor(0) as Gdk.Monitor,
child,
...rest
}: {
anchor: Astal.WindowAnchor
gdkmonitor?: Gdk.Monitor
} & Widget.WindowProps) => {
const monitor = get_hyprland_monitor_desc(gdkmonitor);
const BarVisible = Variable(true);
FullscreenState.subscribe((v) => {
BarVisible.set(!v.monitors.includes(monitor));
});
const barCloser = (
<window
name={`bar-${monitor}-closer`}
css="all: unset;"
visible={false}
gdkmonitor={gdkmonitor}
layer={Astal.Layer.OVERLAY}
anchor={
Astal.WindowAnchor.TOP |
Astal.WindowAnchor.BOTTOM |
Astal.WindowAnchor.LEFT |
Astal.WindowAnchor.RIGHT
}
>
<eventbox
on_hover={() => {
barCloser.visible = false;
BarVisible.set(false);
}}
>
<box css="padding: 1px;" />
</eventbox>
</window>
);
// Hide bar instantly when out of focus
Hyprland.connect('notify::focused-workspace', () => {
const addr = FullscreenState.get().clientAddrs.get(monitor);
if (addr) {
const client = Hyprland.get_client(addr);
if (client?.workspace.id !== Hyprland.get_focused_workspace().get_id()) {
BarVisible.set(true);
barCloser.visible = false;
}
else {
BarVisible.set(false);
barCloser.visible = true;
}
}
});
const buffer = (
<box
css="min-height: 10px;"
visible={bind(BarVisible).as((v) => !v)}
/>
);
const vertical = anchor >= (Astal.WindowAnchor.LEFT | Astal.WindowAnchor.RIGHT);
const isBottomOrLeft = (
anchor === (Astal.WindowAnchor.LEFT | Astal.WindowAnchor.RIGHT | Astal.WindowAnchor.BOTTOM)
) || (
anchor === (Astal.WindowAnchor.LEFT | Astal.WindowAnchor.TOP | Astal.WindowAnchor.BOTTOM)
);
let transition: Gtk.RevealerTransitionType;
if (vertical) {
transition = isBottomOrLeft ?
Gtk.RevealerTransitionType.SLIDE_UP :
Gtk.RevealerTransitionType.SLIDE_DOWN;
}
else {
transition = isBottomOrLeft ?
Gtk.RevealerTransitionType.SLIDE_RIGHT :
Gtk.RevealerTransitionType.SLIDE_LEFT;
}
const barWrap = (
<revealer
reveal_child={bind(BarVisible)}
transitionType={transition}
>
{child}
</revealer>
);
const win = (
<window
name={`bar-${monitor}`}
namespace={`bar-${monitor}`}
layer={Astal.Layer.OVERLAY}
gdkmonitor={gdkmonitor}
anchor={anchor}
{...rest}
>
<eventbox
onHover={() => {
if (!BarVisible.get()) {
barCloser.visible = true;
BarVisible.set(true);
}
}}
>
<box
css="min-height: 1px; padding: 1px;"
hexpand
halign={Gtk.Align.FILL}
vertical={vertical}
>
{isBottomOrLeft ?
[buffer, barWrap] :
[barWrap, buffer]}
</box>
</eventbox>
</window>
) as Widget.Window;
App.add_window(win);
return win;
};

View file

@ -0,0 +1,28 @@
import { bind } from 'astal';
import AstalWp from 'gi://AstalWp';
export default () => {
const speaker = AstalWp.get_default()?.audio.default_speaker;
if (!speaker) {
throw new Error('Could not find default audio devices.');
}
return (
<box className="bar-item audio">
<overlay>
<circularprogress
startAt={0.75}
endAt={0.75}
value={bind(speaker, 'volume')}
rounded
className={bind(speaker, 'mute').as((muted) => muted ? 'disabled' : '')}
/>
<icon icon={bind(speaker, 'volumeIcon')} />
</overlay>
</box>
);
};

View file

@ -0,0 +1,45 @@
import { bind } from 'astal';
import AstalBattery from 'gi://AstalBattery';
const Battery = AstalBattery.get_default();
import Separator from '../../misc/separator';
const LOW_BATT = 20;
export default () => (
<box className="bar-item battery">
<icon
setup={(self) => {
const update = () => {
const percent = Math.round(Battery.get_percentage() * 100);
const level = Math.floor(percent / 10) * 10;
const isCharging = Battery.get_charging();
const charged = percent === 100 && isCharging;
const iconName = charged ?
'battery-level-100-charged-symbolic' :
`battery-level-${level}${isCharging ?
'-charging' :
''}-symbolic`;
self.set_icon(iconName);
self.toggleClassName('charging', isCharging);
self.toggleClassName('charged', charged);
self.toggleClassName('low', percent < LOW_BATT);
};
update();
Battery.connect('notify::percentage', () => update());
Battery.connect('notify::icon-name', () => update());
Battery.connect('notify::battery-icon-name', () => update());
}}
/>
<Separator size={8} />
<label label={bind(Battery, 'percentage').as((v) => `${Math.round(v * 100)}%`)} />
</box>
);

View file

@ -0,0 +1,20 @@
import { bind } from 'astal';
import Brightness from '../../../services/brightness';
export default () => {
return (
<box className="bar-item brightness">
<overlay>
<circularprogress
startAt={0.75}
endAt={0.75}
value={bind(Brightness, 'screen')}
rounded
/>
<icon icon={bind(Brightness, 'screenIcon')} />
</overlay>
</box>
);
};

View file

@ -0,0 +1,55 @@
import { bind, Variable } from 'astal';
import { App } from 'astal/gtk3';
import GLib from 'gi://GLib';
import PopupWindow from '../../misc/popup-window';
export default () => {
const timeVar = Variable<string>('').poll(1000, (prev) => {
const time = GLib.DateTime.new_now_local();
const dayName = time.format('%a. ');
const dayNum = time.get_day_of_month();
const date = time.format(' %b. ');
const hour = (new Date().toLocaleString([], {
hour: 'numeric',
minute: 'numeric',
hour12: true,
}) ?? '')
.replace('a.m.', 'AM')
.replace('p.m.', 'PM');
return (dayNum && dayName && date) ?
(dayName + dayNum + date + hour) :
prev;
});
return (
<button
className="bar-item"
cursor="pointer"
onButtonReleaseEvent={(self) => {
const win = App.get_window('win-calendar') as PopupWindow;
win.set_x_pos(
self.get_allocation(),
'right',
);
win.visible = !win.visible;
}}
setup={(self) => {
App.connect('window-toggled', (_, win) => {
if (win.name === 'win-notif-center') {
self.toggleClassName('toggle-on', win.visible);
}
});
}}
>
<label label={bind(timeVar)} />
</button>
);
};

View file

@ -0,0 +1,78 @@
import { bind, Variable } from 'astal';
import AstalApps from 'gi://AstalApps';
const Applications = AstalApps.Apps.new();
import AstalHyprland from 'gi://AstalHyprland';
const Hyprland = AstalHyprland.get_default();
import Separator from '../../misc/separator';
import { hyprMessage } from '../../../lib';
export default () => {
const visibleIcon = Variable<boolean>(false);
const focusedIcon = Variable<string>('');
const focusedTitle = Variable<string>('');
let lastFocused: string | undefined;
const updateVars = (
client: AstalHyprland.Client | null = Hyprland.get_focused_client(),
) => {
lastFocused = client?.get_address();
const app = Applications.fuzzy_query(
client?.get_class() ?? '',
)[0];
const icon = app?.iconName;
if (icon) {
visibleIcon.set(true);
focusedIcon.set(icon);
}
else {
visibleIcon.set(false);
}
focusedTitle.set(client?.get_title() ?? '');
const id = client?.connect('notify::title', (c) => {
if (c.get_address() !== lastFocused) {
c.disconnect(id);
}
focusedTitle.set(c.get_title());
});
};
updateVars();
Hyprland.connect('notify::focused-client', () => updateVars());
Hyprland.connect('client-removed', () => updateVars());
Hyprland.connect('client-added', async() => {
try {
updateVars(Hyprland.get_client(JSON.parse(await hyprMessage('j/activewindow')).address));
}
catch (e) {
console.log(e);
}
});
return (
<box
className="bar-item current-window"
visible={bind(focusedTitle).as((title) => title !== '')}
>
<icon
css="font-size: 32px;"
icon={bind(focusedIcon)}
visible={bind(visibleIcon)}
/>
<Separator size={8} />
<label
label={bind(focusedTitle)}
truncate
/>
</box>
);
};

View file

@ -0,0 +1,53 @@
import { bind, Variable } from 'astal';
import { Gtk } from 'astal/gtk3';
import AstalNetwork from 'gi://AstalNetwork';
const Network = AstalNetwork.get_default();
export default () => {
const Hovered = Variable(false);
return (
<button
className="bar-item network"
cursor="pointer"
onHover={() => Hovered.set(true)}
onHoverLost={() => Hovered.set(false)}
>
{bind(Network, 'primary').as((primary) => {
if (primary === AstalNetwork.Primary.UNKNOWN) {
return (<icon icon="network-wireless-signal-none-symbolic" />);
}
else if (primary === AstalNetwork.Primary.WIFI) {
const Wifi = Network.get_wifi();
if (!Wifi) { return; }
return (
<box>
<icon icon={bind(Wifi, 'iconName')} />
<revealer
revealChild={bind(Hovered)}
transitionType={Gtk.RevealerTransitionType.SLIDE_LEFT}
>
{bind(Wifi, 'activeAccessPoint').as((ap) => (
<label label={bind(ap, 'ssid')} />
))}
</revealer>
</box>
);
}
else {
const Wired = Network.get_wired();
if (!Wired) { return; }
return (<icon icon={bind(Wired, 'iconName')} />);
}
})}
</button>
);
};

View file

@ -0,0 +1,59 @@
import { bind } from 'astal';
import { App } from 'astal/gtk3';
import AstalNotifd from 'gi://AstalNotifd';
const Notifications = AstalNotifd.get_default();
import Separator from '../../misc/separator';
const SPACING = 4;
// Types
import PopupWindow from '../../misc/popup-window';
export default () => (
<button
className="bar-item"
cursor="pointer"
onButtonReleaseEvent={(self) => {
const win = App.get_window('win-notif-center') as PopupWindow;
win.set_x_pos(
self.get_allocation(),
'right',
);
win.visible = !win.visible;
}}
setup={(self) => {
App.connect('window-toggled', (_, win) => {
if (win.name === 'win-notif-center') {
self.toggleClassName('toggle-on', win.visible);
}
});
}}
>
<box>
<icon
icon={bind(Notifications, 'notifications').as((notifs) => {
if (Notifications.dontDisturb) {
return 'notification-disabled-symbolic';
}
else if (notifs.length > 0) {
return 'notification-new-symbolic';
}
else {
return 'notification-symbolic';
}
})}
/>
<Separator size={SPACING} />
<label label={bind(Notifications, 'notifications').as((n) => String(n.length))} />
</box>
</button>
);

View file

@ -0,0 +1,78 @@
import { App, Gdk, Gtk, Widget } from 'astal/gtk3';
import { bind, idle } from 'astal';
import AstalTray from 'gi://AstalTray';
const Tray = AstalTray.get_default();
const SKIP_ITEMS = ['.spotify-wrapped'];
const TrayItem = (item: AstalTray.TrayItem) => {
if (item.iconThemePath) {
App.add_icons(item.iconThemePath);
}
const menu = item.create_menu();
return (
<revealer
transitionType={Gtk.RevealerTransitionType.SLIDE_RIGHT}
revealChild={false}
>
<button
className="tray-item"
cursor="pointer"
tooltipMarkup={bind(item, 'tooltipMarkup')}
onDestroy={() => menu?.destroy()}
onClickRelease={(self) => {
menu?.popup_at_widget(self, Gdk.Gravity.SOUTH, Gdk.Gravity.NORTH, null);
}}
>
<icon gIcon={bind(item, 'gicon')} />
</button>
</revealer>
);
};
export default () => {
const itemMap = new Map<string, Widget.Revealer>();
return (
<box
className="bar-item system-tray"
visible={bind(Tray, 'items').as((items) => items.length !== 0)}
setup={(self) => {
self
.hook(Tray, 'item-added', (_, item: string) => {
if (itemMap.has(item) || SKIP_ITEMS.includes(Tray.get_item(item).title)) {
return;
}
const widget = TrayItem(Tray.get_item(item)) as Widget.Revealer;
itemMap.set(item, widget);
self.add(widget);
idle(() => {
widget.set_reveal_child(true);
});
})
.hook(Tray, 'item-removed', (_, item: string) => {
if (!itemMap.has(item)) {
return;
}
const widget = itemMap.get(item);
widget?.set_reveal_child(false);
setTimeout(() => {
widget?.destroy();
}, 1000);
});
}}
/>
);
};

View file

@ -0,0 +1,172 @@
import { Gtk, Widget } from 'astal/gtk3';
import { timeout } from 'astal';
import AstalHyprland from 'gi://AstalHyprland';
const Hyprland = AstalHyprland.get_default();
import { hyprMessage } from '../../../lib';
const URGENT_DURATION = 1000;
const Workspace = ({ id = 0 }) => (
<revealer
name={id.toString()}
transitionType={Gtk.RevealerTransitionType.SLIDE_RIGHT}
>
<eventbox
cursor="pointer"
tooltip_text={id.toString()}
onClickRelease={() => {
hyprMessage(`dispatch workspace ${id}`).catch(console.log);
}}
>
<box
valign={Gtk.Align.CENTER}
className="button"
setup={(self) => {
const update = (
_: Widget.Box,
client?: AstalHyprland.Client,
) => {
const workspace = Hyprland.get_workspace(id);
const occupied = workspace && workspace.get_clients().length > 0;
self.toggleClassName('occupied', occupied);
if (!client) {
return;
}
const isUrgent = client &&
client.get_workspace().get_id() === id;
if (isUrgent) {
self.toggleClassName('urgent', true);
// Only show for a sec when urgent is current workspace
if (Hyprland.get_focused_workspace().get_id() === id) {
timeout(URGENT_DURATION, () => {
self.toggleClassName('urgent', false);
});
}
}
};
update(self);
self
.hook(Hyprland, 'event', () => update(self))
// Deal with urgent windows
.hook(Hyprland, 'urgent', update)
.hook(Hyprland, 'notify::focused-workspace', () => {
if (Hyprland.get_focused_workspace().get_id() === id) {
self.toggleClassName('urgent', false);
}
});
}}
/>
</eventbox>
</revealer>
);
export default () => {
const L_PADDING = 2;
const WS_WIDTH = 30;
const updateHighlight = (self: Widget.Box) => {
const currentId = Hyprland.get_focused_workspace().get_id().toString();
const indicators = ((self.get_parent() as Widget.Overlay)
.child as Widget.Box)
.children as Widget.Revealer[];
const currentIndex = indicators.findIndex((w) => w.name === currentId);
if (currentIndex >= 0) {
self.css = `margin-left: ${L_PADDING + (currentIndex * WS_WIDTH)}px`;
}
};
const highlight = (
<box
className="button active"
valign={Gtk.Align.CENTER}
halign={Gtk.Align.START}
setup={(self) => {
self.hook(Hyprland, 'notify::focused-workspace', updateHighlight);
}}
/>
) as Widget.Box;
let workspaces: Widget.Revealer[] = [];
return (
<box
className="bar-item"
>
<overlay
className="workspaces"
passThrough
overlay={highlight}
>
<box
setup={(self) => {
const refresh = () => {
(self.children as Widget.Revealer[]).forEach((rev) => {
rev.reveal_child = false;
});
workspaces.forEach((ws) => {
ws.reveal_child = true;
});
};
const updateWorkspaces = () => {
Hyprland.get_workspaces().forEach((ws) => {
const currentWs = (self.children as Widget.Revealer[])
.find((ch) => ch.name === ws.id.toString());
if (!currentWs && ws.id > 0) {
self.add(Workspace({ id: ws.id }));
}
});
// Make sure the order is correct
workspaces.forEach((workspace, i) => {
(workspace.get_parent() as Widget.Box)
.reorder_child(workspace, i);
});
};
const updateAll = () => {
workspaces = (self.children as Widget.Revealer[])
.filter((ch) => {
return Hyprland.get_workspaces().find((ws) => {
return ws.id.toString() === ch.name;
});
})
.sort((a, b) => parseInt(a.name ?? '0') - parseInt(b.name ?? '0'));
updateWorkspaces();
refresh();
// Make sure the highlight doesn't go too far
const TEMP_TIMEOUT = 100;
timeout(TEMP_TIMEOUT, () => updateHighlight(highlight));
};
updateAll();
self.hook(Hyprland, 'event', updateAll);
}}
/>
</overlay>
</box>
);
};

View file

@ -0,0 +1,70 @@
import { Astal, Gtk } from 'astal/gtk3';
import Audio from './items/audio';
import Battery from './items/battery';
import Brightness from './items/brightness';
import Clock from './items/clock';
import CurrentClient from './items/current-client';
import Network from './items/network';
import NotifButton from './items/notif-button';
import SysTray from './items/tray';
import Workspaces from './items/workspaces';
import BarRevealer from './fullscreen';
import Separator from '../misc/separator';
// TODO: add Bluetooth
export default () => (
<BarRevealer
exclusivity={Astal.Exclusivity.EXCLUSIVE}
anchor={
Astal.WindowAnchor.TOP |
Astal.WindowAnchor.LEFT |
Astal.WindowAnchor.RIGHT
}
>
<centerbox className="bar widget">
<box hexpand halign={Gtk.Align.START}>
<Workspaces />
<Separator size={8} />
<SysTray />
<Separator size={8} />
<CurrentClient />
<Separator size={8} />
</box>
<box>
<Clock />
</box>
<box hexpand halign={Gtk.Align.END}>
<Network />
<Separator size={8} />
<NotifButton />
<Separator size={8} />
<Audio />
<Separator size={8} />
<Brightness />
<Separator size={8} />
<Battery />
<Separator size={2} />
</box>
</centerbox>
</BarRevealer>
);

View file

@ -0,0 +1,24 @@
import { Astal } from 'astal/gtk3';
export default () => {
return (
<window
name="bg-fade"
layer={Astal.Layer.BACKGROUND}
exclusivity={Astal.Exclusivity.IGNORE}
anchor={
Astal.WindowAnchor.TOP |
Astal.WindowAnchor.BOTTOM |
Astal.WindowAnchor.LEFT |
Astal.WindowAnchor.RIGHT
}
css={`
background-image: -gtk-gradient (linear,
left top, left bottom,
from(rgba(0, 0, 0, 0.5)),
to(rgba(0, 0, 0, 0)));
`}
/>
);
};

View file

@ -0,0 +1,4 @@
.clipboard .list row box {
margin: 20px;
font-size: 16px;
}

View file

@ -0,0 +1,98 @@
import { execAsync } from 'astal';
import { register } from 'astal/gobject';
import { Gtk, Widget } from 'astal/gtk3';
export interface EntryObject {
id: number
content: string
entry: string
}
const SCALE = 150;
const BINARY_DATA = /\[\[ binary data (\d+) (KiB|MiB) (\w+) (\d+)x(\d+) \]\]/;
export const CLIP_SCRIPT = `${SRC}/widgets/clipboard/cliphist.sh`;
@register()
export class ClipItem extends Widget.Box {
declare id: number;
declare content: string;
public show_image(file: string, width: string | number, height: string | number) {
this.children[2].destroy();
const initCss = () => {
const _widthPx = Number(width);
const heightPx = Number(height);
const maxWidth = 400;
const widthPx = (_widthPx / heightPx) * SCALE;
let css = `background-image: url("${file}");`;
if (widthPx > maxWidth) {
const newHeightPx = (SCALE / widthPx) * maxWidth;
css += `min-height: ${newHeightPx}px; min-width: ${maxWidth}px;`;
}
else {
css += `min-height: 150px; min-width: ${widthPx}px;`;
}
return css;
};
const icon = (
<box
valign={Gtk.Align.CENTER}
css={initCss()}
/>
);
this.children = [...this.children, icon];
};
constructor({ item }: { item: EntryObject }) {
super({
children: [
<label
label={item.id.toString()}
xalign={0}
valign={Gtk.Align.CENTER}
/>,
<label
label="・"
xalign={0}
valign={Gtk.Align.CENTER}
/>,
<label
label={item.content}
xalign={0}
valign={Gtk.Align.CENTER}
truncate
/>,
],
});
this.id = item.id;
this.content = item.content;
const matches = this.content.match(BINARY_DATA);
if (matches) {
// const size = matches[1];
const format = matches[3];
const width = matches[4];
const height = matches[5];
if (format === 'png') {
execAsync(`${CLIP_SCRIPT} --save-by-id ${this.id}`)
.then((file) => {
this.show_image(file, width, height);
})
.catch(print);
}
}
}
}
export default ClipItem;

View file

@ -0,0 +1,44 @@
#!/usr/bin/env bash
# https://github.com/koeqaife/hyprland-material-you/blob/d23cf9d524522c8c215664c2c3334c2b51609cae/ags/scripts/cliphist.sh
get() {
cliphist list | iconv -f "$(locale charmap)" -t UTF-8 -c
}
copy_by_id() {
id=$1
cliphist decode "$id" | wl-copy
}
clear() {
cliphist wipe
}
save_cache_file() {
id=$1
output_file="/tmp/ags/cliphist/$id.png"
if [[ ! -f "$output_file" ]]; then
mkdir -p "/tmp/ags/cliphist/"
cliphist decode "$id" >"$output_file"
fi
echo "$output_file"
}
clear_tmp() {
rm "/tmp/ags/cliphist/*"
}
if [[ "$1" == "--get" ]]; then
get
elif [[ "$1" == "--copy-by-id" ]]; then
{ copy_by_id "$2"; }
elif [[ "$1" == "--save-by-id" ]]; then
{ save_cache_file "$2"; }
elif [[ "$1" == "--clear-cache" ]]; then
clear_tmp
elif [[ "$1" == "--clear" ]]; then
clear
fi

View file

@ -0,0 +1,66 @@
import { execAsync } from 'astal';
import { App } from 'astal/gtk3';
import SortedList from '../misc/sorted-list';
import { CLIP_SCRIPT, ClipItem, EntryObject } from './clip-item';
export default () => SortedList<EntryObject>({
name: 'clipboard',
create_list: async() => {
const output = await execAsync(`${CLIP_SCRIPT} --get`)
.then((str) => str)
.catch((err) => {
print(err);
return '';
});
return output
.split('\n')
.filter((line) => line.trim() !== '')
.map((entry) => {
const [id, ...content] = entry.split('\t');
return { id: parseInt(id.trim()), content: content.join(' ').trim(), entry };
});
},
create_row: (item) => <ClipItem item={item} />,
fzf_options: {
selector: (item) => item.content,
},
unique_props: ['id'],
on_row_activated: (row) => {
const clip = row.get_children()[0] as ClipItem;
execAsync(`${CLIP_SCRIPT} --copy-by-id ${clip.id}`);
App.get_window('win-clipboard')?.set_visible(false);
},
sort_func: (a, b, entry, fzfResults) => {
const row1 = a.get_children()[0] as ClipItem;
const row2 = b.get_children()[0] as ClipItem;
if (entry.text === '' || entry.text === '-') {
a.set_visible(true);
b.set_visible(true);
return row2.id - row1.id;
}
else {
const s1 = fzfResults.find((r) => r.item.id === row1.id)?.score ?? 0;
const s2 = fzfResults.find((r) => r.item.id === row2.id)?.score ?? 0;
a.set_visible(s1 !== 0);
b.set_visible(s2 !== 0);
return s2 - s1;
}
},
});

View file

@ -0,0 +1,68 @@
import { Astal } from 'astal/gtk3';
import RoundedCorner from './screen-corners';
const TopLeft = () => (
<window
name="cornertl"
layer={Astal.Layer.OVERLAY}
exclusivity={Astal.Exclusivity.IGNORE}
anchor={
Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT
}
clickThrough={true}
>
{RoundedCorner('topleft')}
</window>
);
const TopRight = () => (
<window
name="cornertr"
layer={Astal.Layer.OVERLAY}
exclusivity={Astal.Exclusivity.IGNORE}
anchor={
Astal.WindowAnchor.TOP | Astal.WindowAnchor.RIGHT
}
clickThrough={true}
>
{RoundedCorner('topright')}
</window>
);
const BottomLeft = () => (
<window
name="cornerbl"
layer={Astal.Layer.OVERLAY}
exclusivity={Astal.Exclusivity.IGNORE}
anchor={
Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.LEFT
}
clickThrough={true}
>
{RoundedCorner('bottomleft')}
</window>
);
const BottomRight = () => (
<window
name="cornerbr"
layer={Astal.Layer.OVERLAY}
exclusivity={Astal.Exclusivity.IGNORE}
anchor={
Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.RIGHT
}
clickThrough={true}
>
{RoundedCorner('bottomright')}
</window>
);
export default () => [
TopLeft(),
TopRight(),
BottomLeft(),
BottomRight(),
];

View file

@ -0,0 +1,81 @@
import { Gtk } from 'astal/gtk3';
import Cairo from 'cairo';
export default (
place = 'top left',
css = 'background-color: black;',
) => (
<box
halign={place.includes('left') ? Gtk.Align.START : Gtk.Align.END}
valign={place.includes('top') ? Gtk.Align.START : Gtk.Align.END}
css={`
padding: 1px; margin:
${place.includes('top') ? '-1px' : '0'}
${place.includes('right') ? '-1px' : '0'}
${place.includes('bottom') ? '-1px' : '0'}
${place.includes('left') ? '-1px' : '0'};
`}
>
<drawingarea
css={`
border-radius: 18px;
border-width: 0.068rem;
${css}
`}
setup={(widget) => {
const styleContext = widget.get_style_context();
let radius = styleContext.get_property('border-radius', Gtk.StateFlags.NORMAL) as number;
widget.set_size_request(radius, radius);
widget.connect('draw', (_, cairoContext: Cairo.Context) => {
const bgColor = styleContext.get_background_color(Gtk.StateFlags.NORMAL);
const borderColor = styleContext.get_color(Gtk.StateFlags.NORMAL);
const borderWidth = styleContext.get_border(Gtk.StateFlags.NORMAL).left;
radius = styleContext.get_property('border-radius', Gtk.StateFlags.NORMAL) as number;
widget.set_size_request(radius, radius);
switch (place) {
case 'topleft':
cairoContext.arc(radius, radius, radius, Math.PI, 3 * Math.PI / 2);
cairoContext.lineTo(0, 0);
break;
case 'topright':
cairoContext.arc(0, radius, radius, 3 * Math.PI / 2, 2 * Math.PI);
cairoContext.lineTo(radius, 0);
break;
case 'bottomleft':
cairoContext.arc(radius, 0, radius, Math.PI / 2, Math.PI);
cairoContext.lineTo(0, radius);
break;
case 'bottomright':
cairoContext.arc(0, 0, radius, 0, Math.PI / 2);
cairoContext.lineTo(radius, radius);
break;
}
cairoContext.closePath();
cairoContext.setSourceRGBA(bgColor.red, bgColor.green, bgColor.blue, bgColor.alpha);
cairoContext.fill();
cairoContext.setLineWidth(borderWidth);
cairoContext.setSourceRGBA(
borderColor.red,
borderColor.green,
borderColor.blue,
borderColor.alpha,
);
cairoContext.stroke();
});
}}
/>
</box>
);

View file

@ -0,0 +1,68 @@
@use '../../style/colors';
.date {
margin-top: 0;
}
.timebox {
margin: 30px 0;
.time-container {
.content {
font-weight: bolder;
font-size: 60px;
}
.divider {
margin: 8px 15px;
padding: 0 1px;
background: linear-gradient(colors.$red, colors.$magenta, colors.$blue, colors.$cyan);
}
}
.date-container {
margin-top: 2px;
}
}
.cal-box {
padding: 0 1rem .2rem;
margin: 0 12px 18px;
calendar {
font-size: 20px;
background-color: inherit;
padding: .5rem .10rem 0;
margin-left: 10px;
&>* {
border: solid 0 transparent;
}
&.highlight {
padding: 10rem;
}
}
}
calendar:selected {
color: colors.$cyan;
}
calendar.header {
color: colors.$cyan;
font-weight: bold;
}
calendar.button {
color: colors.$cyan;
}
calendar.highlight {
color: colors.$green;
font-weight: bold;
}
calendar:indeterminate {
color: colors.$lightblack;
}

View file

@ -0,0 +1,18 @@
import { Astal } from 'astal/gtk3';
import PopupWindow from '../misc/popup-window';
import { get_gdkmonitor_from_desc } from '../../lib';
import DateWidget from './main';
export default () => (
<PopupWindow
name="calendar"
gdkmonitor={get_gdkmonitor_from_desc('desc:Acer Technologies Acer K212HQL T3EAA0014201')}
anchor={Astal.WindowAnchor.BOTTOM}
transition="slide bottom"
>
<DateWidget />
</PopupWindow>
);

View file

@ -0,0 +1,86 @@
import { bind, Variable } from 'astal';
import { Gtk } from 'astal/gtk3';
import GLib from 'gi://GLib';
const Divider = () => (
<box
className="divider"
vertical
/>
);
const Time = () => {
const hour = Variable<string>('').poll(1000, () => GLib.DateTime.new_now_local().format('%H') || '');
const min = Variable<string>('').poll(1000, () => GLib.DateTime.new_now_local().format('%M') || '');
const fullDate = Variable<string>('').poll(1000, () => {
const time = GLib.DateTime.new_now_local();
const dayNameMonth = time.format('%A, %B ');
const dayNum = time.get_day_of_month();
const date = time.format(', %Y');
return dayNum && dayNameMonth && date ?
dayNameMonth + dayNum + date :
'';
});
return (
<box
className="timebox"
vertical
>
<box
className="time-container"
halign={Gtk.Align.CENTER}
valign={Gtk.Align.CENTER}
>
<label
className="content"
label={bind(hour)}
/>
<Divider />
<label
className="content"
label={bind(min)}
/>
</box>
<box
className="date-container"
halign={Gtk.Align.CENTER}
>
<label
css="font-size: 20px;"
label={bind(fullDate)}
/>
</box>
</box>
);
};
export default () => {
const cal = new Gtk.Calendar({
show_day_names: true,
show_heading: true,
});
cal.show_all();
return (
<box
className="date widget"
vertical
>
<Time />
<box className="cal-box">
{cal}
</box>
</box>
);
};

View file

@ -0,0 +1,15 @@
import { Astal } from 'astal/gtk3';
import PopupWindow from '../misc/popup-window';
import DateWidget from './main';
export default () => (
<PopupWindow
name="calendar"
anchor={Astal.WindowAnchor.TOP}
>
<DateWidget />
</PopupWindow>
);

View file

@ -0,0 +1,118 @@
import { idle, readFile } from 'astal';
import { Astal, Gtk, Widget } from 'astal/gtk3';
import AstalGreet from 'gi://AstalGreet';
const DEFAULT_NAME = 'matt';
const PARSED_INDEX = {
name: 0,
uid: 2,
gid: 3,
desc: 4,
home: 5,
shell: 6,
};
const parsePasswd = (fileContent: string) => {
const splitUsers = fileContent.split('\n');
const parsedUsers = splitUsers.map((u) => {
const user = u.split(':');
return {
name: user[PARSED_INDEX.name],
uid: Number(user[PARSED_INDEX.uid]),
gid: Number(user[PARSED_INDEX.gid]),
desc: user[PARSED_INDEX.desc],
home: user[PARSED_INDEX.home],
shell: user[PARSED_INDEX.shell],
};
});
// Filter out system users, nixbld users and nobody
return parsedUsers.filter((u) => {
return u.uid >= 1000 &&
!u.name.includes('nixbld') &&
u.name !== 'nobody';
});
};
const users = parsePasswd(readFile('/etc/passwd'));
const dropdown = new Gtk.ComboBoxText();
dropdown.show_all();
users.forEach((u) => {
dropdown.append(null, u.name);
});
const response = <label /> as Widget.Label;
const password = (
<entry
placeholderText="Password"
visibility={false}
setup={(self) => idle(() => {
self.grab_focus();
})}
onActivate={(self) => {
AstalGreet.login(
dropdown.get_active_text() ?? '',
self.text || '',
'Hyprland',
(_, res) => {
try {
AstalGreet.login_finish(res);
}
catch (error) {
response.label = JSON.stringify(error);
}
},
);
}}
/>
);
export default () => (
<window
name="greeter"
keymode={Astal.Keymode.ON_DEMAND}
>
<box
vertical
halign={Gtk.Align.CENTER}
valign={Gtk.Align.CENTER}
hexpand
vexpand
className="base"
>
<box
vertical
halign={Gtk.Align.CENTER}
valign={Gtk.Align.CENTER}
hexpand
vexpand
className="linked"
setup={() => {
idle(() => {
const usernames = users.map((u) => u.name);
if (usernames.includes(DEFAULT_NAME)) {
dropdown.set_active(usernames.indexOf(DEFAULT_NAME));
}
});
}}
>
{dropdown}
{password}
</box>
{response}
</box>
</window>
);

View file

@ -0,0 +1,4 @@
.icon-browser .list row box {
margin: 20px;
font-size: 16px;
}

View file

@ -0,0 +1,46 @@
import { Gtk, Widget } from 'astal/gtk3';
import SortedList from '../misc/sorted-list';
export default () => SortedList({
name: 'icon-browser',
create_list: () => Gtk.IconTheme.get_default().list_icons(null)
.filter((icon) => icon.endsWith('symbolic'))
.sort(),
create_row: (icon) => (
<box>
<icon css="font-size: 60px; margin-right: 25px;" icon={icon} />
<label label={icon} />
</box>
),
on_row_activated: (row) => {
const icon = ((row.get_children()[0] as Widget.Box).get_children()[0] as Widget.Icon).icon;
console.log(icon);
},
sort_func: (a, b, entry, fzfResults) => {
const row1 = ((a.get_children()[0] as Widget.Box).get_children()[0] as Widget.Icon).icon;
const row2 = ((b.get_children()[0] as Widget.Box).get_children()[0] as Widget.Icon).icon;
if (entry.text === '' || entry.text === '-') {
a.set_visible(true);
b.set_visible(true);
return row1.charCodeAt(0) - row2.charCodeAt(0);
}
else {
const s1 = fzfResults.find((r) => r.item === row1)?.score ?? 0;
const s2 = fzfResults.find((r) => r.item === row2)?.score ?? 0;
a.set_visible(s1 !== 0);
b.set_visible(s2 !== 0);
return s2 - s1;
}
},
});

View file

@ -0,0 +1,4 @@
.lock-clock {
font-size: 80pt;
font-family: 'Ubuntu Mono';
}

View file

@ -0,0 +1,242 @@
import { bind, idle, timeout, Variable } from 'astal';
import { App, Astal, Gdk, Gtk, Widget } from 'astal/gtk3';
import { register } from 'astal/gobject';
import AstalAuth from 'gi://AstalAuth';
import Lock from 'gi://GtkSessionLock';
import Separator from '../misc/separator';
import { get_hyprland_monitor_desc } from '../../lib';
// This file is generated by Nix
import Vars from './vars';
/* Types */
declare global {
function authFinger(): void;
}
@register()
class BlurredBox extends Widget.Box {
geometry = {} as { w: number, h: number };
}
const windows = new Map<Gdk.Monitor, Gtk.Window>();
const blurBGs: BlurredBox[] = [];
const transition_duration = 1000;
const WINDOW_MARGINS = -2;
const ENTRY_SPACING = 20;
const CLOCK_SPACING = 60;
const bgCSS = ({ w = 1, h = 1 } = {}) => `
border: 2px solid rgba(189, 147, 249, 0.8);
background: rgba(0, 0, 0, 0.2);
min-height: ${h}px;
min-width: ${w}px;
transition: min-height ${transition_duration / 2}ms,
min-width ${transition_duration / 2}ms;
`;
const lock = Lock.prepare_lock();
const unlock = () => {
blurBGs.forEach((b) => {
b.css = bgCSS({
w: b.geometry.w,
h: 1,
});
timeout(transition_duration / 2, () => {
b.css = bgCSS({
w: 1,
h: 1,
});
});
});
timeout(transition_duration, () => {
lock.unlock_and_destroy();
Gdk.Display.get_default()?.sync();
App.quit();
});
};
const Clock = () => {
const time = Variable<string>('').poll(1000, () => {
return (new Date().toLocaleString([], {
hour: 'numeric',
minute: 'numeric',
hour12: true,
}) ?? '')
.replace('a.m.', 'AM')
.replace('p.m.', 'PM');
});
return (
<label
className="lock-clock"
label={bind(time)}
/>
);
};
const PasswordPrompt = (monitor: Gdk.Monitor, visible: boolean) => {
const rev = new BlurredBox({ css: bgCSS() });
idle(() => {
rev.geometry = {
w: monitor.geometry.width,
h: monitor.geometry.height,
};
rev.css = bgCSS({
w: rev.geometry.w,
h: 1,
});
timeout(transition_duration / 2, () => {
rev.css = bgCSS({
w: rev.geometry.w,
h: rev.geometry.h,
});
});
});
blurBGs.push(rev);
<window
name={`blur-bg-${monitor.get_model()}`}
namespace={`blur-bg-${monitor.get_model()}`}
gdkmonitor={monitor}
layer={Astal.Layer.OVERLAY}
anchor={
Astal.WindowAnchor.TOP |
Astal.WindowAnchor.LEFT |
Astal.WindowAnchor.RIGHT |
Astal.WindowAnchor.BOTTOM
}
margin={WINDOW_MARGINS}
exclusivity={Astal.Exclusivity.IGNORE}
>
<box
halign={Gtk.Align.CENTER}
valign={Gtk.Align.CENTER}
>
{rev}
</box>
</window>;
const label = <label label="Enter password:" /> as Widget.Label;
return new Gtk.Window({
child: visible ?
(
<box
vertical
halign={Gtk.Align.CENTER}
valign={Gtk.Align.CENTER}
spacing={16}
>
<Clock />
<Separator size={CLOCK_SPACING} vertical />
<box
halign={Gtk.Align.CENTER}
className="avatar"
/>
<box
className="entry-box"
vertical
>
{label}
<Separator size={ENTRY_SPACING} vertical />
<entry
halign={Gtk.Align.CENTER}
xalign={0.5}
visibility={false}
placeholder_text="password"
onRealize={(self) => self.grab_focus()}
onActivate={(self) => {
self.sensitive = false;
AstalAuth.Pam.authenticate(self.text ?? '', (_, task) => {
try {
AstalAuth.Pam.authenticate_finish(task);
unlock();
}
catch (e) {
self.text = '';
label.label = (e as Error).message;
self.sensitive = true;
}
});
}}
/>
</box>
</box>
) :
<box />,
});
};
const createWindow = (monitor: Gdk.Monitor) => {
const hyprDesc = get_hyprland_monitor_desc(monitor);
const entryVisible = Vars.mainMonitor === hyprDesc || Vars.dupeLockscreen;
const win = PasswordPrompt(monitor, entryVisible);
windows.set(monitor, win);
};
const lock_screen = () => {
const display = Gdk.Display.get_default();
for (let m = 0; m < (display?.get_n_monitors() ?? 0); m++) {
const monitor = display?.get_monitor(m);
if (monitor) {
createWindow(monitor);
}
}
display?.connect('monitor-added', (_, monitor) => {
createWindow(monitor);
});
lock.lock_lock();
windows.forEach((win, monitor) => {
lock.new_surface(win, monitor);
win.show();
});
};
const on_finished = () => {
lock.destroy();
Gdk.Display.get_default()?.sync();
App.quit();
};
lock.connect('finished', on_finished);
if (Vars.hasFprintd) {
globalThis.authFinger = () => AstalAuth.Pam.authenticate('', (_, task) => {
try {
AstalAuth.Pam.authenticate_finish(task);
unlock();
}
catch (e) {
console.error((e as Error).message);
}
});
globalThis.authFinger();
}
export default () => {
lock_screen();
};

View file

@ -0,0 +1,29 @@
@use '../../style/colors';
.sorted-list {
.search {
icon {
font-size: 20px;
min-width: 40px;
min-height: 40px
}
// entry {}
}
.list {
row {
border-radius: 10px;
&:hover, &:selected {
icon {
-gtk-icon-shadow: 2px 2px colors.$accent_color;
}
}
}
.placeholder {
font-size: 20px;
}
}
}

View file

@ -0,0 +1,111 @@
import { App, Astal, Gtk, Widget } from 'astal/gtk3';
import { property, register } from 'astal/gobject';
import { Binding, idle } from 'astal';
import { get_hyprland_monitor, hyprMessage } from '../../lib';
/* Types */
type CloseType = 'none' | 'stay' | 'released' | 'clicked';
type HyprTransition = 'slide' | 'slide top' | 'slide bottom' | 'slide left' |
'slide right' | 'popin' | 'fade';
type PopupCallback = (self?: Widget.Window) => void;
export type PopupWindowProps = Widget.WindowProps & {
transition?: HyprTransition | Binding<HyprTransition>
close_on_unfocus?: CloseType | Binding<CloseType>
on_open?: PopupCallback
on_close?: PopupCallback
};
@register()
export class PopupWindow extends Widget.Window {
@property(String)
declare transition: HyprTransition | Binding<HyprTransition>;
@property(String)
declare close_on_unfocus: CloseType | Binding<CloseType>;
on_open: PopupCallback;
on_close: PopupCallback;
constructor({
transition = 'slide top',
close_on_unfocus = 'released',
on_open = () => { /**/ },
on_close = () => { /**/ },
name,
visible = false,
layer = Astal.Layer.OVERLAY,
...rest
}: PopupWindowProps) {
super({
...rest,
name: `win-${name}`,
namespace: `win-${name}`,
visible: false,
layer,
setup: () => idle(() => {
// Add way to make window open on startup
if (visible) {
this.visible = true;
}
}),
});
App.add_window(this);
const setTransition = (_: PopupWindow, t: HyprTransition | Binding<HyprTransition>) => {
hyprMessage(`keyword layerrule animation ${t}, ${this.name}`).catch(console.log);
};
this.connect('notify::transition', setTransition);
this.close_on_unfocus = close_on_unfocus;
this.transition = transition;
this.on_open = on_open;
this.on_close = on_close;
this.connect('notify::visible', () => {
// Make sure we have the right animation
setTransition(this, this.transition);
if (this.visible) {
this.on_open(this);
}
else {
this.on_close(this);
}
});
};
async set_x_pos(
alloc: Gtk.Allocation,
side = 'right' as 'left' | 'right',
) {
const monitor = this.gdkmonitor ??
this.get_display().get_monitor_at_point(alloc.x, alloc.y);
const transform = get_hyprland_monitor(monitor)?.transform;
let width: number;
if (transform && (transform === 1 || transform === 3)) {
width = monitor.get_geometry().height;
}
else {
width = monitor.get_geometry().width;
}
this.margin_right = side === 'right' ?
(width - alloc.x - alloc.width) :
this.margin_right;
this.margin_left = side === 'right' ?
this.margin_left :
(alloc.x - alloc.width);
}
}
export default PopupWindow;

View file

@ -0,0 +1,14 @@
import { Widget } from 'astal/gtk3';
export default ({
size,
vertical = false,
css = '',
...rest
}: { size: number } & Widget.BoxProps) => (
<box
css={`${vertical ? 'min-height' : 'min-width'}: ${size}px; ${css}`}
{...rest}
/>
);

View file

@ -0,0 +1,61 @@
import { bind } from 'astal';
import { Gtk, Widget } from 'astal/gtk3';
import { register, property } from 'astal/gobject';
type SmoothProgressProps = Widget.BoxProps & {
transition_duration?: string
};
// PERF: this is kinda laggy
@register()
class SmoothProgress extends Widget.Box {
@property(Number)
declare fraction: number;
@property(String)
declare transition_duration: string;
constructor({
transition_duration = '1s',
...rest
}: SmoothProgressProps = {}) {
super(rest);
this.transition_duration = transition_duration;
const background = (
<box
className="background"
hexpand
vexpand
halign={Gtk.Align.FILL}
valign={Gtk.Align.FILL}
/>
);
const progress = (
<box
className="progress"
vexpand
valign={Gtk.Align.FILL}
css={bind(this, 'fraction').as((fraction) => {
return `
transition: margin-right ${this.transition_duration} linear;
margin-right: ${
Math.abs(fraction - 1) * background.get_allocated_width()
}px;
`;
})}
/>
);
this.add((
<overlay overlay={progress}>
{background}
</overlay>
));
this.show_all();
}
}
export default SmoothProgress;

View file

@ -0,0 +1,198 @@
// This is definitely not good practice but I couldn't figure out how to extend PopupWindow
// so here we are with a cursed function that returns a prop of the class.
import { Astal, Gtk, Widget } from 'astal/gtk3';
import { idle } from 'astal';
import { AsyncFzf, AsyncFzfOptions, FzfResultItem } from 'fzf';
import PopupWindow from '../misc/popup-window';
import { centerCursor } from '../../lib';
export interface SortedListProps<T> {
create_list: () => T[] | Promise<T[]>
create_row: (item: T) => Gtk.Widget
fzf_options?: AsyncFzfOptions<T>
unique_props?: (keyof T)[]
on_row_activated: (row: Gtk.ListBoxRow) => void
sort_func: (
a: Gtk.ListBoxRow,
b: Gtk.ListBoxRow,
entry: Widget.Entry,
fzf: FzfResultItem<T>[],
) => number
name: string
};
export class SortedList<T> {
private item_list: T[] = [];
private fzf_results: FzfResultItem<T>[] = [];
readonly window: PopupWindow;
private _item_map = new Map<T, Gtk.Widget>();
readonly create_list: () => T[] | Promise<T[]>;
readonly create_row: (item: T) => Gtk.Widget;
readonly fzf_options: AsyncFzfOptions<T>;
readonly unique_props: (keyof T)[] | undefined;
readonly on_row_activated: (row: Gtk.ListBoxRow) => void;
readonly sort_func: (
a: Gtk.ListBoxRow,
b: Gtk.ListBoxRow,
entry: Widget.Entry,
fzf: FzfResultItem<T>[],
) => number;
constructor({
create_list,
create_row,
fzf_options = {} as AsyncFzfOptions<T>,
unique_props,
on_row_activated,
sort_func,
name,
}: SortedListProps<T>) {
const list = new Gtk.ListBox({
selectionMode: Gtk.SelectionMode.SINGLE,
});
list.connect('row-activated', (_, row) => {
this.on_row_activated(row);
});
const placeholder = (
<revealer>
<label
label=" Couldn't find a match"
className="placeholder"
/>
</revealer>
) as Widget.Revealer;
const on_text_change = (text: string) => {
// @ts-expect-error this works
(new AsyncFzf<T[]>(this.item_list, this.fzf_options)).find(text)
.then((out) => {
this.fzf_results = out;
list.invalidate_sort();
const visibleApplications = list.get_children().filter((row) => row.visible).length;
placeholder.reveal_child = visibleApplications <= 0;
})
.catch(() => { /**/ });
};
const entry = (
<entry
onChanged={(self) => on_text_change(self.text)}
hexpand
/>
) as Widget.Entry;
list.set_sort_func((a, b) => {
return this.sort_func(a, b, entry, this.fzf_results);
});
const refreshItems = () => idle(async() => {
// Delete items that don't exist anymore
const new_list = await this.create_list();
[...this._item_map].forEach(([item, widget]) => {
if (!new_list.some((new_item) =>
this.unique_props?.every((prop) => item[prop] === new_item[prop]) ??
item === new_item)) {
widget.get_parent()?.destroy();
this._item_map.delete(item);
}
});
// Add missing items
new_list.forEach((item) => {
if (!this.item_list.some((old_item) =>
this.unique_props?.every((prop) => old_item[prop] === item[prop]) ??
old_item === item)) {
const itemWidget = this.create_row(item);
list.add(itemWidget);
this._item_map.set(item, itemWidget);
}
});
this.item_list = new_list;
list.show_all();
on_text_change('');
});
this.window = (
<PopupWindow
name={name}
keymode={Astal.Keymode.ON_DEMAND}
on_open={() => {
entry.text = '';
refreshItems();
centerCursor();
entry.grab_focus();
}}
>
<box
vertical
className={`${name} sorted-list`}
>
<box className="widget search">
<icon icon="preferences-system-search-symbolic" />
{entry}
<button
css="margin-left: 5px;"
cursor="pointer"
onButtonReleaseEvent={refreshItems}
>
<icon icon="view-refresh-symbolic" css="font-size: 26px;" />
</button>
</box>
<eventbox cursor="pointer">
<scrollable
className="widget list"
css="min-height: 600px; min-width: 700px;"
hscroll={Gtk.PolicyType.NEVER}
vscroll={Gtk.PolicyType.AUTOMATIC}
>
<box vertical>
{list}
{placeholder}
</box>
</scrollable>
</eventbox>
</box>
</PopupWindow>
) as PopupWindow;
this.create_list = create_list;
this.create_row = create_row;
this.fzf_options = fzf_options;
this.unique_props = unique_props;
this.on_row_activated = on_row_activated;
this.sort_func = sort_func;
refreshItems();
}
};
/**
* @param props props for a SortedList Widget
* @returns the widget
*/
export default function<Attr>(props: SortedListProps<Attr>) {
return (new SortedList(props)).window;
}

View file

@ -0,0 +1,133 @@
@use 'sass:color';
@use '../../style/colors';
.notification.widget {
// urgencies
// &.urgency ...
.icon {
margin-right: 10px;
}
.time {
margin: 3px;
}
.close-button {
margin: 3px;
}
.title {
font-weight: 800;
font-size: 20px;
margin-bottom: 5px;
}
.description {
margin-bottom: 5px;
}
.actions {
margin: 3px;
// .action-button {}
}
.smooth-progress {
min-height: 7px;
margin: 3px;
.background {
background-color: color.adjust(colors.$window_bg_color, $lightness: -3%);
border-radius: 3px;
}
.progress {
background-color: colors.$accent-color;
border-radius: 3px;
}
}
}
.notification-center {
margin-top: 0;
min-height: 700px;
min-width: 580px;
* {
font-size: 16px;
}
.header {
padding: 10px;
margin-bottom: 9px;
label {
font-size: 22px;
}
}
.notification-list-box {
padding: 0 12px;
.notification {
box-shadow: none;
}
scrollbar {
all: unset;
border-radius: 8px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
* {
all: unset;
}
&:hover {
border-radius: 8px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
scrollbar.vertical {
transition: 200ms;
background-color: rgba(23, 23, 23, 0.3);
&:hover {
background-color: rgba(23, 23, 23, 0.7);
slider {
background-color: rgba(238, 238, 238, 0.7);
min-width: .6em;
}
}
slider {
background-color: rgba(238, 238, 238, 0.5);
border-radius: 9px;
min-width: .4em;
min-height: 2em;
transition: 200ms;
}
}
}
.placeholder {
color: white;
icon {
font-size: 7em;
text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.6);
-gtk-icon-shadow: 2px 2px 2px rgba(0, 0, 0, 0.6);
}
label {
font-size: 1.2em;
text-shadow: 2px 2px 2px rgba(0, 0, 0, 0.6);
-gtk-icon-shadow: 2px 2px 2px rgba(0, 0, 0, 0.6);
}
}
}

View file

@ -0,0 +1,31 @@
import { Astal } from 'astal/gtk3';
import PopupWindow from '../misc/popup-window';
import { get_gdkmonitor_from_desc } from '../../lib';
import Popups from './popups';
import Center from './center';
export const NotifPopups = () => (
<window
name="notifications"
gdkmonitor={get_gdkmonitor_from_desc('desc:Acer Technologies Acer K212HQL T3EAA0014201')}
namespace="notifications"
layer={Astal.Layer.OVERLAY}
anchor={Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.LEFT}
>
<Popups />
</window>
);
export const NotifCenter = () => (
<PopupWindow
name="notif-center"
gdkmonitor={get_gdkmonitor_from_desc('desc:Acer Technologies Acer K212HQL T3EAA0014201')}
anchor={Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.RIGHT}
transition="slide bottom"
>
<Center />
</PopupWindow>
);

View file

@ -0,0 +1,145 @@
import { bind, timeout } from 'astal';
import { App, Gtk, Widget } from 'astal/gtk3';
import AstalNotifd from 'gi://AstalNotifd';
const Notifications = AstalNotifd.get_default();
import { Notification, HasNotifs } from './notification';
import NotifGestureWrapper from './gesture';
const addNotif = (box: Widget.Box, notifObj: AstalNotifd.Notification) => {
if (notifObj) {
const NewNotif = Notification({
id: notifObj.id,
slide_in_from: 'Right',
});
if (NewNotif) {
box.pack_end(NewNotif, false, false, 0);
box.show_all();
}
}
};
const NotificationList = () => (
<box
vertical
vexpand
valign={Gtk.Align.START}
visible={bind(HasNotifs)}
// It needs to be bigger than the notifs to not jiggle
css="min-width: 550px;"
setup={(self) => {
Notifications.get_notifications().forEach((n) => {
addNotif(self, n);
});
self
.hook(Notifications, 'notified', (_, id) => {
if (id) {
const notifObj = Notifications.get_notification(id);
if (notifObj) {
addNotif(self, notifObj);
}
}
})
.hook(Notifications, 'resolved', (_, id) => {
const notif = (self.get_children() as NotifGestureWrapper[])
.find((ch) => ch.id === id);
if (notif?.sensitive) {
notif.slideAway('Right');
}
});
}}
/>
);
const ClearButton = () => (
<eventbox
cursor={bind(HasNotifs).as((hasNotifs) => hasNotifs ? 'pointer' : 'not-allowed')}
>
<button
className="clear"
sensitive={bind(HasNotifs)}
onButtonReleaseEvent={() => {
Notifications.get_notifications().forEach((notif) => {
notif.dismiss();
});
timeout(1000, () => {
App.get_window('win-notif-center')?.set_visible(false);
});
}}
>
<box>
<label label="Clear " />
<icon icon={bind(Notifications, 'notifications')
.as((notifs) => notifs.length > 0 ?
'user-trash-full-symbolic' :
'user-trash-symbolic')}
/>
</box>
</button>
</eventbox>
);
const Header = () => (
<box className="header">
<label
label="Notifications"
hexpand
xalign={0}
/>
<ClearButton />
</box>
);
const Placeholder = () => (
<revealer
transitionType={Gtk.RevealerTransitionType.CROSSFADE}
revealChild={bind(HasNotifs).as((v) => !v)}
>
<box
className="placeholder"
vertical
valign={Gtk.Align.CENTER}
halign={Gtk.Align.CENTER}
vexpand
hexpand
>
<icon icon="notification-disabled-symbolic" />
<label label="Your inbox is empty" />
</box>
</revealer>
);
export default () => (
<box
className="notification-center widget"
vertical
>
<Header />
<box className="notification-wallpaper-box">
<scrollable
className="notification-list-box"
hscroll={Gtk.PolicyType.NEVER}
vscroll={Gtk.PolicyType.AUTOMATIC}
>
<box
className="notification-list"
vertical
>
<NotificationList />
<Placeholder />
</box>
</scrollable>
</box>
</box>
);

View file

@ -0,0 +1,356 @@
import { Gdk, Gtk, Widget } from 'astal/gtk3';
import { property, register } from 'astal/gobject';
import { idle, interval, timeout } from 'astal';
import AstalIO from 'gi://AstalIO';
import AstalNotifd from 'gi://AstalNotifd';
const Notifications = AstalNotifd.get_default();
import { hyprMessage } from '../../lib';
import { HasNotifs } from './notification';
import { get_hyprland_monitor } from '../../lib';
/* Types */
import { CursorPos, LayerResult } from '../../lib';
const display = Gdk.Display.get_default();
const MAX_OFFSET = 200;
const OFFSCREEN = 300;
const ANIM_DURATION = 500;
const SLIDE_MIN_THRESHOLD = 10;
const TRANSITION = 'transition: margin 0.5s ease, opacity 0.5s ease;';
const MAX_LEFT = `
margin-left: -${Number(MAX_OFFSET + OFFSCREEN)}px;
margin-right: ${Number(MAX_OFFSET + OFFSCREEN)}px;
`;
const MAX_RIGHT = `
margin-left: ${Number(MAX_OFFSET + OFFSCREEN)}px;
margin-right: -${Number(MAX_OFFSET + OFFSCREEN)}px;
`;
const slideLeft = `${TRANSITION} ${MAX_LEFT} opacity: 0;`;
const slideRight = `${TRANSITION} ${MAX_RIGHT} opacity: 0;`;
const defaultStyle = `${TRANSITION} margin: unset; opacity: 1;`;
type NotifGestureWrapperProps = Widget.BoxProps & {
id: number
slide_in_from?: 'Left' | 'Right'
popup_timer?: number
setup_notif?: (self: NotifGestureWrapper) => void
};
@register()
export class NotifGestureWrapper extends Widget.EventBox {
public static popups = new Map<number, NotifGestureWrapper>();
public static sliding_in = 0;
public static on_sliding_in: (amount: number) => void;
readonly id: number;
readonly slide_in_from: 'Left' | 'Right';
readonly is_popup: boolean;
private timer_object: AstalIO.Time | undefined;
@property(Number)
declare popup_timer: number;
@property(Boolean)
declare dragging: boolean;
private _sliding_away = false;
private async get_hovered(): Promise<boolean> {
const layers = JSON.parse(await hyprMessage('j/layers')) as LayerResult;
const cursorPos = JSON.parse(await hyprMessage('j/cursorpos')) as CursorPos;
const win = this.get_window();
if (!win) {
return false;
}
const monitor = display?.get_monitor_at_window(win);
if (!monitor) {
return false;
}
const plugName = get_hyprland_monitor(monitor)?.name;
const notifLayer = layers[plugName ?? '']?.levels['3']
?.find((n) => n.namespace === 'notifications');
if (!notifLayer) {
return false;
}
const index = [...NotifGestureWrapper.popups.keys()]
.sort((a, b) => b - a)
.indexOf(this.id);
const popups = [...NotifGestureWrapper.popups.entries()]
.sort((a, b) => b[0] - a[0])
.map(([key, val]) => [key, val.get_allocated_height()]);
const thisY = notifLayer.y + popups
.map((v) => v[1])
.slice(0, index)
.reduce((prev, curr) => prev + curr, 0);
if (cursorPos.y >= thisY && cursorPos.y <= thisY + (popups[index]?.at(1) ?? 0)) {
if (cursorPos.x >= notifLayer.x &&
cursorPos.x <= notifLayer.x + notifLayer.w) {
return true;
}
}
return false;
}
private setCursor(cursor: string) {
if (!display) {
return;
}
this.window.set_cursor(Gdk.Cursor.new_from_name(
display,
cursor,
));
}
public slideAway(side: 'Left' | 'Right', duplicate = false): void {
if (!this.sensitive || this._sliding_away) {
return;
}
// Make it uninteractable
this.sensitive = false;
this._sliding_away = true;
let rev = this.get_child() as Widget.Revealer | null;
if (!rev) {
return;
}
const revChild = rev.get_child() as Widget.Box | null;
if (!revChild) {
return;
}
revChild.css = side === 'Left' ? slideLeft : slideRight;
timeout(ANIM_DURATION - 100, () => {
rev = this.get_child() as Widget.Revealer | null;
if (!rev) {
return;
}
rev.revealChild = false;
timeout(ANIM_DURATION, () => {
if (!duplicate) {
// Kill notif if specified
if (!this.is_popup) {
Notifications.get_notification(this.id)?.dismiss();
// Update HasNotifs
HasNotifs.set(Notifications.get_notifications().length > 0);
}
else {
// Make sure we cleanup any references to this instance
NotifGestureWrapper.popups.delete(this.id);
}
}
// Get rid of disappeared widget
this.destroy();
});
});
}
constructor({
id,
slide_in_from = 'Left',
popup_timer = 0,
setup_notif = () => { /**/ },
...rest
}: NotifGestureWrapperProps) {
super({
on_button_press_event: () => {
this.setCursor('grabbing');
},
// OnRelease
on_button_release_event: () => {
this.setCursor('grab');
},
// OnHover
on_enter_notify_event: () => {
this.setCursor('grab');
},
// OnHoverLost
on_leave_notify_event: () => {
this.setCursor('grab');
},
onDestroy: () => {
this.timer_object?.cancel();
},
});
this.id = id;
this.slide_in_from = slide_in_from;
this.dragging = false;
this.popup_timer = popup_timer;
this.is_popup = this.popup_timer !== 0;
// Handle timeout before sliding away if it is a popup
if (this.popup_timer !== 0) {
this.timer_object = interval(1000, async() => {
try {
if (!(await this.get_hovered())) {
if (this.popup_timer === 0) {
this.slideAway('Left');
}
else {
--this.popup_timer;
}
}
}
catch (_e) {
this.timer_object?.cancel();
}
});
}
this.hook(Notifications, 'notified', (_, notifId) => {
if (notifId === this.id) {
this.slideAway(this.is_popup ? 'Left' : 'Right', true);
}
});
const gesture = Gtk.GestureDrag.new(this);
this.add(
<revealer
transitionType={Gtk.RevealerTransitionType.SLIDE_DOWN}
transitionDuration={500}
revealChild={false}
>
<box
{...rest}
setup={(self) => {
self
// When dragging
.hook(gesture, 'drag-update', () => {
let offset = gesture.get_offset()[1];
if (!offset || offset === 0) {
return;
}
// Slide right
if (offset > 0) {
self.css = `
opacity: 1; transition: none;
margin-left: ${offset}px;
margin-right: -${offset}px;
`;
}
// Slide left
else {
offset = Math.abs(offset);
self.css = `
opacity: 1; transition: none;
margin-right: ${offset}px;
margin-left: -${offset}px;
`;
}
// Put a threshold on if a click is actually dragging
this.dragging = Math.abs(offset) > SLIDE_MIN_THRESHOLD;
this.setCursor('grabbing');
})
// On drag end
.hook(gesture, 'drag-end', () => {
const offset = gesture.get_offset()[1];
if (!offset) {
return;
}
// If crosses threshold after letting go, slide away
if (Math.abs(offset) > MAX_OFFSET) {
this.slideAway(offset > 0 ? 'Right' : 'Left');
}
else {
self.css = defaultStyle;
this.dragging = false;
this.setCursor('grab');
}
});
if (this.is_popup) {
NotifGestureWrapper.on_sliding_in(++NotifGestureWrapper.sliding_in);
}
// Reverse of slideAway, so it started at squeeze, then we go to slide
self.css = this.slide_in_from === 'Left' ?
slideLeft :
slideRight;
idle(() => {
if (!Notifications.get_notification(id)) {
return;
}
const rev = self?.get_parent() as Widget.Revealer | null;
if (!rev) {
return;
}
rev.revealChild = true;
timeout(ANIM_DURATION, () => {
if (!Notifications.get_notification(id)) {
return;
}
// Then we go to center
self.css = defaultStyle;
if (this.is_popup) {
timeout(ANIM_DURATION, () => {
NotifGestureWrapper.on_sliding_in(
--NotifGestureWrapper.sliding_in,
);
});
}
});
});
}}
/>
</revealer>,
);
setup_notif(this);
}
}
export default NotifGestureWrapper;

View file

@ -0,0 +1,207 @@
import { App, Gtk, Gdk, Widget } from 'astal/gtk3';
import { Variable } from 'astal';
import GLib from 'gi://GLib';
import AstalApps from 'gi://AstalApps';
const Applications = AstalApps.Apps.new();
import AstalNotifd from 'gi://AstalNotifd';
const Notifications = AstalNotifd.get_default();
import NotifGestureWrapper from './gesture';
// import SmoothProgress from '../misc/smooth-progress';
// Make a variable to connect to for Widgets
// to know when there are notifs or not
export const HasNotifs = Variable(false);
const setTime = (time: number): string => GLib.DateTime
.new_from_unix_local(time)
.format('%H:%M') ?? '';
const NotifIcon = ({ notifObj }: {
notifObj: AstalNotifd.Notification
}) => {
let icon: string | undefined;
if (notifObj.get_image() && notifObj.get_image() !== '') {
icon = notifObj.get_image();
App.add_icons(icon);
}
else if (notifObj.get_app_icon() !== '' && Widget.Icon.lookup_icon(notifObj.get_app_icon())) {
icon = notifObj.get_app_icon();
}
else {
icon = Applications.fuzzy_query(
notifObj.get_app_name(),
)[0]?.get_icon_name();
}
return (
<box
valign={Gtk.Align.CENTER}
className="icon"
css={`
min-width: 78px;
min-height: 78px;
`}
>
{icon && (
<icon
icon={icon}
css="font-size: 58px;"
halign={Gtk.Align.CENTER}
hexpand
valign={Gtk.Align.CENTER}
vexpand
/>
)}
</box>
);
};
const setupButton = (self: Gtk.Widget) => {
const display = Gdk.Display.get_default();
// OnHover
self.connect('enter-notify-event', () => {
if (!display) {
return;
}
self.window.set_cursor(Gdk.Cursor.new_from_name(
display,
'pointer',
));
});
// OnHoverLost
self.connect('leave-notify-event', () => {
if (!display) {
return;
}
self.window.set_cursor(null);
});
};
const BlockedApps = [
'Spotify',
];
export const Notification = ({
id = 0,
popup_timer = 0,
slide_in_from = 'Left' as 'Left' | 'Right',
}): NotifGestureWrapper | undefined => {
const notifObj = Notifications.get_notification(id);
if (!notifObj) {
return;
}
if (BlockedApps.find((app) => app === notifObj.app_name)) {
notifObj.dismiss();
return;
}
HasNotifs.set(Notifications.get_notifications().length > 0);
// const progress = SmoothProgress({ className: 'smooth-progress' });
return (
<NotifGestureWrapper
id={id}
popup_timer={popup_timer}
slide_in_from={slide_in_from}
/* setup_notif={(self) => {
if (self.is_popup) {
self.connect('notify::popup-timer', () => {
progress.fraction = self.popup_timer / 5;
});
}
else {
progress.destroy();
}
}}*/
>
<box vertical className={`notification ${notifObj.urgency} widget`}>
{/* Content */}
<box>
<NotifIcon notifObj={notifObj} />
{/* Top of Content */}
<box vertical css="min-width: 400px">
<box>
{/* Title */}
<label
className="title"
halign={Gtk.Align.START}
valign={Gtk.Align.END}
xalign={0}
hexpand
max_width_chars={24}
truncate
wrap
label={notifObj.summary}
use_markup={notifObj.summary.startsWith('<')}
/>
{/* Time */}
<label
className="time"
valign={Gtk.Align.CENTER}
halign={Gtk.Align.END}
label={setTime(notifObj.time)}
/>
{/* Close button */}
<button
className="close-button"
valign={Gtk.Align.START}
halign={Gtk.Align.END}
setup={setupButton}
onButtonReleaseEvent={() => {
notifObj.dismiss();
}}
>
<icon icon="window-close-symbolic" />
</button>
</box>
{/* Description */}
<label
className="description"
hexpand
use_markup
xalign={0}
label={notifObj.body}
wrap
/>
</box>
</box>
{/* progress */}
{/* Actions */}
<box className="actions">
{notifObj.get_actions().map((action) => (
<button
className="action-button"
hexpand
setup={setupButton}
onButtonReleaseEvent={() => notifObj.invoke(action.id)}
>
<label label={action.label} />
</button>
))}
</box>
</box>
</NotifGestureWrapper>
) as NotifGestureWrapper;
};

View file

@ -0,0 +1,65 @@
import AstalNotifd from 'gi://AstalNotifd';
const Notifications = AstalNotifd.get_default();
import NotifGestureWrapper from './gesture';
import { Notification } from './notification';
export default () => (
<box
// Needed so it occupies space at the start
// It needs to be bigger than the notifs to not jiggle
css="min-width: 550px;"
vertical
setup={(self) => {
const notifQueue: number[] = [];
const addPopup = (id: number) => {
if (!id || !Notifications.get_notification(id)) {
return;
}
if (NotifGestureWrapper.sliding_in === 0) {
const NewNotif = Notification({ id, popup_timer: 5 });
if (NewNotif) {
// Use this instead of add to put it at the top
self.pack_end(NewNotif, false, false, 0);
self.show_all();
NotifGestureWrapper.popups.set(id, NewNotif);
}
}
else {
notifQueue.push(id);
}
};
NotifGestureWrapper.on_sliding_in = (n) => {
if (n === 0) {
const id = notifQueue.shift();
if (id) {
addPopup(id);
}
}
};
const handleResolved = (id: number) => {
const notif = NotifGestureWrapper.popups.get(id);
if (!notif) {
return;
}
notif.slideAway('Left');
NotifGestureWrapper.popups.delete(id);
};
self
.hook(Notifications, 'notified', (_, id) => addPopup(id))
.hook(Notifications, 'resolved', (_, id) => handleResolved(id));
}}
/>
);

View file

@ -0,0 +1,27 @@
import { Astal } from 'astal/gtk3';
import PopupWindow from '../misc/popup-window';
import Popups from './popups';
import Center from './center';
export const NotifPopups = () => (
<window
name="notifications"
namespace="notifications"
layer={Astal.Layer.OVERLAY}
anchor={Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT}
>
<Popups />
</window>
);
export const NotifCenter = () => (
<PopupWindow
name="notif-center"
anchor={Astal.WindowAnchor.TOP | Astal.WindowAnchor.RIGHT}
>
<Center />
</PopupWindow>
);

View file

@ -0,0 +1,21 @@
.osd {
.osd-item {
padding: 12px 20px;
label {
min-width: 170px;
}
progressbar {
min-height: 6px;
min-width: 170px;
}
icon {
font-size: 2rem;
color: white;
margin-left: -0.4rem;
margin-right: 0.8rem;
}
}
}

View file

@ -0,0 +1,186 @@
import { bind, timeout } from 'astal';
import { register } from 'astal/gobject';
import { App, Astal, astalify, Gtk, Widget, type ConstructProps } from 'astal/gtk3';
import AstalWp from 'gi://AstalWp';
import PopupWindow from '../misc/popup-window';
import Brightness from '../../services/brightness';
/* Types */
declare global {
function popup_osd(osd: string): void;
}
@register()
class ProgressBar extends astalify(Gtk.ProgressBar) {
constructor(props: ConstructProps<
ProgressBar,
Gtk.ProgressBar.ConstructorProps
>) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
super(props as any);
}
}
const HIDE_DELAY = 2000;
const transition_duration = 300;
export default () => {
let n_showing = 0;
let stack: Widget.Stack | undefined;
const popup = (osd: string) => {
if (!stack) {
return;
}
++n_showing;
stack.shown = osd;
App.get_window('win-osd')?.set_visible(true);
timeout(HIDE_DELAY, () => {
--n_showing;
if (n_showing === 0) {
App.get_window('win-osd')?.set_visible(false);
}
});
};
globalThis.popup_osd = popup;
const speaker = AstalWp.get_default()?.audio.default_speaker;
const microphone = AstalWp.get_default()?.audio.default_microphone;
if (!speaker || !microphone) {
throw new Error('Could not find default audio devices.');
}
return (
<PopupWindow
name="osd"
anchor={Astal.WindowAnchor.BOTTOM}
exclusivity={Astal.Exclusivity.IGNORE}
close_on_unfocus="stay"
transition="slide bottom"
>
<stack
className="osd"
transitionDuration={transition_duration}
setup={(self) => {
timeout(3 * 1000, () => {
stack = self;
});
}}
>
<box
name="speaker"
css="margin-bottom: 80px;"
setup={(self) => {
self.hook(speaker, 'notify::mute', () => {
popup('speaker');
});
}}
>
<box className="osd-item widget">
<icon icon={bind(speaker, 'volumeIcon')} />
<ProgressBar
fraction={bind(speaker, 'volume')}
sensitive={bind(speaker, 'mute').as((v) => !v)}
valign={Gtk.Align.CENTER}
/>
</box>
</box>
<box
name="microphone"
css="margin-bottom: 80px;"
setup={(self) => {
self.hook(microphone, 'notify::mute', () => {
popup('microphone');
});
}}
>
<box className="osd-item widget">
<icon icon={bind(microphone, 'volumeIcon')} />
<ProgressBar
fraction={bind(microphone, 'volume')}
sensitive={bind(microphone, 'mute').as((v) => !v)}
valign={Gtk.Align.CENTER}
/>
</box>
</box>
<box
name="brightness"
css="margin-bottom: 80px;"
setup={(self) => {
self.hook(Brightness, 'notify::screen-icon', () => {
popup('brightness');
});
}}
>
<box className="osd-item widget">
<icon icon={bind(Brightness, 'screenIcon')} />
<ProgressBar
fraction={bind(Brightness, 'screen')}
valign={Gtk.Align.CENTER}
/>
</box>
</box>
{
Brightness.hasKbd && (
<box
name="keyboard"
css="margin-bottom: 80px;"
setup={(self) => {
self.hook(Brightness, 'notify::kbd-level', () => {
popup('keyboard');
});
}}
>
<box className="osd-item widget">
<icon icon="keyboard-brightness-symbolic" />
<ProgressBar
fraction={bind(Brightness, 'kbdLevel').as((v) => (v ?? 0) / 2)}
sensitive={bind(Brightness, 'kbdLevel').as((v) => v !== 0)}
valign={Gtk.Align.CENTER}
/>
</box>
</box>
)
}
<box
name="caps"
css="margin-bottom: 80px;"
setup={(self) => {
self.hook(Brightness, 'notify::caps-icon', () => {
popup('caps');
});
}}
>
<box className="osd-item widget">
<icon icon={bind(Brightness, 'capsIcon')} />
<label label="Caps Lock" />
</box>
</box>
</stack>
</PopupWindow>
);
};

View file

@ -0,0 +1,32 @@
@use 'sass:color';
@use '../../style/colors';
.powermenu {
font-size: 70px;
padding: 10px;
icon {
min-width: 130px;
min-height: 130px;
}
button {
margin: 5px 10px;
transition: all ease .2s;
&:hover,
&:active {
background-color: color.adjust(colors.$window_bg_color, $lightness: 3%);
}
}
.shutdown {
color: colors.$red_1;
}
.reboot {
color: colors.$purple_1;
}
.logout {
color: colors.$yellow_1;
}
}

View file

@ -0,0 +1,46 @@
import { execAsync } from 'astal';
import { Astal } from 'astal/gtk3';
import { hyprMessage } from '../../lib';
import PopupWindow from '../misc/popup-window';
const PowermenuWidget = () => (
<centerbox className="powermenu widget">
<button
className="shutdown button"
cursor="pointer"
onButtonReleaseEvent={() => execAsync(['systemctl', 'poweroff']).catch(print)}
>
<icon icon="system-shutdown-symbolic" />
</button>
<button
className="reboot button"
cursor="pointer"
onButtonReleaseEvent={() => execAsync(['systemctl', 'reboot']).catch(print)}
>
<icon icon="system-restart-symbolic" />
</button>
<button
className="logout button"
cursor="pointer"
onButtonReleaseEvent={() => hyprMessage('dispatch exit').catch(print)}
>
<icon icon="system-log-out-symbolic" />
</button>
</centerbox>
);
export default () => (
<PopupWindow
name="powermenu"
transition="slide bottom"
// To put it at the center of the screen
exclusivity={Astal.Exclusivity.IGNORE}
>
<PowermenuWidget />
</PopupWindow>
);

View file

@ -0,0 +1,27 @@
@use '../../style/colors';
.screenshot {
font-size: 30px;
.header {
.header-btn {
margin: 5px;
transition: background 400ms;
&.active {
background: colors.$window_bg_color;
}
}
}
scrollable {
margin: 5px;
min-height: 400px;
box {
.item-btn {
margin: 3px;
}
}
}
}

View file

@ -0,0 +1,163 @@
import { bind, execAsync, Variable } from 'astal';
import { App, Gtk, Widget } from 'astal/gtk3';
import AstalApps from 'gi://AstalApps';
const Applications = AstalApps.Apps.new();
import AstalHyprland from 'gi://AstalHyprland';
const Hyprland = AstalHyprland.get_default();
import PopupWindow from '../misc/popup-window';
import Separator from '../misc/separator';
import { hyprMessage } from '../../lib';
const ICON_SEP = 6;
const takeScreenshot = (selector: string, delay = 1000): void => {
App.get_window('win-screenshot')?.set_visible(false);
setTimeout(() => {
execAsync([
'bash',
'-c',
`grim ${selector} - | satty -f - || true`,
]).catch(console.error);
}, delay);
};
export default () => {
const windowList = <box vertical /> as Widget.Box;
const updateWindows = async() => {
if (!App.get_window('win-screenshot')?.visible) {
return;
}
windowList.children = (JSON.parse(await hyprMessage('j/clients')) as AstalHyprland.Client[])
.filter((client) => client.workspace.id === Hyprland.get_focused_workspace().get_id())
.map((client) => (
<button
className="item-btn"
cursor="pointer"
onButtonReleaseEvent={() => {
takeScreenshot(`-w ${client.address}`);
}}
>
<box halign={Gtk.Align.CENTER}>
<icon icon={Applications.fuzzy_query(client.class)[0].iconName} />
<Separator size={ICON_SEP} />
<label
label={client.title}
truncate
max_width_chars={50}
/>
</box>
</button>
));
};
Hyprland.connect('notify::clients', updateWindows);
Hyprland.connect('notify::focused-workspace', updateWindows);
const Shown = Variable<string>('monitors');
const stack = (
<stack
shown={bind(Shown)}
transitionType={Gtk.StackTransitionType.SLIDE_LEFT_RIGHT}
>
<scrollable name="monitors">
<box vertical>
{bind(Hyprland, 'monitors').as((monitors) => monitors.map((monitor) => (
<button
className="item-btn"
cursor="pointer"
onButtonReleaseEvent={() => {
takeScreenshot(`-o ${monitor.name}`);
}}
>
<label
label={`${monitor.name}: ${monitor.description}`}
truncate
maxWidthChars={50}
/>
</button>
)))}
</box>
</scrollable>
<scrollable name="windows">
{windowList}
</scrollable>
</stack>
) as Widget.Stack;
const StackButton = ({ label = '', iconName = '' }) => (
<button
cursor="pointer"
className={bind(Shown).as((shown) =>
`header-btn${shown === label ? ' active' : ''}`)}
onButtonReleaseEvent={() => {
Shown.set(label);
}}
>
<box halign={Gtk.Align.CENTER}>
<icon icon={iconName} />
<Separator size={ICON_SEP} />
{label}
</box>
</button>
) as Widget.Button;
const regionButton = (
<button
cursor="pointer"
className="header-btn"
onButtonReleaseEvent={() => {
takeScreenshot('-g "$(slurp)"', 0);
}}
>
<box halign={Gtk.Align.CENTER}>
<icon icon="tool-pencil-symbolic" />
<Separator size={ICON_SEP} />
region
</box>
</button>
) as Widget.Button;
return (
<PopupWindow
name="screenshot"
on_open={() => {
updateWindows();
}}
>
<box
className="screenshot widget"
vertical
>
<box
className="header"
homogeneous
>
<StackButton label="monitors" iconName="display-symbolic" />
<StackButton label="windows" iconName="window-symbolic" />
{regionButton}
</box>
{stack}
</box>
</PopupWindow>
);
};