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 @@
use flake "$FLAKE#node"

3
nixosModules/ags/config/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
@girs
node_modules
tsconfig.json

View file

@ -0,0 +1,10 @@
// TODO: persisting data like bluetooth
// TODO: quick-settings
// TODO: music player stuff
// TODO: on-screen-keyboard
// TODO: GSR
import GLib from 'gi://GLib';
(await import(`./configurations/${GLib.getenv('CONF')}.ts`)).default();

View file

@ -0,0 +1,67 @@
export default async() => {
const { execAsync } = await import('astal');
const { App } = await import('astal/gtk3');
const style = (await import('../style/main.scss')).default;
const AppLauncher = (await import('../widgets/applauncher/main')).default;
const Bar = (await import('../widgets/bar/binto')).default;
const Calendar = (await import('../widgets/date/binto')).default;
const Clipboard = (await import('../widgets/clipboard/main')).default;
const { NotifPopups, NotifCenter } = await import('../widgets/notifs/binto');
const OSD = (await import('../widgets/osd/main')).default;
const PowerMenu = (await import('../widgets/powermenu/main')).default;
const Screenshot = (await import('../widgets/screenshot/main')).default;
const { closeAll } = await import('../lib');
const Brightness = (await import('../services/brightness')).default;
const GSR = (await import('../services/gpu-screen-recorder')).default;
const MonitorClicks = (await import('../services/monitor-clicks')).default;
App.start({
css: style,
requestHandler(request, respond) {
if (request.startsWith('open')) {
App.get_window(request.replace('open ', ''))?.set_visible(true);
respond('window opened');
}
else if (request.startsWith('closeAll')) {
closeAll();
respond('closed all windows');
}
else if (request.startsWith('fetchCapsState')) {
Brightness.fetchCapsState();
respond('fetched caps_lock state');
}
else if (request.startsWith('popup')) {
popup_osd(request.replace('popup ', ''));
respond('osd popped up');
}
else if (request.startsWith('save-replay')) {
GSR.saveReplay();
respond('saving replay');
}
},
main: () => {
execAsync('hyprpaper').catch(() => { /**/ });
AppLauncher();
Bar();
Calendar();
Clipboard();
NotifPopups();
NotifCenter();
OSD();
PowerMenu();
Screenshot();
Brightness.initService({
caps: 'input2::capslock',
});
new MonitorClicks();
},
});
};

View file

@ -0,0 +1,20 @@
export default async() => {
const { execAsync } = await import('astal');
const { App } = await import('astal/gtk3');
const Greeter = (await import('../widgets/greeter/main')).default;
const style = (await import('../style/greeter.scss')).default;
App.start({
css: style,
instanceName: 'greeter',
main: () => {
execAsync('hyprpaper').catch(() => { /**/ });
Greeter();
},
});
};

View file

@ -0,0 +1,21 @@
export default async() => {
const { App } = await import('astal/gtk3');
const Lockscreen = (await import('../widgets/lockscreen/main')).default;
const style = (await import('../style/lock.scss')).default;
App.start({
css: style,
instanceName: 'lock',
requestHandler(js, res) {
App.eval(js).then(res).catch(res);
},
main: () => {
Lockscreen();
},
});
};

View file

@ -0,0 +1,77 @@
export default async() => {
const { execAsync } = await import('astal');
const { App } = await import('astal/gtk3');
const style = (await import('../style/main.scss')).default;
const AppLauncher = (await import('../widgets/applauncher/main')).default;
const Bar = (await import('../widgets/bar/wim')).default;
const BgFade = (await import('../widgets/bg-fade/main')).default;
const Calendar = (await import('../widgets/date/wim')).default;
const Clipboard = (await import('../widgets/clipboard/main')).default;
const Corners = (await import('../widgets/corners/main')).default;
const IconBrowser = (await import('../widgets/icon-browser/main')).default;
const { NotifPopups, NotifCenter } = await import('../widgets/notifs/wim');
const OSD = (await import('../widgets/osd/main')).default;
const PowerMenu = (await import('../widgets/powermenu/main')).default;
const Screenshot = (await import('../widgets/screenshot/main')).default;
const { closeAll } = await import('../lib');
const Brightness = (await import('../services/brightness')).default;
const MonitorClicks = (await import('../services/monitor-clicks')).default;
App.start({
css: style,
requestHandler(request, respond) {
if (request.startsWith('open')) {
App.get_window(request.replace('open ', ''))?.set_visible(true);
respond('window opened');
}
else if (request.startsWith('closeAll')) {
closeAll();
respond('closed all windows');
}
else if (request.startsWith('fetchCapsState')) {
Brightness.fetchCapsState();
respond('fetched caps_lock state');
}
else if (request.startsWith('Brightness.screen')) {
Brightness.screen += parseFloat(request.replace('Brightness.screen ', ''));
respond('screen brightness changed');
}
else if (request.startsWith('popup')) {
popup_osd(request.replace('popup ', ''));
respond('osd popped up');
}
else if (request.startsWith('osk')) {
console.log(`TODO: ${request.replace('osk ', '')}`);
respond('implement this');
}
},
main: () => {
execAsync('hyprpaper').catch(() => { /**/ });
AppLauncher();
Bar();
BgFade();
Calendar();
Clipboard();
Corners();
IconBrowser();
NotifPopups();
NotifCenter();
OSD();
PowerMenu();
Screenshot();
Brightness.initService({
kbd: 'tpacpi::kbd_backlight',
caps: 'input1::capslock',
});
new MonitorClicks();
},
});
};

27
nixosModules/ags/config/env.d.ts vendored Normal file
View file

@ -0,0 +1,27 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
const SRC: string;
declare module 'inline:*' {
const content: string;
export default content;
}
declare module '*.sass' {
const content: string;
export default content;
}
declare module '*.scss' {
const content: string;
export default content;
}
declare module '*.css' {
const content: string;
export default content;
}

View file

@ -0,0 +1,454 @@
import eslint from '@eslint/js';
import jsdoc from 'eslint-plugin-jsdoc';
import stylistic from '@stylistic/eslint-plugin';
import tseslint from 'typescript-eslint';
export default tseslint.config({
files: ['**/*.{js,ts,tsx}'],
ignores: ['node_modules/**', 'types/**'],
extends: [
eslint.configs.recommended,
jsdoc.configs['flat/recommended-typescript'],
stylistic.configs['recommended-flat'],
...tseslint.configs.recommended,
...tseslint.configs.stylistic,
],
rules: {
// JSDoc settings
'jsdoc/tag-lines': ['warn', 'any', { startLines: 1 }],
'jsdoc/check-line-alignment': ['warn', 'always', {
tags: ['param', 'arg', 'argument', 'property', 'prop'],
}],
'jsdoc/no-types': 'off',
// Newer settings
'@typescript-eslint/no-extraneous-class': ['off'],
'@typescript-eslint/no-implied-eval': ['off'],
'class-methods-use-this': 'off',
'@stylistic/no-multiple-empty-lines': 'off',
'@stylistic/jsx-indent-props': 'off',
'no-use-before-define': 'off',
'@typescript-eslint/no-use-before-define': 'error',
'@stylistic/indent-binary-ops': 'off',
'@stylistic/max-statements-per-line': [
'error',
{ max: 2 },
],
// Pre-flat config
'@typescript-eslint/no-unused-vars': [
'error',
{
args: 'all',
argsIgnorePattern: '^_',
caughtErrors: 'all',
caughtErrorsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true,
},
],
'array-callback-return': [
'error',
{
allowImplicit: true,
checkForEach: true,
},
],
'no-constructor-return': [
'error',
],
'no-unreachable-loop': [
'error',
{
ignore: [
'ForInStatement',
'ForOfStatement',
],
},
],
'block-scoped-var': [
'error',
],
'curly': [
'warn',
],
'default-case-last': [
'warn',
],
'default-param-last': [
'error',
],
'eqeqeq': [
'error',
'smart',
],
'func-names': [
'warn',
'never',
],
'func-style': [
'warn',
'expression',
],
'logical-assignment-operators': [
'warn',
'always',
],
'no-array-constructor': [
'error',
],
'no-empty-function': [
'warn',
],
'no-empty-static-block': [
'warn',
],
'no-extend-native': [
'error',
],
'no-extra-bind': [
'warn',
],
'no-implicit-coercion': [
'warn',
],
'no-iterator': [
'error',
],
'no-labels': [
'error',
],
'no-lone-blocks': [
'error',
],
'no-lonely-if': [
'error',
],
'no-loop-func': [
'error',
],
'no-magic-numbers': [
'error',
{
ignore: [
-1,
0.1,
0,
1,
2,
3,
4,
5,
10,
12,
33,
66,
100,
255,
360,
450,
500,
1000,
],
ignoreDefaultValues: true,
ignoreClassFieldInitialValues: true,
},
],
'no-multi-assign': [
'error',
],
'no-new-wrappers': [
'error',
],
'no-object-constructor': [
'error',
],
'no-proto': [
'error',
],
'no-return-assign': [
'error',
],
'no-sequences': [
'error',
],
'no-shadow': [
'error',
{
builtinGlobals: true,
allow: [
'Window',
],
},
],
'no-undef-init': [
'warn',
],
'no-undefined': [
'error',
],
'no-useless-constructor': [
'warn',
],
'no-useless-escape': [
'off',
],
'no-useless-return': [
'error',
],
'no-var': [
'error',
],
'no-void': [
'off',
],
'no-with': [
'error',
],
'object-shorthand': [
'error',
'always',
],
'one-var': [
'error',
'never',
],
'operator-assignment': [
'warn',
'always',
],
'prefer-arrow-callback': [
'error',
],
'prefer-const': [
'error',
],
'prefer-object-has-own': [
'error',
],
'prefer-regex-literals': [
'error',
],
'prefer-template': [
'warn',
],
'no-prototype-builtins': 'off',
'@typescript-eslint/no-var-requires': [
'off',
],
'@stylistic/array-bracket-newline': [
'warn',
'consistent',
],
'@stylistic/array-bracket-spacing': [
'warn',
'never',
],
'@stylistic/arrow-parens': [
'warn',
'always',
],
'@stylistic/brace-style': [
'warn',
'stroustrup',
{ allowSingleLine: true },
],
'@stylistic/comma-dangle': [
'warn',
'always-multiline',
],
'@stylistic/comma-spacing': [
'warn',
{
before: false,
after: true,
},
],
'@stylistic/comma-style': [
'error',
'last',
],
'@stylistic/dot-location': [
'error',
'property',
],
'@stylistic/function-call-argument-newline': [
'warn',
'consistent',
],
'@stylistic/function-paren-newline': [
'warn',
'consistent',
],
'@stylistic/indent': [
'warn',
4,
{
SwitchCase: 1,
ignoreComments: true,
ignoredNodes: ['TemplateLiteral > *'],
},
],
'@stylistic/key-spacing': [
'warn',
{
beforeColon: false,
afterColon: true,
},
],
'@stylistic/keyword-spacing': [
'warn',
{
before: true,
},
],
'@stylistic/linebreak-style': [
'error',
'unix',
],
'@stylistic/lines-between-class-members': [
'warn',
'always',
{
exceptAfterSingleLine: true,
},
],
'@stylistic/max-len': [
'warn',
{
code: 105,
ignoreComments: true,
ignoreTrailingComments: true,
ignoreUrls: true,
},
],
'@stylistic/multiline-ternary': [
'warn',
'always-multiline',
],
'@stylistic/new-parens': [
'error',
],
'@stylistic/no-mixed-operators': [
'warn',
],
'@stylistic/no-mixed-spaces-and-tabs': [
'error',
],
'@stylistic/no-multi-spaces': [
'error',
],
'@stylistic/no-tabs': [
'error',
],
'@stylistic/no-trailing-spaces': [
'error',
],
'@stylistic/no-whitespace-before-property': [
'warn',
],
'@stylistic/nonblock-statement-body-position': [
'error',
'below',
],
'@stylistic/object-curly-newline': [
'warn',
{
consistent: true,
},
],
'@stylistic/object-curly-spacing': [
'warn',
'always',
],
'@stylistic/operator-linebreak': [
'warn',
'after',
],
'@stylistic/padded-blocks': [
'error',
'never',
],
'@stylistic/padding-line-between-statements': [
'warn',
{
blankLine: 'always',
prev: '*',
next: 'return',
},
{
blankLine: 'always',
prev: [
'const',
'let',
'var',
],
next: '*',
},
{
blankLine: 'any',
prev: [
'const',
'let',
'var',
],
next: [
'const',
'let',
'var',
],
},
{
blankLine: 'always',
prev: [
'case',
'default',
],
next: '*',
},
],
'@stylistic/quote-props': [
'error',
'consistent-as-needed',
],
'@stylistic/quotes': [
'error',
'single',
{
avoidEscape: true,
},
],
'@stylistic/semi': [
'error',
'always',
],
'@stylistic/semi-spacing': [
'warn',
],
'@stylistic/space-before-blocks': [
'warn',
],
'@stylistic/space-before-function-paren': [
'warn',
'never',
],
'@stylistic/space-infix-ops': [
'warn',
],
'@stylistic/spaced-comment': [
'warn',
'always',
],
'@stylistic/switch-colon-spacing': [
'warn',
],
'@stylistic/wrap-regex': [
'warn',
],
},
});

View file

@ -0,0 +1,121 @@
import { App, Gdk } from 'astal/gtk3';
import AstalHyprland from 'gi://AstalHyprland';
const Hyprland = AstalHyprland.get_default();
/* Types */
import PopupWindow from './widgets/misc/popup-window';
export interface Layer {
address: string
x: number
y: number
w: number
h: number
namespace: string
}
export interface Levels {
0?: Layer[] | null
1?: Layer[] | null
2?: Layer[] | null
3?: Layer[] | null
}
export interface Layers {
levels: Levels
}
export type LayerResult = Record<string, Layers>;
export interface CursorPos {
x: number
y: number
}
export const get_hyprland_monitor = (monitor: Gdk.Monitor): AstalHyprland.Monitor | undefined => {
const manufacturer = monitor.manufacturer?.replace(',', '');
const model = monitor.model?.replace(',', '');
const start = `${manufacturer} ${model}`;
return Hyprland.get_monitors().find((m) => m.description?.startsWith(start));
};
export const get_hyprland_monitor_desc = (monitor: Gdk.Monitor): string => {
const manufacturer = monitor.manufacturer?.replace(',', '');
const model = monitor.model?.replace(',', '');
const start = `${manufacturer} ${model}`;
return `desc:${Hyprland.get_monitors().find((m) => m.description?.startsWith(start))?.description}`;
};
export const get_gdkmonitor_from_desc = (desc: string): Gdk.Monitor => {
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 && desc === get_hyprland_monitor_desc(monitor)) {
return monitor;
}
}
throw Error(`Monitor ${desc} not found`);
};
export const get_monitor_desc = (mon: AstalHyprland.Monitor): string => {
return `desc:${mon.description}`;
};
export const hyprMessage = (message: string) => new Promise<string>((
resolution = () => { /**/ },
rejection = () => { /**/ },
) => {
try {
Hyprland.message_async(message, (_, asyncResult) => {
const result = Hyprland.message_finish(asyncResult);
resolution(result);
});
}
catch (e) {
rejection(e);
}
});
export const centerCursor = (): void => {
let x: number;
let y: number;
const monitor = Hyprland.get_monitors().find((m) => m.focused) as AstalHyprland.Monitor;
switch (monitor.transform) {
case 1:
x = monitor.x - (monitor.height / 2);
y = monitor.y - (monitor.width / 2);
break;
case 2:
x = monitor.x - (monitor.width / 2);
y = monitor.y - (monitor.height / 2);
break;
case 3:
x = monitor.x + (monitor.height / 2);
y = monitor.y + (monitor.width / 2);
break;
default:
x = monitor.x + (monitor.width / 2);
y = monitor.y + (monitor.height / 2);
break;
}
hyprMessage(`dispatch movecursor ${x} ${y}`);
};
export const closeAll = () => {
(App.get_windows() as PopupWindow[])
.filter((w) => w &&
w.close_on_unfocus &&
w.close_on_unfocus !== 'stay')
.forEach((w) => {
App.get_window(w.name)?.set_visible(false);
});
};

1735
nixosModules/ags/config/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
{
"name": "ags",
"version": "0.0.0",
"main": "app.ts",
"dependencies": {
"@eslint/js": "9.14.0",
"@stylistic/eslint-plugin": "2.10.1",
"@types/node": "22.9.0",
"eslint": "9.14.0",
"eslint-plugin-jsdoc": "50.5.0",
"fzf": "0.5.2",
"jiti": "2.4.0",
"typescript-eslint": "8.14.0"
}
}

View file

@ -0,0 +1,174 @@
import { execAsync, interval } from 'astal';
import GObject, { register, property } from 'astal/gobject';
const SCREEN_ICONS: Record<number, string> = {
90: 'display-brightness-high-symbolic',
70: 'display-brightness-medium-symbolic',
20: 'display-brightness-low-symbolic',
5: 'display-brightness-off-symbolic',
};
const INTERVAL = 500;
@register()
class Brightness extends GObject.Object {
declare private _kbd: string | undefined;
declare private _caps: string | undefined;
declare private _screen: number;
@property(Number)
get screen() {
return this._screen;
};
set screen(percent) {
if (percent < 0) {
percent = 0;
}
if (percent > 1) {
percent = 1;
}
percent = parseFloat(percent.toFixed(2));
execAsync(`brightnessctl s ${percent * 100}% -q`)
.then(() => {
this._screen = percent;
this.notify('screen');
this._getScreenIcon();
})
.catch(console.error);
}
private _screenIcon = 'display-brightness-high-symbolic';
@property(String)
get screenIcon() {
return this._screenIcon;
}
public hasKbd = false;
declare private _kbdMax: number | undefined;
declare private _kbdLevel: number | undefined;
@property(Number)
get kbdLevel() {
if (!this._kbdMax) {
console.error('[get kbdLevel] No Keyboard brightness');
return -1;
}
return this._kbdLevel;
}
set kbdLevel(value) {
if (!this._kbdMax || !value) {
console.error('[set kbdLevel] No Keyboard brightness');
return;
}
if (value < 0 || value > this._kbdMax) {
return;
}
execAsync(`brightnessctl -d ${this._kbd} s ${value} -q`)
.then(() => {
this._kbdLevel = value;
this.notify('kbd-level');
})
.catch(console.error);
}
declare private _capsLevel: number;
@property(Number)
get capsLevel() {
return this._capsLevel;
}
private _capsIcon = 'caps-lock-symbolic';
@property(String)
get capsIcon() {
return this._capsIcon;
}
/**
* This is to basically have the constructor run when I want and
* still export this to wherever I need to.
*
* @param o params
* @param o.kbd name of kbd in brightnessctl
* @param o.caps name of caps_lock in brightnessctl
*/
public async initService({ kbd, caps }: { kbd?: string, caps?: string }) {
try {
if (kbd) {
this.hasKbd = true;
this._kbd = kbd;
this._monitorKbdState();
this._kbdMax = Number(await execAsync(`brightnessctl -d ${this._kbd} m`));
}
this._caps = caps;
this._screen = Number(await execAsync('brightnessctl g')) /
Number(await execAsync('brightnessctl m'));
this.notify('screen');
}
catch (_e) {
console.error('missing dependency: brightnessctl');
}
}
private _getScreenIcon() {
const brightness = this._screen * 100;
// eslint-disable-next-line
for (const threshold of [4, 19, 69, 89]) {
if (brightness > threshold + 1) {
this._screenIcon = SCREEN_ICONS[threshold + 1];
this.notify('screen-icon');
}
}
}
private _monitorKbdState() {
const timer = interval(INTERVAL, () => {
execAsync(`brightnessctl -d ${this._kbd} g`)
.then(
(out) => {
if (parseInt(out) !== this._kbdLevel) {
this._kbdLevel = parseInt(out);
this.notify('kbd-level');
}
},
)
.catch(() => {
timer?.cancel();
});
});
}
public fetchCapsState() {
execAsync(`brightnessctl -d ${this._caps} g`)
.then((out) => {
this._capsLevel = Number(out);
this._capsIcon = this._capsLevel ?
'caps-lock-symbolic' :
'capslock-disabled-symbolic';
this.notify('caps-icon');
this.notify('caps-level');
})
.catch(logError);
}
}
const brightnessService = new Brightness();
export default brightnessService;

View file

@ -0,0 +1,158 @@
import { execAsync, subprocess } from 'astal';
import GObject, { register } from 'astal/gobject';
/* Types */
interface NotifyAction {
id: string
label: string
callback: () => void
}
interface NotifySendProps {
actions?: NotifyAction[]
appName?: string
body?: string
category?: string
hint?: string
iconName: string
replaceId?: number
title: string
urgency?: 'low' | 'normal' | 'critical'
}
const APP_NAME = 'gpu-screen-recorder';
const ICON_NAME = 'nvidia';
const escapeShellArg = (arg: string): string => `'${arg.replace(/'/g, '\'\\\'\'')}'`;
const notifySend = ({
actions = [],
appName,
body,
category,
hint,
iconName,
replaceId,
title,
urgency = 'normal',
}: NotifySendProps) => new Promise<number>((resolve) => {
let printedId = false;
const cmd = [
'notify-send',
'--print-id',
`--icon=${escapeShellArg(iconName)}`,
escapeShellArg(title),
escapeShellArg(body ?? ''),
// Optional params
appName ? `--app-name=${escapeShellArg(appName)}` : '',
category ? `--category=${escapeShellArg(category)}` : '',
hint ? `--hint=${escapeShellArg(hint)}` : '',
replaceId ? `--replace-id=${replaceId.toString()}` : '',
`--urgency=${urgency}`,
].concat(
actions.map(({ id, label }) => `--action=${escapeShellArg(id)}=${escapeShellArg(label)}`),
).join(' ');
subprocess(
cmd,
(out) => {
if (!printedId) {
resolve(parseInt(out));
printedId = true;
}
else {
actions.find((action) => action.id === out)?.callback();
}
},
(err) => {
console.error(`[Notify] ${err}`);
},
);
});
@register()
class GSR extends GObject.Object {
private _lastNotifID: number | undefined;
constructor() {
super();
subprocess(
['gsr-start'],
(path) => {
if (!this._lastNotifID) {
console.error('[GSR] ID of warning notif not found');
setTimeout(() => {
this._onSaved(path);
}, 1000);
}
else {
this._onSaved(path);
}
},
() => { /**/ },
);
}
public saveReplay() {
execAsync(['gpu-save-replay'])
.then(async() => {
this._lastNotifID = await notifySend({
appName: APP_NAME,
iconName: ICON_NAME,
title: 'Saving Replay',
body: 'Last 20 minutes',
});
})
.catch((err) => {
console.error(`[GSR save-replay] ${err}`);
});
}
private _onSaved(path: string) {
notifySend({
appName: APP_NAME,
iconName: ICON_NAME,
replaceId: this._lastNotifID,
title: 'Replay Saved',
body: `Saved to ${path}`,
actions: [
{
id: 'folder',
label: 'Open Folder',
callback: () => execAsync([
'xdg-open',
path.substring(0, path.lastIndexOf('/')),
]).catch(print),
},
{
id: 'video',
label: 'Open Video',
callback: () => execAsync(['xdg-open', path]).catch(print),
},
{
id: 'kdenlive',
label: 'Edit in kdenlive',
callback: () => execAsync([
'bash',
'-c',
`kdenlive -i ${path}`,
]).catch(print),
},
],
});
}
}
const gsrService = new GSR();
export default gsrService;

View file

@ -0,0 +1,193 @@
import { subprocess } from 'astal';
import { App } from 'astal/gtk3';
import GObject, { register, signal } from 'astal/gobject';
import AstalIO from 'gi://AstalIO';
import { hyprMessage } from '../lib';
const ON_RELEASE_TRIGGERS = [
'released',
'TOUCH_UP',
'HOLD_END',
];
const ON_CLICK_TRIGGERS = [
'pressed',
'TOUCH_DOWN',
];
/* Types */
import PopupWindow from '../widgets/misc/popup-window';
import { CursorPos, Layer, LayerResult } from '../lib';
@register()
export default class MonitorClicks extends GObject.Object {
@signal(Boolean)
declare procStarted: (state: boolean) => void;
@signal(Boolean)
declare procDestroyed: (state: boolean) => void;
@signal(String)
declare released: (procLine: string) => void;
@signal(String)
declare clicked: (procLine: string) => void;
private process = null as AstalIO.Process | null;
constructor() {
super();
this.#initAppConnection();
}
startProc() {
if (this.process) {
return;
}
this.process = subprocess(
['libinput', 'debug-events'],
(output) => {
if (output.includes('cancelled')) {
return;
}
if (ON_RELEASE_TRIGGERS.some((p) => output.includes(p))) {
MonitorClicks.detectClickedOutside('released');
this.emit('released', output);
}
if (ON_CLICK_TRIGGERS.some((p) => output.includes(p))) {
MonitorClicks.detectClickedOutside('clicked');
this.emit('clicked', output);
}
},
);
this.emit('proc-started', true);
}
killProc() {
if (this.process) {
this.process.kill();
this.process = null;
this.emit('proc-destroyed', true);
}
}
#initAppConnection() {
App.connect('window-toggled', () => {
const anyVisibleAndClosable =
(App.get_windows() as PopupWindow[]).some((w) => {
const closable = w.close_on_unfocus &&
!(
w.close_on_unfocus === 'none' ||
w.close_on_unfocus === 'stay'
);
return w.visible && closable;
});
if (anyVisibleAndClosable) {
this.startProc();
}
else {
this.killProc();
}
});
}
static async detectClickedOutside(clickStage: string) {
const toClose = ((App.get_windows() as PopupWindow[])).some((w) => {
const closable = (
w.close_on_unfocus &&
w.close_on_unfocus === clickStage
);
return w.visible && closable;
});
if (!toClose) {
return;
}
try {
const layers = JSON.parse(await hyprMessage('j/layers')) as LayerResult;
const pos = JSON.parse(await hyprMessage('j/cursorpos')) as CursorPos;
Object.values(layers).forEach((key) => {
const overlayLayer = key.levels['3'];
if (overlayLayer) {
const noCloseWidgetsNames = [
'bar-',
'osk',
];
const getNoCloseWidgets = (names: string[]) => {
const arr = [] as Layer[];
names.forEach((name) => {
arr.push(
overlayLayer.find(
(n) => n.namespace.startsWith(name),
) ||
// Return an empty Layer if widget doesn't exist
{
address: '',
x: 0,
y: 0,
w: 0,
h: 0,
namespace: '',
},
);
});
return arr;
};
const clickIsOnWidget = (w: Layer) => {
return (
pos.x > w.x && pos.x < w.x + w.w &&
pos.y > w.y && pos.y < w.y + w.h
);
};
const noCloseWidgets = getNoCloseWidgets(noCloseWidgetsNames);
const widgets = overlayLayer.filter((n) => {
let window = null as null | PopupWindow;
if (App.get_windows().some((win) => win.name === n.namespace)) {
window = (App.get_window(n.namespace) as PopupWindow);
}
return window &&
window.close_on_unfocus &&
window.close_on_unfocus ===
clickStage;
});
if (noCloseWidgets.some(clickIsOnWidget)) {
// Don't handle clicks when on certain widgets
}
else {
widgets.forEach((w) => {
if (!(
pos.x > w.x && pos.x < w.x + w.w &&
pos.y > w.y && pos.y < w.y + w.h
)) {
App.get_window(w.namespace)?.set_visible(false);
}
});
}
}
});
}
catch (e) {
console.log(e);
}
}
}

View file

@ -0,0 +1,96 @@
// Vars from my Gradient theme
$accent_color: #bd93f9;
$accent_bg_color: #bd93f9;
$accent_fg_color: #f8f8f2;
$destructive_color: #ff5555;
$destructive_bg_color: #ff5555;
$destructive_fg_color: #f8f8f2;
$success_color: #50fa7b;
$success_bg_color: #50fa7b;
$success_fg_color: #f8f8f2;
$warning_color: #f1fa8c;
$warning_bg_color: #f1fa8c;
$warning_fg_color: rgba(0, 0, 0, 0.8);
$error_color: #ff5555;
$error_bg_color: #ff5555;
$error_fg_color: #f8f8f2;
$window_bg_color: #282a36;
$window_fg_color: #f8f8f2;
$view_bg_color: #282a36;
$view_fg_color: #f8f8f2;
$headerbar_bg_color: #282a36;
$headerbar_fg_color: #f8f8f2;
$headerbar_border_color: #ffffff;
$headerbar_backdrop_color: $window_bg_color;
$headerbar_shade_color: rgba(0, 0, 0, 0.36);
$card_bg_color: rgba(255, 255, 255, 0.08);
$card_fg_color: #f8f8f2;
$card_shade_color: rgba(0, 0, 0, 0.36);
$dialog_bg_color: #282a36;
$dialog_fg_color: #f8f8f2;
$popover_bg_color: #282a36;
$popover_fg_color: #f8f8f2;
$shade_color: #383838;
$scrollbar_outline_color: rgba(0, 0, 0, 0.5);
$green_1: #8ff0a4;
$green_2: #57e389;
$green_3: #33d17a;
$green_4: #2ec27e;
$green_5: #26a269;
$yellow_1: #f9f06b;
$yellow_2: #f8e45c;
$yellow_3: #f6d32d;
$yellow_4: #f5c211;
$yellow_5: #e5a50a;
$orange_1: #ffbe6f;
$orange_2: #ffa348;
$orange_3: #ff7800;
$orange_4: #e66100;
$orange_5: #c64600;
$red_1: #f66151;
$red_2: #ed333b;
$red_3: #e01b24;
$red_4: #c01c28;
$red_5: #a51d2d;
$purple_1: #dc8add;
$purple_2: #c061cb;
$purple_3: #9141ac;
$purple_4: #813d9c;
$purple_5: #613583;
$brown_1: #cdab8f;
$brown_2: #b5835a;
$brown_3: #986a44;
$brown_4: #865e3c;
$brown_5: #63452c;
$light_1: #ffffff;
$light_2: #f6f5f4;
$light_3: #deddda;
$light_4: #c0bfbc;
$light_5: #9a9996;
$dark_1: #77767b;
$dark_2: #5e5c64;
$dark_3: #3d3846;
$dark_4: #241f31;
$dark_5: #000000;
$blue_1: #99c1f1;
$blue_2: #62a0ea;
$blue_3: #3584e4;
$blue_4: #1c71d8;
$blue_5: #1a5fb4;
// Other colors
$black: #151720;
$dimblack: #1a1c25;
$lightblack: #262831;
$red: #dd6777;
$blue: #86aaec;
$cyan: #93cee9;
$blue-desaturated: #93cee9;
$magenta: #c296eb;
$purple: #c296eb;
$green: #90ceaa;
$aquamarine: #90ceaa;
$yellow: #ecd3a0;
$accent: $blue;
$javacafe-magenta: #c296eb;
$javacafe-blue: #86aaec;

View file

@ -0,0 +1,51 @@
@use 'colors';
window,
viewport,
stack {
all: unset;
}
progressbar {
border-radius: 999px;
background: transparent;
border: none;
trough {
background: #363847;
min-height: inherit;
border-radius: inherit;
border: none;
}
progress {
background: #79659f;
min-height: inherit;
border-radius: inherit;
border: none;
}
&:disabled {
opacity: 0.5;
}
}
circular-progress {
background: #363847;
min-height: 35px;
min-width: 35px;
font-size: 4px;
color: #79659f;
&.disabled {
opacity: 0.5;
}
}
.widget {
margin: 10px;
padding: 5px;
border-radius: 7px;
background-color: colors.$window_bg_color;
box-shadow: 8px 8px colors.$accent_color;
}

View file

@ -0,0 +1,18 @@
@use 'colors';
window {
all: unset;
background-color: transparent;
}
.base {
background-color: colors.$window_bg_color;
border: 1.3px solid colors.$accent_bg_color;
border-radius: 12px;
padding: 5px;
}
dropdown popover.menu {
padding-top: 0;
}

View file

@ -0,0 +1,3 @@
@use 'common';
@use '../widgets/lockscreen';

View file

@ -0,0 +1,12 @@
@use 'common';
@use '../widgets/applauncher';
@use '../widgets/bar';
@use '../widgets/clipboard';
@use '../widgets/date';
@use '../widgets/icon-browser';
@use '../widgets/misc';
@use '../widgets/notifs';
@use '../widgets/osd';
@use '../widgets/powermenu';
@use '../widgets/screenshot';

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>
);
};

View file

@ -0,0 +1,62 @@
self: {
config,
lib,
...
}: let
inherit (lib) hasPrefix mkIf removePrefix;
# Configs
cfgDesktop = config.roles.desktop;
flakeDir = config.environment.variables.FLAKE;
agsConfigDir = "${removePrefix "/home/${cfgDesktop.user}/" flakeDir}/nixosModules/ags/config";
hmOpts = {lib, ...}: {
options.programs.ags = {
package = lib.mkOption {
type = with lib.types; nullOr package;
default = null;
};
astalLibs = lib.mkOption {
type = with lib.types; nullOr (listOf package);
default = null;
};
lockPkg = lib.mkOption {
type = with lib.types; nullOr package;
default = null;
};
configDir = lib.mkOption {
type = lib.types.str;
default = agsConfigDir;
};
};
};
in {
config = mkIf cfgDesktop.ags.enable {
assertions = [
{
assertion = hasPrefix "/home/${cfgDesktop.user}/" flakeDir;
message = ''
Your $FLAKE environment variable needs to point to a directory in
the main users' home to use the AGS module.
'';
}
];
# Machine config
security.pam.services.astal-auth = {};
services.upower.enable = true;
home-manager.users.${cfgDesktop.user}.imports = [
hmOpts
(import ./packages.nix self)
./hyprland.nix
];
};
# For accurate stack trace
_file = ./default.nix;
}

View file

@ -0,0 +1,49 @@
{...}: {
wayland.windowManager.hyprland = {
settings = {
animations = {
bezier = [
"easeInOutQuart, 0.77, 0 , 0.175, 1"
"easeInExpo , 0.95, 0.05, 0.795, 0.035"
];
animation = [
"fadeLayersIn , 0"
"fadeLayersOut, 1, 3000, easeInExpo"
"layers , 1, 4 , easeInOutQuart, slide left"
];
};
layerrule = [
"noanim, ^(?!win-).*"
# Lockscreen blur
"blur, ^(blur-bg.*)"
"ignorealpha 0.19, ^(blur-bg.*)"
];
exec-once = [
"ags"
"sleep 3; ags request 'open win-applauncher'"
];
bind = [
"$mainMod SHIFT, E , exec, ags toggle win-powermenu"
"$mainMod , D , exec, ags toggle win-applauncher"
"$mainMod , V , exec, ags toggle win-clipboard"
" , Print, exec, ags toggle win-screenshot"
];
binde = [
## Brightness control
", XF86MonBrightnessUp , exec, ags request 'Brightness.screen +0.05'"
", XF86MonBrightnessDown, exec, ags request 'Brightness.screen -0.05'"
## Volume control
", XF86AudioRaiseVolume , exec, wpctl set-volume -l 1 @DEFAULT_AUDIO_SINK@ 5%+ & ags request 'popup speaker' &"
", XF86AudioLowerVolume , exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%- & ags request 'popup speaker' &"
];
bindn = [" , Escape , exec, ags request closeAll"];
bindr = ["CAPS, Caps_Lock, exec, ags request fetchCapsState"];
};
};
}

View file

@ -0,0 +1,137 @@
self: {
config,
lib,
osConfig,
pkgs,
...
}: let
inherit (self.inputs) ags gtk-session-lock;
inherit (lib) attrValues boolToString optionals removeAttrs;
inherit (osConfig.networking) hostName;
cfg = config.programs.ags;
cfgDesktop = osConfig.roles.desktop;
in {
config = {
# Make these accessible outside these files
programs.ags = {
package = ags.packages.${pkgs.system}.ags.override {
extraPackages = cfg.astalLibs;
};
astalLibs =
attrValues (
removeAttrs ags.inputs.astal.packages.${pkgs.system} ["docs" "gjs"]
)
++ [gtk-session-lock.packages.${pkgs.system}.default];
lockPkg = pkgs.writeShellApplication {
name = "lock";
runtimeInputs = [cfg.package];
text = ''
export CONF="lock"
if [ "$#" == 0 ]; then
exec ags run ~/${cfg.configDir}
else
exec ags "$@" -i lock
fi
'';
};
};
home = {
packages =
[
(pkgs.writeShellApplication {
name = "ags";
runtimeInputs = [cfg.package];
text = ''
export CONF="${hostName}"
if [ "$#" == 0 ]; then
exec ags run ~/${cfg.configDir}
else
exec ags "$@"
fi
'';
})
(pkgs.writeShellApplication {
name = "agsConf";
runtimeInputs = [cfg.package];
text = ''
export CONF="$1"
exec ${cfg.package}/bin/ags run ~/${cfg.configDir}
'';
})
]
++ (builtins.attrValues {
inherit
(pkgs)
playerctl
pavucontrol # TODO: replace with ags widget
;
})
++ (optionals cfgDesktop.isTouchscreen (builtins.attrValues {
inherit
(pkgs)
ydotool
;
}));
file = let
inherit
(import "${self}/lib" {inherit pkgs self;})
buildNodeModules
buildGirTypes
;
in (
(buildGirTypes {
pname = "ags";
configPath = "${cfg.configDir}/@girs";
packages = cfg.astalLibs;
})
// {
"${cfg.configDir}/node_modules".source =
buildNodeModules ./config "sha256-DtrSplIOSudRgBCfKsDAtok0/21xzHiTJNrKLYy4mfY=";
"${cfg.configDir}/tsconfig.json".source = let
inherit (ags.packages.${pkgs.system}) gjs;
in
pkgs.writers.writeJSON "tsconfig.json" {
"$schema" = "https://json.schemastore.org/tsconfig";
"compilerOptions" = {
"experimentalDecorators" = true;
"strict" = true;
"target" = "ES2023";
"moduleResolution" = "Bundler";
"jsx" = "react-jsx";
"jsxImportSource" = "${gjs}/share/astal/gjs/gtk3";
"paths" = {
"astal" = ["${gjs}/share/astal/gjs"];
"astal/*" = ["${gjs}/share/astal/gjs/*"];
};
"skipLibCheck" = true;
"module" = "ES2022";
"lib" = ["ES2023"];
};
};
"${cfg.configDir}/widgets/lockscreen/vars.ts".text =
# javascript
''
export default {
mainMonitor: '${cfgDesktop.mainMonitor}',
dupeLockscreen: ${boolToString cfgDesktop.displayManager.duplicateScreen},
hasFprintd: ${boolToString (hostName == "wim")},
};
'';
}
);
};
};
# For accurate stack trace
_file = ./default.nix;
}

View file

@ -0,0 +1,78 @@
// For ./ts/bar/hovers/keyboard.ts
export interface Keyboard {
address: string
name: string
rules: string
model: string
layout: string
variant: string
options: string
active_keymap: string
main: boolean
}
// For ./ts/media-player/gesture.ts
export interface Gesture {
attribute?: object
setup?(self: OverlayGeneric): void
props?: OverlayProps<unknown & Widget, unknown>
}
// For ./ts/media-player/mpris.ts
type PlayerDragProps = unknown & { dragging: boolean };
export type PlayerDrag = AgsCenterBox<
unknown & Widget, unknown & Widget, unknown & Widget, unknown & PlayerDragProps
>;
interface Colors {
imageAccent: string
buttonAccent: string
buttonText: string
hoverAccent: string
}
// For ./ts/media-player
export interface PlayerBoxProps {
bgStyle: string
player: MprisPlayer
}
export type PlayerBox = AgsCenterBox<
unknown & Widget, unknown & Widget, unknown & Widget, PlayerBoxProps
>;
export type PlayerOverlay = AgsOverlay<AgsWidget, {
players: Map
setup: boolean
dragging: boolean
includesWidget(playerW: PlayerBox): PlayerBox
showTopOnly(): void
moveToTop(player: PlayerBox): void
}>;
export interface PlayerButtonType {
player: MprisPlayer
colors: Var<Colors>
children: StackProps['children']
onClick: string
prop: string
}
// For ./ts/on-screen-keyboard
export type OskWindow = Window<BoxGeneric, {
startY: null | number
setVisible: (state: boolean) => void
killGestureSigs: () => void
setSlideUp: () => void
setSlideDown: () => void
}>;
// For ./ts/quick-settings
import { BluetoothDevice as BTDev } from 'types/service/bluetooth.ts';
export interface APType {
bssid: string
address: string
lastSeen: number
ssid: string
active: boolean
strength: number
iconName: string
}
export type APBox = AgsBox<unknown & Widget, { ap: Var<APType> }>;
export type DeviceBox = AgsBox<unknown & Widget, { dev: BTDev }>;

View file

@ -0,0 +1,99 @@
.thingy {
border-radius: 2rem 2rem 0 0;
min-height: 2.7rem;
min-width: 20rem;
.settings {
padding: 0.5rem;
.button {
background-color: $bgfull;
border: 0.1rem solid $darkbg;
border-radius: 0.7rem;
padding: 0.3rem;
&.toggled {
background-color: $contrast-bg;
}
}
}
}
.osk {
padding-top: 4px;
border-radius: 10px 10px 0;
.side {
.key {
&:active label {
background-color: $contrast-bg;
}
label {
background-color: $bg;
border: 0.08rem solid $darkbg;
border-radius: 0.7rem;
min-height: 3rem;
transition: background-color 0.2s ease-in-out,
border-color 0.2s ease-in-out;
&.normal,
&.Super {
min-width: 3rem;
}
&.Tab,
&.Backspace {
min-width: 7rem;
}
&.Enter,
&.Caps {
min-width: 8rem;
}
&.Shift {
min-width: 9rem;
}
&.Space {
min-width: 20rem;
}
&.PrtSc,
&.AltGr {
min-width: 3.2rem;
}
&.active {
background-color: $darkbg;
}
&.altgr {
border: 0.08rem solid blue;
}
}
}
&.right-side {
.key .mod {
&.Ctrl {
min-width: 2.4rem;
}
}
}
&.left-side {
.key .mod {
&.Alt {
min-width: 3rem;
}
&.Ctrl {
min-width: 4rem;
}
}
}
}
}

View file

@ -0,0 +1,116 @@
.arrow {
transition: -gtk-icon-transform 0.3s ease-in-out;
margin-bottom: 12px;
}
.media {
margin-top: 9px;
}
.player {
all: unset;
padding: 10px;
min-width: 400px;
min-height: 200px;
border-radius: 30px;
border-top: 2px solid $contrast-bg;
border-bottom: 2px solid $contrast-bg;
transition: background 250ms;
.top {
font-size: 23px;
}
.metadata {
.title {
font-weight: 500;
transition: text 250ms;
}
.artist {
font-weight: 400;
font-size: 15px;
transition: text 250ms;
}
}
.bottom {
font-size: 30px;
}
.pausebutton {
transition: background-color ease .2s,
color ease .2s;
font-size: 15px;
padding: 4px 4px 4px 7px;
}
.playing {
transition: background-color ease .2s,
color ease .2s;
border-radius: 15px;
}
.stopped,
.paused {
transition: background-color ease .2s,
color ease .2s;
border-radius: 26px;
padding: 4px 4px 4px 10px;
}
button label {
min-width: 35px;
}
}
.position-indicator {
min-width: 18px;
margin: 7px;
background-color: rgba(255, 255, 255, 0.7);
box-shadow: 0 0 5px 0 rgba(255, 255, 255, 0.3);
border-radius: 100%;
}
.previous,
.next,
.shuffle,
.loop {
border-radius: 100%;
transition: color 200ms;
&:hover {
border-radius: 100%;
background-color: rgba(127, 132, 156, 0.4);
transition: color 200ms;
}
}
.loop {
label {
padding-right: 8px;
}
}
.position-slider {
highlight {
margin: 0;
border-radius: 2em;
}
trough {
margin: 0 8px;
border-radius: 2em;
}
slider {
margin: -8px;
min-height: 20px;
border-radius: 10px;
transition: background-color 0.5s ease-in-out;
}
slider:hover {
transition: background-color 0.5s ease-in-out;
}
}

View file

@ -0,0 +1,184 @@
.quick-settings {
font-size: 30px;
min-width: 500px;
padding: 0;
background-color: $bg;
border-radius: 30px 0 30px 30px;
border: 2px solid $contrast-bg;
}
.title {
font-size: 22px;
margin-top: 30px;
}
.grid-label {
font-size: 30px;
margin-left: 15px;
margin-right: 10px;
min-width: 50px;
}
.scrolled-indicator {
margin: 5px 0;
}
.menu {
margin: 10px;
padding: 0;
border: 1.5px solid $contrast-bg;
border-radius: 10px;
font-size: 12px;
scrolledwindow {
padding: 3px;
}
row {
padding: 0;
margin: 0;
}
.menu-item {
margin: 5px;
label {
font-size: 16px;
margin-left: 5px;
}
image {
font-size: 20px;
}
}
}
.sub-label {
font-size: 14px;
padding: 3px;
border: 2px solid $contrast-bg;
border-radius: 10px 20px 20px 10px;
min-width: 106px;
background: #1b1b1b;
margin-top: 5px;
}
.grid-chev {
margin-left: 10px;
margin-right: 12px;
font-size: 25px;
transition: -gtk-icon-transform 0.3s ease-in-out;
}
.button-grid {
font-size: 10px;
min-width: 440px;
background-color: $bgfull;
border-top: 2px solid $contrast-bg;
border-bottom: 2px solid $contrast-bg;
border-radius: 15px;
padding: 10px 15px;
}
.grid-button {
min-height: 65px;
min-width: 70px;
}
.left-part {
background: #1b1b1b;
border-top-left-radius: 15px;
border-bottom-left-radius: 15px;
border-left: 2px solid $contrast-bg;
border-top: 2px solid $contrast-bg;
border-bottom: 2px solid $contrast-bg;
transition: all 0.5s ease-in-out;
}
.right-part {
background: #1b1b1b;
border-top-right-radius: 30px;
border-bottom-right-radius: 30px;
border-right: 2px solid $contrast-bg;
border-top: 2px solid $contrast-bg;
border-bottom: 2px solid $contrast-bg;
transition: all 0.5s ease-in-out;
}
.right-part:hover,
.right-part:active {
color: $contrast-bg;
border: 2px solid $contrast-bg;
border-top-left-radius: 7px;
border-bottom-left-radius: 7px;
transition: all 0.5s ease-in-out;
}
.left-part:hover,
.left-part:active {
color: $contrast-bg;
border: 2px solid $contrast-bg;
border-top-right-radius: 7px;
border-bottom-right-radius: 7px;
transition: all 0.5s ease-in-out;
}
.player {
margin-top: 6px;
min-height: 220px;
opacity: 0;
}
.slider-box {
min-height: 100px;
min-width: 470px;
background-color: $bgfull;
border-top: 2px solid $contrast-bg;
border-bottom: 2px solid $contrast-bg;
border-radius: 15px;
margin-top: 30px;
margin-bottom: 20px;
.slider-label {
font-size: 30px;
min-width: 40px;
margin-right: -20px;
}
.slider {
min-height: 55px;
margin-right: -15px;
scale {
min-width: 400px;
margin-left: 18px;
margin-right: 20px;
highlight {
margin: 0;
background-color: #79659f;
border-radius: 2em;
}
trough {
background-color: #363847;
border-radius: 2em;
}
slider {
margin: -4px;
min-width: 20px;
min-height: 20px;
background: #3e4153;
border-radius: 100%;
transition: background-color 0.5s ease-in-out;
}
slider:hover {
background-color: #303240;
transition: background-color 0.5s ease-in-out;
}
}
}
}

View file

@ -0,0 +1,172 @@
const Hyprland = await Service.import('hyprland');
const { execAsync, subprocess } = Utils;
import TouchGestures from './touch-gestures.ts';
const ROTATION_MAP = {
'normal': 0,
'right-up': 3,
'bottom-up': 2,
'left-up': 1,
};
const SCREEN = 'desc:BOE 0x0964';
const DEVICES = [
'wacom-hid-52eb-finger',
'wacom-hid-52eb-pen',
];
// Types
import { Subprocess } from 'types/@girs/gio-2.0/gio-2.0.cjs';
class Tablet extends Service {
static {
Service.register(this, {
'device-fetched': ['boolean'],
'autorotate-started': ['boolean'],
'autorotate-destroyed': ['boolean'],
'autorotate-toggled': ['boolean'],
'inputs-blocked': ['boolean'],
'inputs-unblocked': ['boolean'],
'laptop-mode': ['boolean'],
'tablet-mode': ['boolean'],
'mode-toggled': ['boolean'],
'osk-toggled': ['boolean'],
});
}
#tabletMode = false;
#oskState = false;
#autorotate = null as Subprocess | null;
#blockedInputs = null as Subprocess | null;
get tabletMode() {
return this.#tabletMode;
}
get autorotateState() {
return this.#autorotate !== null;
}
get oskState() {
return this.#oskState;
}
set oskState(value: boolean) {
this.#oskState = value;
this.emit('osk-toggled', this.#oskState);
}
#blockInputs() {
if (this.#blockedInputs) {
return;
}
this.#blockedInputs = subprocess(['libinput', 'debug-events', '--grab',
'--device', '/dev/input/by-path/platform-i8042-serio-0-event-kbd',
'--device', '/dev/input/by-path/platform-i8042-serio-1-event-mouse',
'--device', '/dev/input/by-path/platform-AMDI0010:02-event-mouse',
'--device', '/dev/input/by-path/platform-thinkpad_acpi-event',
'--device', '/dev/video-bus'],
() => { /**/ });
this.emit('inputs-blocked', true);
}
#unblockInputs() {
if (this.#blockedInputs) {
this.#blockedInputs.force_exit();
this.#blockedInputs = null;
this.emit('inputs-unblocked', true);
}
}
setTabletMode() {
execAsync(['brightnessctl', '-d', 'tpacpi::kbd_backlight', 's', '0'])
.catch(print);
this.startAutorotate();
this.#blockInputs();
this.#tabletMode = true;
this.emit('tablet-mode', true);
this.emit('mode-toggled', true);
}
setLaptopMode() {
execAsync(['brightnessctl', '-d', 'tpacpi::kbd_backlight', 's', '2'])
.catch(print);
this.killAutorotate();
this.#unblockInputs();
this.#tabletMode = false;
this.emit('laptop-mode', true);
this.emit('mode-toggled', true);
}
toggleMode() {
if (this.#tabletMode) {
this.setLaptopMode();
}
else {
this.setTabletMode();
}
this.emit('mode-toggled', true);
}
startAutorotate() {
if (this.#autorotate) {
return;
}
this.#autorotate = subprocess(
['monitor-sensor'],
(output) => {
if (output.includes('orientation changed')) {
const index = output.split(' ').at(-1);
if (!index) {
return;
}
const orientation = ROTATION_MAP[index];
Hyprland.messageAsync(
`keyword monitor ${SCREEN},transform,${orientation}`,
).catch(print);
const batchRotate = DEVICES.map((dev) =>
`keyword device:${dev}:transform ${orientation}; `);
Hyprland.messageAsync(`[[BATCH]] ${batchRotate.flat()}`);
if (TouchGestures.gestureDaemon) {
TouchGestures.killDaemon();
TouchGestures.startDaemon();
}
}
},
);
this.emit('autorotate-started', true);
this.emit('autorotate-toggled', true);
}
killAutorotate() {
if (this.#autorotate) {
this.#autorotate.force_exit();
this.#autorotate = null;
this.emit('autorotate-destroyed', true);
this.emit('autorotate-toggled', false);
}
}
toggleOsk() {
this.#oskState = !this.#oskState;
this.emit('osk-toggled', this.#oskState);
}
}
const tabletService = new Tablet();
export default tabletService;

View file

@ -0,0 +1,26 @@
const { Label } = Widget;
import CursorBox from '../../misc/cursorbox.ts';
import Persist from '../../misc/persist.ts';
const HeartState = Variable('');
Persist({
name: 'heart',
gobject: HeartState,
prop: 'value',
condition: '',
whenFalse: '󰣐',
});
export default () => CursorBox({
on_primary_click_release: () => {
HeartState.setValue(HeartState.value === '' ? '󰣐' : '');
},
child: Label({
class_name: 'heart-toggle',
label: HeartState.bind(),
}),
});

View file

@ -0,0 +1,54 @@
const Hyprland = await Service.import('hyprland');
const { Icon, Label } = Widget;
import HoverRevealer from './hover-revealer.ts';
const DEFAULT_KB = 'at-translated-set-2-keyboard';
// Types
import { Keyboard, LabelGeneric } from 'global-types';
const getKbdLayout = (self: LabelGeneric, _: string, layout: string) => {
if (layout) {
if (layout === 'error') {
return;
}
const shortName = layout.match(/\(([A-Za-z]+)\)/);
self.label = shortName ? shortName[1] : layout;
}
else {
// At launch, kb layout is undefined
Hyprland.messageAsync('j/devices').then((obj) => {
const keyboards = Array.from(JSON.parse(obj)
.keyboards) as Keyboard[];
const kb = keyboards.find((v) => v.name === DEFAULT_KB);
if (kb) {
layout = kb.active_keymap;
const shortName = layout
.match(/\(([A-Za-z]+)\)/);
self.label = shortName ? shortName[1] : layout;
}
else {
self.label = 'None';
}
}).catch(print);
}
};
export default () => HoverRevealer({
class_name: 'keyboard',
spacing: 4,
icon: Icon({
icon: 'input-keyboard-symbolic',
size: 20,
}),
label: Label({ css: 'font-size: 20px;' })
.hook(Hyprland, getKbdLayout, 'keyboard-layout'),
});

View file

@ -0,0 +1,167 @@
const { timeout } = Utils;
const { Box, EventBox, Overlay } = Widget;
const { Gtk } = imports.gi;
const MAX_OFFSET = 200;
const OFFSCREEN = 500;
const ANIM_DURATION = 500;
const TRANSITION = `transition: margin ${ANIM_DURATION}ms ease,
opacity ${ANIM_DURATION}ms ease;`;
// Types
import {
CenterBoxGeneric,
Gesture,
OverlayGeneric,
PlayerBox,
} from 'global-types';
export default ({
setup = () => { /**/ },
...props
}: Gesture) => {
const widget = EventBox();
const gesture = Gtk.GestureDrag.new(widget);
// Have empty PlayerBox to define the size of the widget
const emptyPlayer = Box({
class_name: 'player',
attribute: { empty: true },
});
const content = Overlay({
...props,
attribute: {
players: new Map(),
setup: false,
dragging: false,
includesWidget: (playerW: OverlayGeneric) => {
return content.overlays.find((w) => w === playerW);
},
showTopOnly: () => content.overlays.forEach((over) => {
over.visible = over === content.overlays.at(-1);
}),
moveToTop: (player: CenterBoxGeneric) => {
player.visible = true;
content.reorder_overlay(player, -1);
timeout(ANIM_DURATION, () => {
content.attribute.showTopOnly();
});
},
},
child: emptyPlayer,
setup: (self) => {
setup(self);
self
.hook(gesture, (_, realGesture) => {
if (realGesture) {
self.overlays.forEach((over) => {
over.visible = true;
});
}
else {
self.attribute.showTopOnly();
}
// Don't allow gesture when only one player
if (self.overlays.length <= 1) {
return;
}
self.attribute.dragging = true;
let offset = gesture.get_offset()[1];
const playerBox = self.overlays.at(-1) as PlayerBox;
if (!offset) {
return;
}
// Slide right
if (offset >= 0) {
playerBox.setCss(`
margin-left: ${offset}px;
margin-right: -${offset}px;
${playerBox.attribute.bgStyle}
`);
}
// Slide left
else {
offset = Math.abs(offset);
playerBox.setCss(`
margin-left: -${offset}px;
margin-right: ${offset}px;
${playerBox.attribute.bgStyle}
`);
}
}, 'drag-update')
.hook(gesture, () => {
// Don't allow gesture when only one player
if (self.overlays.length <= 1) {
return;
}
self.attribute.dragging = false;
const offset = gesture.get_offset()[1];
const playerBox = self.overlays.at(-1) as PlayerBox;
// If crosses threshold after letting go, slide away
if (offset && Math.abs(offset) > MAX_OFFSET) {
// Disable inputs during animation
widget.sensitive = false;
// Slide away right
if (offset >= 0) {
playerBox.setCss(`
${TRANSITION}
margin-left: ${OFFSCREEN}px;
margin-right: -${OFFSCREEN}px;
opacity: 0.7; ${playerBox.attribute.bgStyle}
`);
}
// Slide away left
else {
playerBox.setCss(`
${TRANSITION}
margin-left: -${OFFSCREEN}px;
margin-right: ${OFFSCREEN}px;
opacity: 0.7; ${playerBox.attribute.bgStyle}
`);
}
timeout(ANIM_DURATION, () => {
// Put the player in the back after anim
self.reorder_overlay(playerBox, 0);
// Recenter player
playerBox.setCss(playerBox.attribute.bgStyle);
widget.sensitive = true;
self.attribute.showTopOnly();
});
}
else {
// Recenter with transition for animation
playerBox.setCss(`${TRANSITION}
${playerBox.attribute.bgStyle}`);
}
}, 'drag-end');
},
});
widget.add(content);
return widget;
};

View file

@ -0,0 +1,473 @@
const Mpris = await Service.import('mpris');
const { Button, Icon, Label, Stack, Slider, CenterBox, Box } = Widget;
const { execAsync, lookUpIcon, readFileAsync } = Utils;
import Separator from '../misc/separator.ts';
import CursorBox from '../misc/cursorbox.ts';
const ICON_SIZE = 32;
const icons = {
mpris: {
fallback: 'audio-x-generic-symbolic',
shuffle: {
enabled: '󰒝',
disabled: '󰒞',
},
loop: {
none: '󰑗',
track: '󰑘',
playlist: '󰑖',
},
playing: ' ',
paused: ' ',
stopped: ' ',
prev: '󰒮',
next: '󰒭',
},
};
// Types
import { MprisPlayer } from 'types/service/mpris.ts';
import { Variable as Var } from 'types/variable';
import {
AgsWidget,
CenterBoxPropsGeneric,
Colors,
PlayerBox,
PlayerButtonType,
PlayerDrag,
PlayerOverlay,
} from 'global-types';
export const CoverArt = (
player: MprisPlayer,
colors: Var<Colors>,
props: CenterBoxPropsGeneric,
) => CenterBox({
...props,
vertical: true,
attribute: {
bgStyle: '',
player,
},
setup: (self: PlayerBox) => {
// Give temp cover art
readFileAsync(player.cover_path).catch(() => {
if (!colors.value && !player.track_cover_url) {
colors.setValue({
imageAccent: '#6b4fa2',
buttonAccent: '#ecdcff',
buttonText: '#25005a',
hoverAccent: '#d4baff',
});
self.attribute.bgStyle = `
background: radial-gradient(circle,
rgba(0, 0, 0, 0.4) 30%,
${(colors as Var<Colors>).value.imageAccent}),
rgb(0, 0, 0);
background-size: cover;
background-position: center;
`;
self.setCss(self.attribute.bgStyle);
}
});
self.hook(player, () => {
execAsync(['bash', '-c', `[[ -f "${player.cover_path}" ]] &&
coloryou "${player.cover_path}" | grep -v Warning`])
.then((out) => {
if (!Mpris.players.find((p) => player === p)) {
return;
}
colors.setValue(JSON.parse(out));
self.attribute.bgStyle = `
background: radial-gradient(circle,
rgba(0, 0, 0, 0.4) 30%,
${colors.value.imageAccent}),
url("${player.cover_path}");
background-size: cover;
background-position: center;
`;
if (!(self.get_parent() as PlayerDrag)
.attribute.dragging) {
self.setCss(self.attribute.bgStyle);
}
}).catch((err) => {
if (err !== '') {
print(err);
}
});
});
},
});
export const TitleLabel = (player: MprisPlayer) => Label({
xalign: 0,
max_width_chars: 40,
truncate: 'end',
justification: 'left',
class_name: 'title',
label: player.bind('track_title'),
});
export const ArtistLabel = (player: MprisPlayer) => Label({
xalign: 0,
max_width_chars: 40,
truncate: 'end',
justification: 'left',
class_name: 'artist',
label: player.bind('track_artists')
.transform((a) => a.join(', ') || ''),
});
export const PlayerIcon = (player: MprisPlayer, overlay: PlayerOverlay) => {
const playerIcon = (
p: MprisPlayer,
widget?: PlayerOverlay,
playerBox?: PlayerBox,
) => CursorBox({
tooltip_text: p.identity || '',
on_primary_click_release: () => {
if (widget && playerBox) {
widget.attribute.moveToTop(playerBox);
}
},
child: Icon({
class_name: widget ? 'position-indicator' : 'player-icon',
size: widget ? 0 : ICON_SIZE,
setup: (self) => {
self.hook(p, () => {
self.icon = lookUpIcon(p.entry) ?
p.entry :
icons.mpris.fallback;
});
},
}),
});
return Box().hook(Mpris, (self) => {
const grandPa = self.get_parent()?.get_parent() as AgsWidget;
if (!grandPa) {
return;
}
const thisIndex = overlay.overlays
.indexOf(grandPa);
self.children = (overlay.overlays as PlayerBox[])
.map((playerBox, i) => {
self.children.push(Separator(2));
return i === thisIndex ?
playerIcon(player) :
playerIcon(playerBox.attribute.player, overlay, playerBox);
})
.reverse();
});
};
const { Gdk } = imports.gi;
const display = Gdk.Display.get_default();
export const PositionSlider = (
player: MprisPlayer,
colors: Var<Colors>,
) => Slider({
class_name: 'position-slider',
vpack: 'center',
hexpand: true,
draw_value: false,
on_change: ({ value }) => {
player.position = player.length * value;
},
setup: (self) => {
const update = () => {
if (!self.dragging) {
self.visible = player.length > 0;
if (player.length > 0) {
self.value = player.position / player.length;
}
}
};
self
.poll(1000, () => update())
.hook(player, () => update(), 'position')
.hook(colors, () => {
if (colors.value) {
const c = colors.value;
self.setCss(`
highlight { background-color: ${c.buttonAccent}; }
slider { background-color: ${c.buttonAccent}; }
slider:hover { background-color: ${c.hoverAccent}; }
trough { background-color: ${c.buttonText}; }
`);
}
})
// OnClick
.on('button-press-event', () => {
if (!display) {
return;
}
self.window.set_cursor(Gdk.Cursor.new_from_name(
display,
'grabbing',
));
})
// OnRelease
.on('button-release-event', () => {
if (!display) {
return;
}
self.window.set_cursor(Gdk.Cursor.new_from_name(
display,
'pointer',
));
})
// OnHover
.on('enter-notify-event', () => {
if (!display) {
return;
}
self.window.set_cursor(Gdk.Cursor.new_from_name(
display,
'pointer',
));
self.toggleClassName('hover', true);
})
// OnHoverLost
.on('leave-notify-event', () => {
self.window.set_cursor(null);
self.toggleClassName('hover', false);
});
},
});
const PlayerButton = ({
player,
colors,
children = {},
onClick,
prop,
}: PlayerButtonType) => CursorBox({
child: Button({
attribute: { hovered: false },
child: Stack({ children }),
on_primary_click_release: () => player[onClick](),
on_hover: (self) => {
self.attribute.hovered = true;
if (prop === 'playBackStatus' && colors.value) {
const c = colors.value;
Object.values(children).forEach((ch: AgsWidget) => {
ch.setCss(`
background-color: ${c.hoverAccent};
color: ${c.buttonText};
min-height: 40px;
min-width: 36px;
margin-bottom: 1px;
margin-right: 1px;
`);
});
}
},
on_hover_lost: (self) => {
self.attribute.hovered = false;
if (prop === 'playBackStatus' && colors.value) {
const c = colors.value;
Object.values(children).forEach((ch: AgsWidget) => {
ch.setCss(`
background-color: ${c.buttonAccent};
color: ${c.buttonText};
min-height: 42px;
min-width: 38px;
`);
});
}
},
setup: (self) => {
self
.hook(player, () => {
self.child.shown = `${player[prop]}`;
})
.hook(colors, () => {
if (!Mpris.players.find((p) => player === p)) {
return;
}
if (colors.value) {
const c = colors.value;
if (prop === 'playBackStatus') {
if (self.attribute.hovered) {
Object.values(children)
.forEach((ch: AgsWidget) => {
ch.setCss(`
background-color: ${c.hoverAccent};
color: ${c.buttonText};
min-height: 40px;
min-width: 36px;
margin-bottom: 1px;
margin-right: 1px;
`);
});
}
else {
Object.values(children)
.forEach((ch: AgsWidget) => {
ch.setCss(`
background-color: ${c.buttonAccent};
color: ${c.buttonText};
min-height: 42px;
min-width: 38px;
`);
});
}
}
else {
self.setCss(`
* { color: ${c.buttonAccent}; }
*:hover { color: ${c.hoverAccent}; }
`);
}
}
});
},
}),
});
export const ShuffleButton = (
player: MprisPlayer,
colors: Var<Colors>,
) => PlayerButton({
player,
colors,
children: {
true: Label({
class_name: 'shuffle enabled',
label: icons.mpris.shuffle.enabled,
}),
false: Label({
class_name: 'shuffle disabled',
label: icons.mpris.shuffle.disabled,
}),
},
onClick: 'shuffle',
prop: 'shuffleStatus',
});
export const LoopButton = (
player: MprisPlayer,
colors: Var<Colors>,
) => PlayerButton({
player,
colors,
children: {
None: Label({
class_name: 'loop none',
label: icons.mpris.loop.none,
}),
Track: Label({
class_name: 'loop track',
label: icons.mpris.loop.track,
}),
Playlist: Label({
class_name: 'loop playlist',
label: icons.mpris.loop.playlist,
}),
},
onClick: 'loop',
prop: 'loopStatus',
});
export const PlayPauseButton = (
player: MprisPlayer,
colors: Var<Colors>,
) => PlayerButton({
player,
colors,
children: {
Playing: Label({
class_name: 'pausebutton playing',
label: icons.mpris.playing,
}),
Paused: Label({
class_name: 'pausebutton paused',
label: icons.mpris.paused,
}),
Stopped: Label({
class_name: 'pausebutton stopped paused',
label: icons.mpris.stopped,
}),
},
onClick: 'playPause',
prop: 'playBackStatus',
});
export const PreviousButton = (
player: MprisPlayer,
colors: Var<Colors>,
) => PlayerButton({
player,
colors,
children: {
true: Label({
class_name: 'previous',
label: icons.mpris.prev,
}),
false: Label({
class_name: 'previous',
label: icons.mpris.prev,
}),
},
onClick: 'previous',
prop: 'canGoPrev',
});
export const NextButton = (
player: MprisPlayer,
colors: Var<Colors>,
) => PlayerButton({
player,
colors,
children: {
true: Label({
class_name: 'next',
label: icons.mpris.next,
}),
false: Label({
class_name: 'next',
label: icons.mpris.next,
}),
},
onClick: 'next',
prop: 'canGoNext',
});

View file

@ -0,0 +1,201 @@
const Mpris = await Service.import('mpris');
const { Box, CenterBox } = Widget;
import * as mpris from './mpris.ts';
import PlayerGesture from './gesture.ts';
import Separator from '../misc/separator.ts';
const FAVE_PLAYER = 'org.mpris.MediaPlayer2.spotify';
const SPACING = 8;
// Types
import { MprisPlayer } from 'types/service/mpris.ts';
import { Variable as Var } from 'types/variable';
import { Colors, PlayerBox, PlayerOverlay } from 'global-types';
const Top = (
player: MprisPlayer,
overlay: PlayerOverlay,
) => Box({
class_name: 'top',
hpack: 'start',
vpack: 'start',
children: [
mpris.PlayerIcon(player, overlay),
],
});
const Center = (
player: MprisPlayer,
colors: Var<Colors>,
) => Box({
class_name: 'center',
children: [
CenterBox({
vertical: true,
start_widget: Box({
class_name: 'metadata',
vertical: true,
hpack: 'start',
vpack: 'center',
hexpand: true,
children: [
mpris.TitleLabel(player),
mpris.ArtistLabel(player),
],
}),
}),
CenterBox({
vertical: true,
center_widget: mpris.PlayPauseButton(player, colors),
}),
],
});
const Bottom = (
player: MprisPlayer,
colors: Var<Colors>,
) => Box({
class_name: 'bottom',
children: [
mpris.PreviousButton(player, colors),
Separator(SPACING),
mpris.PositionSlider(player, colors),
Separator(SPACING),
mpris.NextButton(player, colors),
Separator(SPACING),
mpris.ShuffleButton(player, colors),
Separator(SPACING),
mpris.LoopButton(player, colors),
],
});
const PlayerBox = (
player: MprisPlayer,
colors: Var<Colors>,
overlay: PlayerOverlay,
) => {
const widget = mpris.CoverArt(player, colors, {
class_name: `player ${player.name}`,
hexpand: true,
start_widget: Top(player, overlay),
center_widget: Center(player, colors),
end_widget: Bottom(player, colors),
});
widget.visible = false;
return widget;
};
export default () => {
const content = PlayerGesture({
setup: (self: PlayerOverlay) => {
self
.hook(Mpris, (_, bus_name) => {
const players = self.attribute.players;
if (players.has(bus_name)) {
return;
}
// Sometimes the signal doesn't give the bus_name
if (!bus_name) {
const player = Mpris.players.find((p) => {
return !players.has(p.bus_name);
});
if (player) {
bus_name = player.bus_name;
}
else {
return;
}
}
// Get the one on top so we can move it up later
const previousFirst = self.overlays.at(-1) as PlayerBox;
// Make the new player
const player = Mpris.getPlayer(bus_name);
const colorsVar = Variable({
imageAccent: '#6b4fa2',
buttonAccent: '#ecdcff',
buttonText: '#25005a',
hoverAccent: '#d4baff',
});
if (!player) {
return;
}
players.set(
bus_name,
PlayerBox(player, colorsVar, self),
);
self.overlays = Array.from(players.values())
.map((widget) => widget) as PlayerBox[];
const includes = self.attribute
.includesWidget(previousFirst);
// Select favorite player at startup
const attrs = self.attribute;
if (!attrs.setup && players.has(FAVE_PLAYER)) {
attrs.moveToTop(players.get(FAVE_PLAYER));
attrs.setup = true;
}
// Move previousFirst on top again
else if (includes) {
attrs.moveToTop(previousFirst);
}
}, 'player-added')
.hook(Mpris, (_, bus_name) => {
const players = self.attribute.players;
if (!bus_name || !players.has(bus_name)) {
return;
}
// Get the one on top so we can move it up later
const previousFirst = self.overlays.at(-1) as PlayerBox;
// Remake overlays without deleted one
players.delete(bus_name);
self.overlays = Array.from(players.values())
.map((widget) => widget) as PlayerBox[];
// Move previousFirst on top again
const includes = self.attribute
.includesWidget(previousFirst);
if (includes) {
self.attribute.moveToTop(previousFirst);
}
}, 'player-closed');
},
});
return Box({
class_name: 'media',
child: content,
});
};

View file

@ -0,0 +1,164 @@
const Hyprland = await Service.import('hyprland');
const { execAsync, timeout } = Utils;
const { Gtk } = imports.gi;
import Tablet from '../../services/tablet.ts';
const KEY_N = 249;
const HIDDEN_MARGIN = 340;
const ANIM_DURATION = 700;
// Types
import { OskWindow } from 'global-types';
const releaseAllKeys = () => {
const keycodes = Array.from(Array(KEY_N).keys());
execAsync([
'ydotool', 'key',
...keycodes.map((keycode) => `${keycode}:0`),
]).catch(print);
};
export default (window: OskWindow) => {
const gesture = Gtk.GestureDrag.new(window);
window.child.setCss(`margin-bottom: -${HIDDEN_MARGIN}px;`);
let signals = [] as number[];
window.attribute = {
startY: null,
setVisible: (state: boolean) => {
if (state) {
window.visible = true;
window.attribute.setSlideDown();
window.child.setCss(`
transition: margin-bottom 0.7s
cubic-bezier(0.36, 0, 0.66, -0.56);
margin-bottom: 0px;
`);
}
else {
timeout(ANIM_DURATION + 100 + 100, () => {
if (!Tablet.tabletMode) {
window.visible = false;
}
});
releaseAllKeys();
window.attribute.setSlideUp();
window.child.setCss(`
transition: margin-bottom 0.7s
cubic-bezier(0.36, 0, 0.66, -0.56);
margin-bottom: -${HIDDEN_MARGIN}px;
`);
}
},
killGestureSigs: () => {
signals.forEach((id) => {
gesture.disconnect(id);
});
signals = [];
window.attribute.startY = null;
},
setSlideUp: () => {
window.attribute.killGestureSigs();
// Begin drag
signals.push(
gesture.connect('drag-begin', () => {
Hyprland.messageAsync('j/cursorpos').then((out) => {
window.attribute.startY = JSON.parse(out).y;
});
}),
);
// Update drag
signals.push(
gesture.connect('drag-update', () => {
Hyprland.messageAsync('j/cursorpos').then((out) => {
if (!window.attribute.startY) {
return;
}
const currentY = JSON.parse(out).y;
const offset = window.attribute.startY - currentY;
if (offset < 0) {
return;
}
window.child.setCss(`
margin-bottom: ${offset - HIDDEN_MARGIN}px;
`);
});
}),
);
// End drag
signals.push(
gesture.connect('drag-end', () => {
window.child.setCss(`
transition: margin-bottom 0.5s ease-in-out;
margin-bottom: -${HIDDEN_MARGIN}px;
`);
}),
);
},
setSlideDown: () => {
window.attribute.killGestureSigs();
// Begin drag
signals.push(
gesture.connect('drag-begin', () => {
Hyprland.messageAsync('j/cursorpos').then((out) => {
window.attribute.startY = JSON.parse(out).y;
});
}),
);
// Update drag
signals.push(
gesture.connect('drag-update', () => {
Hyprland.messageAsync('j/cursorpos').then((out) => {
if (!window.attribute.startY) {
return;
}
const currentY = JSON.parse(out).y;
const offset = window.attribute.startY - currentY;
if (offset > 0) {
return;
}
window.child.setCss(`
margin-bottom: ${offset}px;
`);
});
}),
);
// End drag
signals.push(
gesture.connect('drag-end', () => {
window.child.setCss(`
transition: margin-bottom 0.5s ease-in-out;
margin-bottom: 0px;
`);
}),
);
},
};
return window;
};

View file

@ -0,0 +1,551 @@
// TODO: right Ctrl https://handwiki.org/wiki/images/4/41/KB_Canadian_Multilingual_Standard.svg
export const defaultOskLayout = 'qwerty_custom';
export const oskLayouts = {
qwerty_custom: {
name: 'QWERTY - Custom',
name_short: 'CSA',
comment: 'Like physical keyboard',
// A normal key looks like this: {label: "a", labelShift: "A", shape: "normal", keycode: 30, type: "normal"}
// A modkey looks like this: {label: "Ctrl", shape: "control", keycode: 29, type: "modkey"}
// key types are: normal, tab, caps, shift, control, fn (normal w/ half height), space, expand
keys: [
[
{
keytype: 'normal',
label: 'Esc',
shape: 'fn',
keycode: 1,
},
{
keytype: 'normal',
label: 'F1',
shape: 'fn',
keycode: 59,
},
{
keytype: 'normal',
label: 'F2',
shape: 'fn',
keycode: 60,
},
{
keytype: 'normal',
label: 'F3',
shape: 'fn',
keycode: 61,
},
{
keytype: 'normal',
label: 'F4',
shape: 'fn',
keycode: 62,
},
{
keytype: 'normal',
label: 'F5',
shape: 'fn',
keycode: 63,
},
{
keytype: 'normal',
label: 'F6',
shape: 'fn',
keycode: 64,
},
{
keytype: 'normal',
label: 'F7',
shape: 'fn',
keycode: 65,
},
{
keytype: 'normal',
label: 'F8',
shape: 'fn',
keycode: 66,
},
{
keytype: 'normal',
label: 'F9',
shape: 'fn',
keycode: 67,
},
{
keytype: 'normal',
label: 'F10',
shape: 'fn',
keycode: 68,
},
{
keytype: 'normal',
label: 'F11',
shape: 'fn',
keycode: 87,
},
{
keytype: 'normal',
label: 'F12',
shape: 'fn',
keycode: 88,
},
{
keytype: 'normal',
label: 'Home',
shape: 'fn',
keycode: 110,
},
{
keytype: 'normal',
label: 'End',
shape: 'fn',
keycode: 115,
},
{
keytype: 'normal',
label: 'Del',
shape: 'fn',
keycode: 111,
},
],
[
{
keytype: 'normal',
label: '/',
labelShift: '\\',
labelAltGr: '|',
shape: 'normal',
keycode: 41,
},
{
keytype: 'normal',
label: '1',
labelShift: '!',
shape: 'normal',
keycode: 2,
},
{
keytype: 'normal',
label: '2',
labelShift: '@',
shape: 'normal',
keycode: 3,
},
{
keytype: 'normal',
label: '3',
labelShift: '#',
labelAltGr: '¤',
shape: 'normal',
keycode: 4,
},
{
keytype: 'normal',
label: '4',
labelShift: '$',
shape: 'normal',
keycode: 5,
},
{
keytype: 'normal',
label: '5',
labelShift: '%',
shape: 'normal',
keycode: 6,
},
{
keytype: 'normal',
label: '6',
labelShift: '?',
shape: 'normal',
keycode: 7,
},
{
keytype: 'normal',
label: '7',
labelShift: '&',
labelAltGr: '{',
shape: 'normal',
keycode: 8,
},
{
keytype: 'normal',
label: '8',
labelShift: '*',
labelAltGr: '}',
shape: 'normal',
keycode: 9,
},
{
keytype: 'normal',
label: '9',
labelShift: '(',
labelAltGr: '[',
shape: 'normal',
keycode: 10,
},
{
keytype: 'normal',
label: '0',
labelShift: ')',
labelAltGr: ']',
shape: 'normal',
keycode: 11,
},
{
keytype: 'normal',
label: '-',
labelShift: '_',
shape: 'normal',
keycode: 12,
},
{
keytype: 'normal',
label: '=',
labelShift: '+',
labelAltGr: '¬',
shape: 'normal',
keycode: 13,
},
{
keytype: 'normal',
label: 'Backspace',
shape: 'expand',
keycode: 14,
},
],
[
{
keytype: 'normal',
label: 'Tab',
shape: 'tab',
keycode: 15,
},
{
keytype: 'normal',
label: 'q',
labelShift: 'Q',
shape: 'normal',
keycode: 16,
},
{
keytype: 'normal',
label: 'w',
labelShift: 'W',
shape: 'normal',
keycode: 17,
},
{
keytype: 'normal',
label: 'e',
labelShift: 'E',
labelAltGr: '€',
shape: 'normal',
keycode: 18,
},
{
keytype: 'normal',
label: 'r',
labelShift: 'R',
shape: 'normal',
keycode: 19,
},
{
keytype: 'normal',
label: 't',
labelShift: 'T',
shape: 'normal',
keycode: 20,
},
{
keytype: 'normal',
label: 'y',
labelShift: 'Y',
shape: 'normal',
keycode: 21,
},
{
keytype: 'normal',
label: 'u',
labelShift: 'U',
shape: 'normal',
keycode: 22,
},
{
keytype: 'normal',
label: 'i',
labelShift: 'I',
shape: 'normal',
keycode: 23,
},
{
keytype: 'normal',
label: 'o',
labelShift: 'O',
shape: 'normal',
keycode: 24,
},
{
keytype: 'normal',
label: 'p',
labelShift: 'P',
shape: 'normal',
keycode: 25,
},
{
keytype: 'normal',
label: '^',
labelShift: '"',
labelAltGr: '`',
shape: 'normal',
keycode: 26,
},
{
keytype: 'normal',
label: 'ç',
labelShift: 'Ç',
labelAltGr: '~',
shape: 'normal',
keycode: 27,
},
{
keytype: 'normal',
label: 'à',
labelShift: 'À',
shape: 'expand',
keycode: 43,
},
],
[
{
keytype: 'normal',
label: 'Caps',
shape: 'caps',
keycode: 58,
},
{
keytype: 'normal',
label: 'a',
labelShift: 'A',
shape: 'normal',
keycode: 30,
},
{
keytype: 'normal',
label: 's',
labelShift: 'S',
shape: 'normal',
keycode: 31,
},
{
keytype: 'normal',
label: 'd',
labelShift: 'D',
shape: 'normal',
keycode: 32,
},
{
keytype: 'normal',
label: 'f',
labelShift: 'F',
shape: 'normal',
keycode: 33,
},
{
keytype: 'normal',
label: 'g',
labelShift: 'G',
shape: 'normal',
keycode: 34,
},
{
keytype: 'normal',
label: 'h',
labelShift: 'H',
shape: 'normal',
keycode: 35,
},
{
keytype: 'normal',
label: 'j',
labelShift: 'J',
shape: 'normal',
keycode: 36,
},
{
keytype: 'normal',
label: 'k',
labelShift: 'K',
shape: 'normal',
keycode: 37,
},
{
keytype: 'normal',
label: 'l',
labelShift: 'L',
shape: 'normal',
keycode: 38,
},
{
keytype: 'normal',
label: ';',
labelShift: ':',
labelAltGr: '°',
shape: 'normal',
keycode: 39,
},
{
keytype: 'normal',
label: 'è',
labelShift: 'È',
shape: 'normal',
keycode: 40,
},
{
keytype: 'normal',
label: 'Enter',
shape: 'expand',
keycode: 28,
},
],
[
{
keytype: 'modkey',
label: 'Shift',
shape: 'shift',
keycode: 42,
},
{
keytype: 'normal',
label: 'z',
labelShift: 'Z',
labelAltGr: '«',
shape: 'normal',
keycode: 44,
},
{
keytype: 'normal',
label: 'x',
labelShift: 'X',
labelAltGr: '»',
shape: 'normal',
keycode: 45,
},
{
keytype: 'normal',
label: 'c',
labelShift: 'C',
shape: 'normal',
keycode: 46,
},
{
keytype: 'normal',
label: 'v',
labelShift: 'V',
shape: 'normal',
keycode: 47,
},
{
keytype: 'normal',
label: 'b',
labelShift: 'B',
shape: 'normal',
keycode: 48,
},
{
keytype: 'normal',
label: 'n',
labelShift: 'N',
shape: 'normal',
keycode: 49,
},
{
keytype: 'normal',
label: 'm',
labelShift: 'M',
shape: 'normal',
keycode: 50,
},
{
keytype: 'normal',
label: ',',
labelShift: "'",
labelAltGr: '<',
shape: 'normal',
keycode: 51,
},
{
keytype: 'normal',
label: '.',
labelShift: '"',
labelAltGr: '>',
shape: 'normal',
keycode: 52,
},
{
keytype: 'normal',
label: 'é',
labelShift: 'É',
shape: 'normal',
keycode: 53,
},
{
keytype: 'modkey',
label: 'Shift',
shape: 'expand',
keycode: 54,
},
],
[
{
keytype: 'modkey',
label: 'Ctrl',
shape: 'control',
keycode: 29,
},
{
keytype: 'modkey',
label: 'Super',
shape: 'normal',
keycode: 125,
},
{
keytype: 'modkey',
label: 'Alt',
shape: 'normal',
keycode: 56,
},
{
keytype: 'normal',
label: 'Space',
shape: 'space',
keycode: 57,
},
{
keytype: 'normal',
label: 'Space',
shape: 'space',
keycode: 57,
},
{
keytype: 'modkey',
label: 'AltGr',
shape: 'normal',
keycode: 100,
},
{
keytype: 'normal',
label: 'PrtSc',
shape: 'fn',
keycode: 99,
},
{
keytype: 'modkey',
label: 'Ctrl',
shape: 'control',
keycode: 97,
},
],
],
},
};

View file

@ -0,0 +1,169 @@
const { Box, CenterBox, Label, ToggleButton } = Widget;
const { Gdk } = imports.gi;
const display = Gdk.Display.get_default();
import Separator from '../misc/separator.ts';
import RoundedCorner from '../corners/screen-corners.ts';
import Key from './keys.ts';
import { defaultOskLayout, oskLayouts } from './keyboard-layouts.ts';
const keyboardLayout = defaultOskLayout;
const keyboardJson = oskLayouts[keyboardLayout];
const L_KEY_PER_ROW = [8, 7, 6, 6, 6, 4]; // eslint-disable-line
const COLOR = 'rgba(0, 0, 0, 0.3)';
const SPACING = 4;
// Types
import { BoxGeneric, OskWindow } from 'global-types';
export default (window: OskWindow) => Box({
vertical: true,
children: [
CenterBox({
hpack: 'center',
start_widget: RoundedCorner('bottomright', `
background-color: ${COLOR};
`),
center_widget: CenterBox({
class_name: 'thingy',
css: `background: ${COLOR};`,
center_widget: Box({
hpack: 'center',
class_name: 'settings',
children: [
ToggleButton({
class_name: 'button',
active: true,
vpack: 'center',
setup: (self) => {
self
.on('toggled', () => {
self.toggleClassName(
'toggled',
self.get_active(),
);
window.exclusivity = self.get_active() ?
'exclusive' :
'normal';
})
// OnHover
.on('enter-notify-event', () => {
if (!display) {
return;
}
self.window.set_cursor(
Gdk.Cursor.new_from_name(
display,
'pointer',
),
);
self.toggleClassName('hover', true);
})
// OnHoverLost
.on('leave-notify-event', () => {
self.window.set_cursor(null);
self.toggleClassName('hover', false);
});
},
child: Label('Exclusive'),
}),
],
}),
}),
end_widget: RoundedCorner('bottomleft', `
background-color: ${COLOR};
`),
}),
CenterBox({
css: `background: ${COLOR};`,
class_name: 'osk',
hexpand: true,
start_widget: Box({
class_name: 'left-side side',
hpack: 'start',
vertical: true,
children: keyboardJson.keys.map((row, rowIndex) => {
const keys = [] as BoxGeneric[];
row.forEach((key, keyIndex) => {
if (keyIndex < L_KEY_PER_ROW[rowIndex]) {
keys.push(Key(key));
}
});
return Box({
vertical: true,
children: [
Box({
class_name: 'row',
children: [
Separator(SPACING),
...keys,
],
}),
Separator(SPACING, { vertical: true }),
],
});
}),
}),
center_widget: Box({
hpack: 'center',
vpack: 'center',
children: [
],
}),
end_widget: Box({
class_name: 'right-side side',
hpack: 'end',
vertical: true,
children: keyboardJson.keys.map((row, rowIndex) => {
const keys = [] as BoxGeneric[];
row.forEach((key, keyIndex) => {
if (keyIndex >= L_KEY_PER_ROW[rowIndex]) {
keys.push(Key(key));
}
});
return Box({
vertical: true,
children: [
Box({
hpack: 'end',
class_name: 'row',
children: keys,
}),
Separator(SPACING, { vertical: true }),
],
});
}),
}),
}),
],
});

View file

@ -0,0 +1,254 @@
import Brightness from '../../services/brightness.ts';
const { Box, EventBox, Label } = Widget;
const { execAsync } = Utils;
const { Gdk, Gtk } = imports.gi;
const display = Gdk.Display.get_default();
import Separator from '../misc/separator.ts';
// Keep track of when a non modifier key
// is clicked to release all modifiers
const NormalClick = Variable(false);
// Keep track of modifier statuses
const Super = Variable(false);
const LAlt = Variable(false);
const LCtrl = Variable(false);
const AltGr = Variable(false);
const RCtrl = Variable(false);
const Caps = Variable(false);
Brightness.connect('caps', (_, state) => {
Caps.setValue(state);
});
// Assume both shifts are the same for key.labelShift
const LShift = Variable(false);
const RShift = Variable(false);
const Shift = Variable(false);
LShift.connect('changed', () => {
Shift.setValue(LShift.value || RShift.value);
});
RShift.connect('changed', () => {
Shift.setValue(LShift.value || RShift.value);
});
const SPACING = 4;
const LSHIFT_CODE = 42;
const LALT_CODE = 56;
const LCTRL_CODE = 29;
// Types
import { Variable as Var } from 'types/variable.ts';
interface Key {
keytype: string
label: string
labelShift?: string
labelAltGr?: string
shape: string
keycode: number
}
const ModKey = (key: Key) => {
let Mod: Var<boolean>;
if (key.label === 'Super') {
Mod = Super;
}
// Differentiate left and right mods
else if (key.label === 'Shift' && key.keycode === LSHIFT_CODE) {
Mod = LShift;
}
else if (key.label === 'Alt' && key.keycode === LALT_CODE) {
Mod = LAlt;
}
else if (key.label === 'Ctrl' && key.keycode === LCTRL_CODE) {
Mod = LCtrl;
}
else if (key.label === 'Shift') {
Mod = RShift;
}
else if (key.label === 'AltGr') {
Mod = AltGr;
}
else if (key.label === 'Ctrl') {
Mod = RCtrl;
}
const label = Label({
class_name: `mod ${key.label}`,
label: key.label,
});
const button = EventBox({
class_name: 'key',
on_primary_click_release: () => {
console.log('mod toggled');
execAsync(`ydotool key ${key.keycode}:${Mod.value ? 0 : 1}`);
label.toggleClassName('active', !Mod.value);
Mod.setValue(!Mod.value);
},
setup: (self) => {
self
.hook(NormalClick, () => {
Mod.setValue(false);
label.toggleClassName('active', false);
execAsync(`ydotool key ${key.keycode}:0`);
})
// OnHover
.on('enter-notify-event', () => {
if (!display) {
return;
}
self.window.set_cursor(Gdk.Cursor.new_from_name(
display,
'pointer',
));
self.toggleClassName('hover', true);
})
// OnHoverLost
.on('leave-notify-event', () => {
self.window.set_cursor(null);
self.toggleClassName('hover', false);
});
},
child: label,
});
return Box({
children: [
button,
Separator(SPACING),
],
});
};
const RegularKey = (key: Key) => {
const widget = EventBox({
class_name: 'key',
child: Label({
class_name: `normal ${key.label}`,
label: key.label,
setup: (self) => {
self
.hook(Shift, () => {
if (!key.labelShift) {
return;
}
self.label = Shift.value ? key.labelShift : key.label;
})
.hook(Caps, () => {
if (key.label === 'Caps') {
self.toggleClassName('active', Caps.value);
return;
}
if (!key.labelShift) {
return;
}
if (key.label.match(/[A-Za-z]/)) {
self.label = Caps.value ?
key.labelShift :
key.label;
}
})
.hook(AltGr, () => {
if (!key.labelAltGr) {
return;
}
self.toggleClassName('altgr', AltGr.value);
self.label = AltGr.value ? key.labelAltGr : key.label;
})
// OnHover
.on('enter-notify-event', () => {
if (!display) {
return;
}
self.window.set_cursor(Gdk.Cursor.new_from_name(
display,
'pointer',
));
self.toggleClassName('hover', true);
})
// OnHoverLost
.on('leave-notify-event', () => {
self.window.set_cursor(null);
self.toggleClassName('hover', false);
});
},
}),
});
const gesture = Gtk.GestureLongPress.new(widget);
gesture.delay_factor = 1.0;
// Long press
widget.hook(gesture, () => {
const pointer = gesture.get_point(null);
const x = pointer[1];
const y = pointer[2];
if ((!x || !y) || (x === 0 && y === 0)) {
return;
}
console.log('Not implemented yet');
// TODO: popup menu for accents
}, 'pressed');
// OnPrimaryClickRelease
widget.hook(gesture, () => {
const pointer = gesture.get_point(null);
const x = pointer[1];
const y = pointer[2];
if ((!x || !y) || (x === 0 && y === 0)) {
return;
}
console.log('key clicked');
execAsync(`ydotool key ${key.keycode}:1`);
execAsync(`ydotool key ${key.keycode}:0`);
NormalClick.setValue(true);
}, 'cancelled');
return Box({
children: [
widget,
Separator(SPACING),
],
});
};
export default (key: Key) => key.keytype === 'normal' ?
RegularKey(key) :
ModKey(key);

View file

@ -0,0 +1,32 @@
const { Window } = Widget;
const { execAsync } = Utils;
import Tablet from '../../services/tablet.ts';
import Gesture from './gesture.ts';
import Keyboard from './keyboard.ts';
/* Types */
import { OskWindow } from 'global-types';
// Start ydotool daemon
execAsync('ydotoold').catch(print);
// Window
export default () => {
const window = Window({
name: 'osk',
layer: 'overlay',
anchor: ['left', 'bottom', 'right'],
})
.hook(Tablet, (self: OskWindow, state) => {
self.attribute.setVisible(state);
}, 'osk-toggled')
.hook(Tablet, () => {
window.visible = !(!Tablet.tabletMode && !Tablet.oskState);
}, 'mode-toggled');
window.child = Keyboard(window);
return Gesture(window);
};

View file

@ -0,0 +1,28 @@
{
agsConfigDir,
pkgs,
...
}: {
"${agsConfigDir}/config/icons/down-large-symbolic.svg".source = pkgs.fetchurl {
url = "https://www.svgrepo.com/download/158537/down-chevron.svg";
hash = "sha256-mOfNjgZh0rt6XosKA2kpLY22lJldSS1XCphgrnvZH1s=";
};
"${agsConfigDir}/config/icons/nixos-logo-symbolic.svg".text =
# xml
''
<svg viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<path id="lambda" d="M7.352 1.592l-1.364.002L5.32 2.75l1.557 2.713-3.137-.008-1.32 2.34H14.11l-1.353-2.332-3.192-.006-2.214-3.865z" fill="#000000" />
</defs>
<use xlink:href="#lambda" />
<use xlink:href="#lambda" transform="rotate(120 12 12)" />
<use xlink:href="#lambda" transform="rotate(240 12 12)" />
<g opacity=".7">
<use xlink:href="#lambda" transform="rotate(60 12 12)" />
<use xlink:href="#lambda" transform="rotate(180 12 12)" />
<use xlink:href="#lambda" transform="rotate(300 12 12)" />
</g>
</svg>
'';
}