hyprpanel

This commit is contained in:
wt committed 2024-10-23 21:12:15 +07:00
1 parent 10080a83ee
commit 5151ac24da
322 files changed
+38276

No files matched your search

@@ -0,0 +1,75 @@
const audio = await Service.import('audio');
import { getIcon } from '../utils.js';
import Box from 'types/widgets/box.js';
import { Attribute, Child } from 'lib/types/widget.js';
const renderActiveInput = (): Box<Child, Attribute>[] => {
return [
Widget.Box({
class_name: 'menu-slider-container input',
children: [
Widget.Button({
vexpand: false,
vpack: 'end',
setup: (self) => {
const updateClass = (): void => {
const mic = audio.microphone;
const className = `menu-active-button input ${mic.is_muted ? 'muted' : ''}`;
self.class_name = className;
};
self.hook(audio.microphone, updateClass, 'notify::is-muted');
},
on_primary_click: () => (audio.microphone.is_muted = !audio.microphone.is_muted),
child: Widget.Icon({
class_name: 'menu-active-icon input',
setup: (self) => {
const updateIcon = (): void => {
const isMicMuted =
audio.microphone.is_muted !== null ? audio.microphone.is_muted : true;
if (audio.microphone.volume > 0) {
self.icon = getIcon(audio.microphone.volume, isMicMuted)['mic'];
} else {
self.icon = getIcon(100, true)['mic'];
}
};
self.hook(audio.microphone, updateIcon, 'notify::volume');
self.hook(audio.microphone, updateIcon, 'notify::is-muted');
},
}),
}),
Widget.Box({
vertical: true,
children: [
Widget.Label({
class_name: 'menu-active input',
hpack: 'start',
truncate: 'end',
wrap: true,
label: audio.bind('microphone').as((v) => {
return v.description === null ? 'No input device found...' : v.description;
}),
}),
Widget.Slider({
value: audio.microphone.bind('volume').as((v) => v),
class_name: 'menu-active-slider menu-slider inputs',
draw_value: false,
hexpand: true,
min: 0,
max: 1,
onChange: ({ value }) => (audio.microphone.volume = value),
}),
],
}),
Widget.Label({
class_name: 'menu-active-percentage input',
vpack: 'end',
label: audio.microphone.bind('volume').as((v) => `${Math.round(v * 100)}%`),
}),
],
}),
];
};
export { renderActiveInput };
@@ -0,0 +1,68 @@
const audio = await Service.import('audio');
import { getIcon } from '../utils.js';
import Box from 'types/widgets/box.js';
import { Attribute, Child } from 'lib/types/widget.js';
const renderActivePlayback = (): Box<Child, Attribute>[] => {
return [
Widget.Box({
class_name: 'menu-slider-container playback',
children: [
Widget.Button({
vexpand: false,
vpack: 'end',
setup: (self) => {
const updateClass = (): void => {
const spkr = audio.speaker;
const className = `menu-active-button playback ${spkr.is_muted ? 'muted' : ''}`;
self.class_name = className;
};
self.hook(audio.speaker, updateClass, 'notify::is-muted');
},
on_primary_click: () => (audio.speaker.is_muted = !audio.speaker.is_muted),
child: Widget.Icon({
class_name: 'menu-active-icon playback',
setup: (self) => {
const updateIcon = (): void => {
const isSpeakerMuted = audio.speaker.is_muted !== null ? audio.speaker.is_muted : true;
self.icon = getIcon(audio.speaker.volume, isSpeakerMuted)['spkr'];
};
self.hook(audio.speaker, updateIcon, 'notify::volume');
self.hook(audio.speaker, updateIcon, 'notify::is-muted');
},
}),
}),
Widget.Box({
vertical: true,
children: [
Widget.Label({
class_name: 'menu-active playback',
hpack: 'start',
truncate: 'end',
expand: true,
wrap: true,
label: audio.bind('speaker').as((v) => v.description || ''),
}),
Widget.Slider({
value: audio['speaker'].bind('volume'),
class_name: 'menu-active-slider menu-slider playback',
draw_value: false,
hexpand: true,
min: 0,
max: 1,
onChange: ({ value }) => (audio.speaker.volume = value),
}),
],
}),
Widget.Label({
vpack: 'end',
class_name: 'menu-active-percentage playback',
label: audio.speaker.bind('volume').as((v) => `${Math.round(v * 100)}%`),
}),
],
}),
];
};
export { renderActivePlayback };
@@ -0,0 +1,41 @@
import { renderActiveInput } from './SelectedInput.js';
import { renderActivePlayback } from './SelectedPlayback.js';
import Box from 'types/widgets/box.js';
import { Attribute, Child } from 'lib/types/widget.js';
const activeDevices = (): Box<Child, Attribute> => {
return Widget.Box({
class_name: 'menu-section-container volume',
vertical: true,
children: [
Widget.Box({
class_name: 'menu-label-container volume selected',
hpack: 'fill',
child: Widget.Label({
class_name: 'menu-label audio volume',
hexpand: true,
hpack: 'start',
label: 'Volume',
}),
}),
Widget.Box({
class_name: 'menu-items-section selected',
vertical: true,
children: [
Widget.Box({
class_name: 'menu-active-container playback',
vertical: true,
children: renderActivePlayback(),
}),
Widget.Box({
class_name: 'menu-active-container input',
vertical: true,
children: renderActiveInput(),
}),
],
}),
],
});
};
export { activeDevices };
@@ -0,0 +1,66 @@
const audio = await Service.import('audio');
import { InputDevices } from 'lib/types/audio';
import { Stream } from 'types/service/audio';
const renderInputDevices = (inputDevices: Stream[]): InputDevices => {
if (inputDevices.length === 0) {
return [
Widget.Button({
class_name: `menu-unfound-button input`,
child: Widget.Box({
children: [
Widget.Box({
hpack: 'start',
children: [
Widget.Label({
class_name: 'menu-button-name input',
label: 'No input devices found...',
}),
],
}),
],
}),
}),
];
}
return inputDevices.map((device) => {
return Widget.Button({
on_primary_click: () => (audio.microphone = device),
class_name: `menu-button audio input ${device}`,
child: Widget.Box({
children: [
Widget.Box({
hpack: 'start',
children: [
Widget.Label({
wrap: true,
class_name: audio.microphone
.bind('description')
.as((v) =>
device.description === v
? 'menu-button-icon active input txt-icon'
: 'menu-button-icon input txt-icon',
),
label: '',
}),
Widget.Label({
truncate: 'end',
wrap: true,
class_name: audio.microphone
.bind('description')
.as((v) =>
device.description === v
? 'menu-button-name active input'
: 'menu-button-name input',
),
label: device.description,
}),
],
}),
],
}),
});
});
};
export { renderInputDevices };
@@ -0,0 +1,60 @@
const audio = await Service.import('audio');
import { PlaybackDevices } from 'lib/types/audio';
import { Stream } from 'types/service/audio';
const renderPlaybacks = (playbackDevices: Stream[]): PlaybackDevices => {
return playbackDevices.map((device) => {
if (device.description === 'Dummy Output') {
return Widget.Box({
class_name: 'menu-unfound-button playback',
child: Widget.Box({
children: [
Widget.Label({
class_name: 'menu-button-name playback',
label: 'No playback devices found...',
}),
],
}),
});
}
return Widget.Button({
class_name: `menu-button audio playback ${device}`,
on_primary_click: () => (audio.speaker = device),
child: Widget.Box({
children: [
Widget.Box({
hpack: 'start',
children: [
Widget.Label({
truncate: 'end',
wrap: true,
class_name: audio.speaker
.bind('description')
.as((v) =>
device.description === v
? 'menu-button-icon active playback txt-icon'
: 'menu-button-icon playback txt-icon',
),
label: '',
}),
Widget.Label({
truncate: 'end',
wrap: true,
class_name: audio.speaker
.bind('description')
.as((v) =>
device.description === v
? 'menu-button-name active playback'
: 'menu-button-name playback',
),
label: device.description,
}),
],
}),
],
}),
});
});
};
export { renderPlaybacks };
@@ -0,0 +1,72 @@
const audio = await Service.import('audio');
import { BoxWidget } from 'lib/types/widget.js';
import { renderInputDevices } from './InputDevices.js';
import { renderPlaybacks } from './PlaybackDevices.js';
const availableDevices = (): BoxWidget => {
return Widget.Box({
vertical: true,
children: [
Widget.Box({
class_name: 'menu-section-container playback',
vertical: true,
children: [
Widget.Box({
class_name: 'menu-label-container playback',
hpack: 'fill',
child: Widget.Label({
class_name: 'menu-label audio playback',
hexpand: true,
hpack: 'start',
label: 'Playback Devices',
}),
}),
Widget.Box({
class_name: 'menu-items-section playback',
vertical: true,
children: [
Widget.Box({
class_name: 'menu-container playback',
vertical: true,
children: [
Widget.Box({
vertical: true,
children: audio.bind('speakers').as((v) => renderPlaybacks(v)),
}),
],
}),
],
}),
Widget.Box({
class_name: 'menu-label-container input',
hpack: 'fill',
child: Widget.Label({
class_name: 'menu-label audio input',
hexpand: true,
hpack: 'start',
label: 'Input Devices',
}),
}),
Widget.Box({
class_name: 'menu-items-section input',
vertical: true,
children: [
Widget.Box({
class_name: 'menu-container input',
vertical: true,
children: [
Widget.Box({
vertical: true,
children: audio.bind('microphones').as((v) => renderInputDevices(v)),
}),
],
}),
],
}),
],
}),
],
});
};
export { availableDevices };
@@ -0,0 +1,25 @@
import Window from 'types/widgets/window.js';
import DropdownMenu from '../shared/dropdown/index.js';
import { activeDevices } from './active/index.js';
import { availableDevices } from './available/index.js';
import { Attribute, Child } from 'lib/types/widget.js';
import options from 'options.js';
export default (): Window<Child, Attribute> => {
return DropdownMenu({
name: 'audiomenu',
transition: options.menus.transition.bind('value'),
child: Widget.Box({
class_name: 'menu-items audio',
hpack: 'fill',
hexpand: true,
child: Widget.Box({
vertical: true,
hpack: 'fill',
hexpand: true,
class_name: 'menu-items-container audio',
children: [activeDevices(), availableDevices()],
}),
}),
});
};
@@ -0,0 +1,29 @@
const speakerIcons = {
101: 'audio-volume-overamplified-symbolic',
66: 'audio-volume-high-symbolic',
34: 'audio-volume-medium-symbolic',
1: 'audio-volume-low-symbolic',
0: 'audio-volume-muted-symbolic',
} as const;
const inputIcons = {
101: 'microphone-sensitivity-high-symbolic',
66: 'microphone-sensitivity-high-symbolic',
34: 'microphone-sensitivity-medium-symbolic',
1: 'microphone-sensitivity-low-symbolic',
0: 'microphone-disabled-symbolic',
};
type IconVolumes = keyof typeof speakerIcons;
const getIcon = (audioVol: number, isMuted: boolean): Record<string, string> => {
const thresholds: IconVolumes[] = [101, 66, 34, 1, 0];
const icon = isMuted ? 0 : thresholds.find((threshold) => threshold <= audioVol * 100) || 0;
return {
spkr: speakerIcons[icon],
mic: inputIcons[icon],
};
};
export { getIcon };
@@ -0,0 +1,71 @@
import { BoxWidget } from 'lib/types/widget';
import { BluetoothDevice } from 'types/service/bluetooth';
const connectedControls = (dev: BluetoothDevice, connectedDevices: BluetoothDevice[]): BoxWidget => {
if (!connectedDevices.includes(dev.address)) {
return Widget.Box({});
}
return Widget.Box({
vpack: 'start',
class_name: 'bluetooth-controls',
children: [
Widget.Button({
class_name: 'menu-icon-button unpair bluetooth',
child: Widget.Label({
tooltip_text: dev.paired ? 'Unpair' : 'Pair',
class_name: 'menu-icon-button-label unpair bluetooth txt-icon',
label: dev.paired ? '' : '',
}),
on_primary_click: () =>
Utils.execAsync([
'bash',
'-c',
`bluetoothctl ${dev.paired ? 'unpair' : 'pair'} ${dev.address}`,
]).catch((err) =>
console.error(`bluetoothctl ${dev.paired ? 'unpair' : 'pair'} ${dev.address}`, err),
),
}),
Widget.Button({
class_name: 'menu-icon-button disconnect bluetooth',
child: Widget.Label({
tooltip_text: dev.connected ? 'Disconnect' : 'Connect',
class_name: 'menu-icon-button-label disconnect bluetooth txt-icon',
label: dev.connected ? '󱘖' : '',
}),
on_primary_click: () => dev.setConnection(!dev.connected),
}),
Widget.Button({
class_name: 'menu-icon-button untrust bluetooth',
child: Widget.Label({
tooltip_text: dev.trusted ? 'Untrust' : 'Trust',
class_name: 'menu-icon-button-label untrust bluetooth txt-icon',
label: dev.trusted ? '' : '󱖡',
}),
on_primary_click: () =>
Utils.execAsync([
'bash',
'-c',
`bluetoothctl ${dev.trusted ? 'untrust' : 'trust'} ${dev.address}`,
]).catch((err) =>
console.error(`bluetoothctl ${dev.trusted ? 'untrust' : 'trust'} ${dev.address}`, err),
),
}),
Widget.Button({
class_name: 'menu-icon-button delete bluetooth',
child: Widget.Label({
tooltip_text: 'Forget',
class_name: 'menu-icon-button-label delete bluetooth txt-icon',
label: '󰆴',
}),
on_primary_click: () => {
Utils.execAsync(['bash', '-c', `bluetoothctl remove ${dev.address}`]).catch((err) =>
console.error('Bluetooth Remove', err),
);
},
}),
],
});
};
export { connectedControls };
@@ -0,0 +1,136 @@
import { Bluetooth } from 'types/service/bluetooth.js';
import Box from 'types/widgets/box.js';
import { connectedControls } from './connectedControls.js';
import { getBluetoothIcon } from '../utils.js';
import Gtk from 'types/@girs/gtk-3.0/gtk-3.0.js';
import { Attribute, Child } from 'lib/types/widget.js';
const devices = (bluetooth: Bluetooth, self: Box<Gtk.Widget, unknown>): Box<Child, Attribute> => {
return self.hook(bluetooth, () => {
if (!bluetooth.enabled) {
return (self.child = Widget.Box({
class_name: 'bluetooth-items',
vertical: true,
expand: true,
vpack: 'center',
hpack: 'center',
children: [
Widget.Label({
class_name: 'bluetooth-disabled dim',
hexpand: true,
label: 'Bluetooth is disabled',
}),
],
}));
}
const availableDevices = bluetooth.devices
.filter((btDev) => btDev.name !== null)
.sort((a, b) => {
if (a.connected || a.paired) {
return -1;
}
if (b.connected || b.paired) {
return 1;
}
return b.name - a.name;
});
const conDevNames = availableDevices.filter((d) => d.connected || d.paired).map((d) => d.address);
if (!availableDevices.length) {
return (self.child = Widget.Box({
class_name: 'bluetooth-items',
vertical: true,
expand: true,
vpack: 'center',
hpack: 'center',
children: [
Widget.Label({
class_name: 'no-bluetooth-devices dim',
hexpand: true,
label: 'No devices currently found',
}),
Widget.Label({
class_name: 'search-bluetooth-label dim',
hexpand: true,
label: "Press '󰑐' to search",
}),
],
}));
}
return (self.child = Widget.Box({
vertical: true,
children: availableDevices.map((device) => {
return Widget.Box({
children: [
Widget.Button({
hexpand: true,
class_name: `bluetooth-element-item ${device}`,
on_primary_click: () => {
if (!conDevNames.includes(device.address)) device.setConnection(true);
},
child: Widget.Box({
hexpand: true,
children: [
Widget.Box({
hexpand: true,
hpack: 'start',
class_name: 'menu-button-container',
children: [
Widget.Label({
vpack: 'start',
class_name: `menu-button-icon bluetooth ${conDevNames.includes(device.address) ? 'active' : ''} txt-icon`,
label: getBluetoothIcon(`${device['icon_name']}-symbolic`),
}),
Widget.Box({
vertical: true,
vpack: 'center',
children: [
Widget.Label({
vpack: 'center',
hpack: 'start',
class_name: 'menu-button-name bluetooth',
truncate: 'end',
wrap: true,
label: device.alias,
}),
Widget.Revealer({
hpack: 'start',
reveal_child: device.connected || device.paired,
child: Widget.Label({
hpack: 'start',
class_name: 'connection-status dim',
label: device.connected ? 'Connected' : 'Paired',
}),
}),
],
}),
],
}),
Widget.Box({
hpack: 'end',
children: device.connecting
? [
Widget.Spinner({
vpack: 'start',
class_name: 'spinner bluetooth',
}),
]
: [],
}),
],
}),
}),
connectedControls(device, conDevNames),
],
});
}),
}));
});
};
export { devices };
@@ -0,0 +1,26 @@
const bluetooth = await Service.import('bluetooth');
import { label } from './label.js';
import { devices } from './devicelist.js';
import { BoxWidget } from 'lib/types/widget.js';
const Devices = (): BoxWidget => {
return Widget.Box({
class_name: 'menu-section-container',
vertical: true,
children: [
label(bluetooth),
Widget.Box({
class_name: 'menu-items-section',
child: Widget.Box({
class_name: 'menu-content',
vertical: true,
setup: (self) => {
devices(bluetooth, self);
},
}),
}),
],
});
};
export { Devices };
@@ -0,0 +1,65 @@
import { BoxWidget } from 'lib/types/widget';
import { Bluetooth } from 'types/service/bluetooth';
const label = (bluetooth: Bluetooth): BoxWidget => {
const searchInProgress = Variable(false);
const startRotation = (): void => {
searchInProgress.value = true;
setTimeout(() => {
searchInProgress.value = false;
}, 10 * 1000);
};
return Widget.Box({
class_name: 'menu-label-container',
hpack: 'fill',
vpack: 'start',
children: [
Widget.Label({
class_name: 'menu-label',
vpack: 'center',
hpack: 'start',
label: 'Bluetooth',
}),
Widget.Box({
class_name: 'controls-container',
vpack: 'start',
children: [
Widget.Switch({
class_name: 'menu-switch bluetooth',
hexpand: true,
hpack: 'end',
active: bluetooth.bind('enabled'),
on_activate: ({ active }) => {
searchInProgress.value = false;
Utils.execAsync(['bash', '-c', `bluetoothctl power ${active ? 'on' : 'off'}`]).catch(
(err) => console.error(`bluetoothctl power ${active ? 'on' : 'off'}`, err),
);
},
}),
Widget.Separator({
class_name: 'menu-separator bluetooth',
}),
Widget.Button({
vpack: 'center',
class_name: 'menu-icon-button search',
on_primary_click: () => {
startRotation();
Utils.execAsync(['bash', '-c', 'bluetoothctl --timeout 120 scan on']).catch((err) => {
searchInProgress.value = false;
console.error('bluetoothctl --timeout 120 scan on', err);
});
},
child: Widget.Icon({
class_name: searchInProgress.bind('value').as((v) => (v ? 'spinning' : '')),
icon: 'view-refresh-symbolic',
}),
}),
],
}),
],
});
};
export { label };
@@ -0,0 +1,24 @@
import Window from 'types/widgets/window.js';
import DropdownMenu from '../shared/dropdown/index.js';
import { Devices } from './devices/index.js';
import { Attribute, Child } from 'lib/types/widget.js';
import options from 'options.js';
export default (): Window<Child, Attribute> => {
return DropdownMenu({
name: 'bluetoothmenu',
transition: options.menus.transition.bind('value'),
child: Widget.Box({
class_name: 'menu-items bluetooth',
hpack: 'fill',
hexpand: true,
child: Widget.Box({
vertical: true,
hpack: 'fill',
hexpand: true,
class_name: 'menu-items-container bluetooth',
child: Devices(),
}),
}),
});
};
@@ -0,0 +1,29 @@
const getBluetoothIcon = (iconName: string): string => {
const deviceIconMap = [
['^audio-card*', '󰎄'],
['^audio-headphones*', '󰋋'],
['^audio-headset*', '󰋎'],
['^audio-input*', '󰍬'],
['^audio-speakers*', '󰓃'],
['^bluetooth*', '󰂯'],
['^camera*', '󰄀'],
['^computer*', '󰟀'],
['^input-gaming*', '󰍬'],
['^input-keyboard*', '󰌌'],
['^input-mouse*', '󰍽'],
['^input-tablet*', '󰓶'],
['^media*', '󱛟'],
['^modem*', '󱂇'],
['^network*', '󱂇'],
['^phone*', '󰄞'],
['^printer*', '󰐪'],
['^scanner*', '󰚫'],
['^video-camera*', '󰕧'],
];
const foundMatch = deviceIconMap.find((icon) => RegExp(icon[0]).test(iconName.toLowerCase()));
return foundMatch ? foundMatch[1] : '󰂯';
};
export { getBluetoothIcon };
@@ -0,0 +1,24 @@
import { BoxWidget } from 'lib/types/widget';
const CalendarWidget = (): BoxWidget => {
return Widget.Box({
class_name: 'calendar-menu-item-container calendar',
hpack: 'fill',
vpack: 'fill',
expand: true,
child: Widget.Box({
class_name: 'calendar-container-box',
child: Widget.Calendar({
expand: true,
hpack: 'fill',
vpack: 'fill',
class_name: 'calendar-menu-widget',
showDayNames: true,
showDetails: false,
showHeading: true,
}),
}),
});
};
export { CalendarWidget };
@@ -0,0 +1,36 @@
import DropdownMenu from 'modules/menus/shared/dropdown/index';
import { TimeWidget } from './time/index';
import { CalendarWidget } from './calendar';
import { WeatherWidget } from './weather/index';
import options from 'options';
import Window from 'types/widgets/window';
import { Attribute, Child } from 'lib/types/widget';
const { enabled: weatherEnabled } = options.menus.clock.weather;
export default (): Window<Child, Attribute> => {
return DropdownMenu({
name: 'calendarmenu',
transition: options.menus.transition.bind('value'),
child: Widget.Box({
class_name: 'calendar-menu-content',
css: 'padding: 1px; margin: -1px;',
vexpand: false,
children: [
Widget.Box({
class_name: 'calendar-content-container',
vertical: true,
children: [
Widget.Box({
class_name: 'calendar-content-items',
vertical: true,
children: weatherEnabled.bind('value').as((isWeatherEnabled) => {
return [TimeWidget(), CalendarWidget(), ...(isWeatherEnabled ? [WeatherWidget()] : [])];
}),
}),
],
}),
],
}),
});
};
@@ -0,0 +1,75 @@
import { BoxWidget } from 'lib/types/widget';
import options from 'options';
const { military, hideSeconds } = options.menus.clock.time;
const time = Variable('', {
poll: [1000, 'date "+%I:%M:%S"'],
});
const period = Variable('', {
poll: [1000, 'date "+%p"'],
});
const militaryTime = Variable('', {
poll: [1000, 'date "+%H:%M:%S"'],
});
const TimeWidget = (): BoxWidget => {
return Widget.Box({
class_name: 'calendar-menu-item-container clock',
hexpand: true,
vpack: 'center',
hpack: 'fill',
child: Widget.Box({
hexpand: true,
vpack: 'center',
hpack: 'center',
class_name: 'clock-content-items',
children: Utils.merge(
[military.bind('value'), hideSeconds.bind('value')],
(is24hr: boolean, hideSeconds: boolean) => {
if (!is24hr) {
return [
Widget.Box({
hpack: 'center',
children: [
Widget.Label({
class_name: 'clock-content-time',
label: hideSeconds ? time.bind().as((str) => str.slice(0, -3)) : time.bind(),
}),
],
}),
Widget.Box({
hpack: 'center',
children: [
Widget.Label({
vpack: 'end',
class_name: 'clock-content-period',
label: period.bind(),
}),
],
}),
];
}
return [
Widget.Box({
hpack: 'center',
children: [
Widget.Label({
class_name: 'clock-content-time',
label: hideSeconds
? militaryTime.bind().as((str) => str.slice(0, -3))
: militaryTime.bind(),
}),
],
}),
];
},
),
}),
});
};
export { TimeWidget };
@@ -0,0 +1,43 @@
import { Weather, WeatherIconTitle } from 'lib/types/weather.js';
import { Variable } from 'types/variable.js';
import { weatherIcons } from 'modules/icons/weather.js';
import { isValidWeatherIconTitle } from 'globals/weather';
import { BoxWidget } from 'lib/types/widget';
import { getNextEpoch } from '../utils';
export const HourlyIcon = (theWeather: Variable<Weather>, hoursFromNow: number): BoxWidget => {
const getIconQuery = (wthr: Weather): WeatherIconTitle => {
const nextEpoch = getNextEpoch(wthr, hoursFromNow);
const weatherAtEpoch = wthr.forecast.forecastday[0].hour.find((h) => h.time_epoch === nextEpoch);
if (weatherAtEpoch === undefined) {
return 'warning';
}
let iconQuery = weatherAtEpoch.condition.text.trim().toLowerCase().replaceAll(' ', '_');
if (!weatherAtEpoch?.is_day && iconQuery === 'partly_cloudy') {
iconQuery = 'partly_cloudy_night';
}
if (isValidWeatherIconTitle(iconQuery)) {
return iconQuery;
} else {
return 'warning';
}
};
return Widget.Box({
hpack: 'center',
child: theWeather.bind('value').as((w) => {
const iconQuery = getIconQuery(w);
const weatherIcn = weatherIcons[iconQuery] || weatherIcons['warning'];
return Widget.Label({
hpack: 'center',
class_name: 'hourly-weather-icon txt-icon',
label: weatherIcn,
});
}),
});
};
@@ -0,0 +1,27 @@
import { Weather } from 'lib/types/weather';
import { Variable } from 'types/variable';
import { HourlyIcon } from './icon/index.js';
import { HourlyTemp } from './temperature/index.js';
import { HourlyTime } from './time/index.js';
import { BoxWidget } from 'lib/types/widget.js';
export const Hourly = (theWeather: Variable<Weather>): BoxWidget => {
return Widget.Box({
vertical: false,
hexpand: true,
hpack: 'fill',
class_name: 'hourly-weather-container',
children: [1, 2, 3, 4].map((hoursFromNow) => {
return Widget.Box({
class_name: 'hourly-weather-item',
hexpand: true,
vertical: true,
children: [
HourlyTime(theWeather, hoursFromNow),
HourlyIcon(theWeather, hoursFromNow),
HourlyTemp(theWeather, hoursFromNow),
],
});
}),
});
};
@@ -0,0 +1,27 @@
import { Weather } from 'lib/types/weather';
import { Variable } from 'types/variable';
import options from 'options';
import Label from 'types/widgets/label';
import { Child } from 'lib/types/widget';
import { getNextEpoch } from '../utils';
const { unit } = options.menus.clock.weather;
export const HourlyTemp = (theWeather: Variable<Weather>, hoursFromNow: number): Label<Child> => {
return Widget.Label({
class_name: 'hourly-weather-temp',
label: Utils.merge([theWeather.bind('value'), unit.bind('value')], (wthr, unt) => {
if (!Object.keys(wthr).length) {
return '-';
}
const nextEpoch = getNextEpoch(wthr, hoursFromNow);
const weatherAtEpoch = wthr.forecast.forecastday[0].hour.find((h) => h.time_epoch === nextEpoch);
if (unt === 'imperial') {
return `${weatherAtEpoch ? Math.ceil(weatherAtEpoch.temp_f) : '-'}° F`;
}
return `${weatherAtEpoch ? Math.ceil(weatherAtEpoch.temp_c) : '-'}° C`;
}),
});
};
@@ -0,0 +1,24 @@
import { Weather } from 'lib/types/weather';
import { Child } from 'lib/types/widget';
import { Variable } from 'types/variable';
import Label from 'types/widgets/label';
import { getNextEpoch } from '../utils';
export const HourlyTime = (theWeather: Variable<Weather>, hoursFromNow: number): Label<Child> => {
return Widget.Label({
class_name: 'hourly-weather-time',
label: theWeather.bind('value').as((w) => {
if (!Object.keys(w).length) {
return '-';
}
const nextEpoch = getNextEpoch(w, hoursFromNow);
const dateAtEpoch = new Date(nextEpoch * 1000);
let hours = dateAtEpoch.getHours();
const ampm = hours >= 12 ? 'PM' : 'AM';
hours = hours % 12 || 12;
return `${hours}${ampm}`;
}),
});
};
@@ -0,0 +1,20 @@
import { Weather } from 'lib/types/weather';
export const getNextEpoch = (wthr: Weather, hoursFromNow: number): number => {
const currentEpoch = wthr.location.localtime_epoch;
const epochAtHourStart = currentEpoch - (currentEpoch % 3600);
let nextEpoch = 3600 * hoursFromNow + epochAtHourStart;
const curHour = new Date(currentEpoch * 1000).getHours();
/*
* NOTE: Since the API is only capable of showing the current day; if
* the hours left in the day are less than 4 (aka spilling into the next day),
* then rewind to contain the prediction within the current day.
*/
if (curHour > 19) {
const hoursToRewind = curHour - 19;
nextEpoch = 3600 * hoursFromNow + epochAtHourStart - hoursToRewind * 3600;
}
return nextEpoch;
};
@@ -0,0 +1,18 @@
import { Weather } from 'lib/types/weather';
import { Variable } from 'types/variable';
import { getWeatherStatusTextIcon } from 'globals/weather.js';
import { BoxWidget } from 'lib/types/widget';
export const TodayIcon = (theWeather: Variable<Weather>): BoxWidget => {
return Widget.Box({
vpack: 'center',
hpack: 'start',
class_name: 'calendar-menu-weather today icon container',
child: Widget.Label({
class_name: 'calendar-menu-weather today icon txt-icon',
label: theWeather.bind('value').as((w) => {
return getWeatherStatusTextIcon(w);
}),
}),
});
};
@@ -0,0 +1,38 @@
import { TodayIcon } from './icon/index.js';
import { TodayStats } from './stats/index.js';
import { TodayTemperature } from './temperature/index.js';
import { Hourly } from './hourly/index.js';
import { globalWeatherVar } from 'globals/weather.js';
import { BoxWidget } from 'lib/types/widget.js';
const WeatherWidget = (): BoxWidget => {
return Widget.Box({
class_name: 'calendar-menu-item-container weather',
child: Widget.Box({
class_name: 'weather-container-box',
setup: (self) => {
return (self.child = Widget.Box({
vertical: true,
hexpand: true,
children: [
Widget.Box({
class_name: 'calendar-menu-weather today',
hexpand: true,
children: [
TodayIcon(globalWeatherVar),
TodayTemperature(globalWeatherVar),
TodayStats(globalWeatherVar),
],
}),
Widget.Separator({
class_name: 'menu-separator weather',
}),
Hourly(globalWeatherVar),
],
}));
},
}),
});
};
export { WeatherWidget };
@@ -0,0 +1,50 @@
import { Weather } from 'lib/types/weather';
import { Variable } from 'types/variable';
import options from 'options';
import { Unit } from 'lib/types/options';
import { getRainChance, getWindConditions } from 'globals/weather';
import { BoxWidget } from 'lib/types/widget';
const { unit } = options.menus.clock.weather;
export const TodayStats = (theWeather: Variable<Weather>): BoxWidget => {
return Widget.Box({
class_name: 'calendar-menu-weather today stats container',
hpack: 'end',
vpack: 'center',
vertical: true,
children: [
Widget.Box({
class_name: 'weather wind',
children: [
Widget.Label({
class_name: 'weather wind icon txt-icon',
label: '',
}),
Widget.Label({
class_name: 'weather wind label',
label: Utils.merge(
[theWeather.bind('value'), unit.bind('value')],
(wthr: Weather, unt: Unit) => {
return getWindConditions(wthr, unt);
},
),
}),
],
}),
Widget.Box({
class_name: 'weather precip',
children: [
Widget.Label({
class_name: 'weather precip icon txt-icon',
label: '',
}),
Widget.Label({
class_name: 'weather precip label',
label: theWeather.bind('value').as((v) => getRainChance(v)),
}),
],
}),
],
});
};
@@ -0,0 +1,62 @@
import { Weather } from 'lib/types/weather';
import { Variable } from 'types/variable';
import options from 'options';
import { getTemperature, getWeatherIcon } from 'globals/weather';
import { BoxWidget } from 'lib/types/widget';
const { unit } = options.menus.clock.weather;
export const TodayTemperature = (theWeather: Variable<Weather>): BoxWidget => {
return Widget.Box({
hpack: 'center',
vpack: 'center',
vertical: true,
children: [
Widget.Box({
hexpand: true,
vpack: 'center',
class_name: 'calendar-menu-weather today temp container',
vertical: false,
children: [
Widget.Box({
hexpand: true,
hpack: 'center',
children: [
Widget.Label({
class_name: 'calendar-menu-weather today temp label',
label: Utils.merge([theWeather.bind('value'), unit.bind('value')], (wthr, unt) => {
return getTemperature(wthr, unt);
}),
}),
Widget.Label({
class_name: theWeather
.bind('value')
.as(
(v) =>
`calendar-menu-weather today temp label icon txt-icon ${getWeatherIcon(Math.ceil(v.current.temp_f)).color}`,
),
label: theWeather
.bind('value')
.as((v) => getWeatherIcon(Math.ceil(v.current.temp_f)).icon),
}),
],
}),
],
}),
Widget.Box({
hpack: 'center',
child: Widget.Label({
max_width_chars: 17,
truncate: 'end',
lines: 2,
class_name: theWeather
.bind('value')
.as(
(v) =>
`calendar-menu-weather today condition label ${getWeatherIcon(Math.ceil(v.current.temp_f)).color}`,
),
label: theWeather.bind('value').as((v) => v.current.condition.text),
}),
}),
],
});
};
@@ -0,0 +1,113 @@
import { BoxWidget } from 'lib/types/widget';
const network = await Service.import('network');
const bluetooth = await Service.import('bluetooth');
const notifications = await Service.import('notifications');
const audio = await Service.import('audio');
const Controls = (): BoxWidget => {
return Widget.Box({
class_name: 'dashboard-card controls-container',
hpack: 'fill',
vpack: 'fill',
expand: true,
children: [
Widget.Button({
tooltip_text: 'Toggle Wifi',
expand: true,
setup: (self) => {
self.hook(network, () => {
return (self.class_name = `dashboard-button wifi ${!network.wifi.enabled ? 'disabled' : ''}`);
});
},
on_primary_click: () => network.toggleWifi(),
child: Widget.Label({
class_name: 'txt-icon',
setup: (self) => {
self.hook(network, () => {
return (self.label = network.wifi.enabled ? '󰤨' : '󰤭');
});
},
}),
}),
Widget.Button({
tooltip_text: 'Toggle Bluetooth',
expand: true,
class_name: bluetooth
.bind('enabled')
.as((btOn) => `dashboard-button bluetooth ${!btOn ? 'disabled' : ''}`),
on_primary_click: () => bluetooth.toggle(),
child: Widget.Label({
class_name: 'txt-icon',
label: bluetooth.bind('enabled').as((btOn) => (btOn ? '󰂯' : '󰂲')),
}),
}),
Widget.Button({
tooltip_text: 'Toggle Notifications',
expand: true,
class_name: notifications
.bind('dnd')
.as((dnd) => `dashboard-button notifications ${dnd ? 'disabled' : ''}`),
on_primary_click: () => (notifications.dnd = !notifications.dnd),
child: Widget.Label({
class_name: 'txt-icon',
label: notifications.bind('dnd').as((dnd) => (dnd ? '󰂛' : '󰂚')),
}),
}),
Widget.Button({
tooltip_text: 'Toggle Mute (Playback)',
expand: true,
on_primary_click: () => (audio.speaker.is_muted = !audio.speaker.is_muted),
setup: (self) => {
self.hook(
audio.speaker,
() => {
return (self.class_name = `dashboard-button playback ${audio.speaker.is_muted ? 'disabled' : ''}`);
},
'notify::is-muted',
);
},
child: Widget.Label({
class_name: 'txt-icon',
setup: (self) => {
self.hook(
audio.speaker,
() => {
return (self.label = audio.speaker.is_muted ? '󰖁' : '󰕾');
},
'notify::is-muted',
);
},
}),
}),
Widget.Button({
tooltip_text: 'Toggle Mute (Microphone)',
expand: true,
on_primary_click: () => (audio.microphone.is_muted = !audio.microphone.is_muted),
setup: (self) => {
self.hook(
audio.microphone,
() => {
return (self.class_name = `dashboard-button input ${audio.microphone.is_muted ? 'disabled' : ''}`);
},
'notify::is-muted',
);
},
child: Widget.Label({
class_name: 'txt-icon',
setup: (self) => {
self.hook(
audio.microphone,
() => {
return (self.label = audio.microphone.is_muted ? '󰍭' : '󰍬');
},
'notify::is-muted',
);
},
}),
}),
],
});
};
export { Controls };
@@ -0,0 +1,121 @@
import { BoxWidget } from 'lib/types/widget';
import options from 'options';
const { left, right } = options.menus.dashboard.directories;
const Directories = (): BoxWidget => {
return Widget.Box({
class_name: 'dashboard-card directories-container',
vpack: 'fill',
hpack: 'fill',
expand: true,
children: [
Widget.Box({
vertical: true,
expand: true,
class_name: 'section right',
children: [
Widget.Button({
hpack: 'start',
expand: true,
class_name: 'directory-link left top',
on_primary_click: left.directory1.command.bind('value').as((cmd) => {
return () => {
App.closeWindow('dashboardmenu');
Utils.execAsync(cmd);
};
}),
child: Widget.Label({
hpack: 'start',
label: left.directory1.label.bind('value'),
}),
}),
Widget.Button({
expand: true,
hpack: 'start',
class_name: 'directory-link left middle',
on_primary_click: left.directory2.command.bind('value').as((cmd) => {
return () => {
App.closeWindow('dashboardmenu');
Utils.execAsync(cmd);
};
}),
child: Widget.Label({
hpack: 'start',
label: left.directory2.label.bind('value'),
}),
}),
Widget.Button({
expand: true,
hpack: 'start',
class_name: 'directory-link left bottom',
on_primary_click: left.directory3.command.bind('value').as((cmd) => {
return () => {
App.closeWindow('dashboardmenu');
Utils.execAsync(cmd);
};
}),
child: Widget.Label({
hpack: 'start',
label: left.directory3.label.bind('value'),
}),
}),
],
}),
Widget.Box({
vertical: true,
expand: true,
class_name: 'section left',
children: [
Widget.Button({
hpack: 'start',
expand: true,
class_name: 'directory-link right top',
on_primary_click: right.directory1.command.bind('value').as((cmd) => {
return () => {
App.closeWindow('dashboardmenu');
Utils.execAsync(cmd);
};
}),
child: Widget.Label({
hpack: 'start',
label: right.directory1.label.bind('value'),
}),
}),
Widget.Button({
expand: true,
hpack: 'start',
class_name: 'directory-link right middle',
on_primary_click: right.directory2.command.bind('value').as((cmd) => {
return () => {
App.closeWindow('dashboardmenu');
Utils.execAsync(cmd);
};
}),
child: Widget.Label({
hpack: 'start',
label: right.directory2.label.bind('value'),
}),
}),
Widget.Button({
expand: true,
hpack: 'start',
class_name: 'directory-link right bottom',
on_primary_click: right.directory3.command.bind('value').as((cmd) => {
return () => {
App.closeWindow('dashboardmenu');
Utils.execAsync(cmd);
};
}),
child: Widget.Label({
hpack: 'start',
label: right.directory3.label.bind('value'),
}),
}),
],
}),
],
});
};
export { Directories };
@@ -0,0 +1,52 @@
import DropdownMenu from '../shared/dropdown/index.js';
import { Profile } from './profile/index.js';
import { Shortcuts } from './shortcuts/index.js';
import { Controls } from './controls/index.js';
import { Stats } from './stats/index.js';
import { Directories } from './directories/index.js';
import Window from 'types/widgets/window.js';
import { Attribute, Child } from 'lib/types/widget.js';
import options from 'options.js';
const { controls, shortcuts, stats, directories } = options.menus.dashboard;
export default (): Window<Child, Attribute> => {
return DropdownMenu({
name: 'dashboardmenu',
transition: options.menus.transition.bind('value'),
child: Widget.Box({
class_name: 'dashboard-menu-content',
css: 'padding: 1px; margin: -1px;',
vexpand: false,
children: [
Widget.Box({
class_name: 'dashboard-content-container',
vertical: true,
children: Utils.merge(
[
controls.enabled.bind('value'),
shortcuts.enabled.bind('value'),
stats.enabled.bind('value'),
directories.enabled.bind('value'),
],
(isControlsEnabled, isShortcutsEnabled, isStatsEnabled, isDirectoriesEnabled) => {
return [
Widget.Box({
class_name: 'dashboard-content-items',
vertical: true,
children: [
Profile(),
...(isShortcutsEnabled ? [Shortcuts()] : []),
...(isControlsEnabled ? [Controls()] : []),
...(isDirectoriesEnabled ? [Directories()] : []),
...(isStatsEnabled ? [Stats()] : []),
],
}),
];
},
),
}),
],
}),
});
};
@@ -0,0 +1,110 @@
import powermenu from '../../power/helpers/actions.js';
import { PowerOptions } from 'lib/types/options.js';
import GdkPixbuf from 'gi://GdkPixbuf';
import options from 'options';
import { BoxWidget, Child } from 'lib/types/widget.js';
import Label from 'types/widgets/label.js';
const { image, name } = options.menus.dashboard.powermenu.avatar;
const { confirmation, shutdown, logout, sleep, reboot } = options.menus.dashboard.powermenu;
const Profile = (): BoxWidget => {
const handleClick = (action: PowerOptions): void => {
const actions = {
shutdown: shutdown.value,
reboot: reboot.value,
logout: logout.value,
sleep: sleep.value,
};
App.closeWindow('dashboardmenu');
if (!confirmation.value) {
Utils.execAsync(actions[action]).catch((err) =>
console.error(`Failed to execute ${action} command. Error: ${err}`),
);
} else {
powermenu.action(action);
}
};
const getIconForButton = (txtIcon: string): Label<Child> => {
return Widget.Label({
className: 'txt-icon',
label: txtIcon,
});
};
return Widget.Box({
class_name: 'profiles-container',
hpack: 'fill',
hexpand: true,
children: [
Widget.Box({
class_name: 'profile-picture-container dashboard-card',
hexpand: true,
vertical: true,
children: [
Widget.Box({
hpack: 'center',
class_name: 'profile-picture',
css: image.bind('value').as((i) => {
try {
GdkPixbuf.Pixbuf.new_from_file(i);
return `background-image: url("${i}")`;
} catch {
return `background-image: url("${App.configDir}/assets/hyprpanel.png")`;
}
}),
}),
Widget.Label({
hpack: 'center',
class_name: 'profile-name',
label: name.bind('value').as((v) => {
if (v === 'system') {
return Utils.exec('bash -c whoami');
}
return v;
}),
}),
],
}),
Widget.Box({
class_name: 'power-menu-container dashboard-card',
vertical: true,
vexpand: true,
children: [
Widget.Button({
class_name: 'dashboard-button shutdown',
on_clicked: () => handleClick('shutdown'),
tooltip_text: 'Shut Down',
vexpand: true,
child: getIconForButton('󰐥'),
}),
Widget.Button({
class_name: 'dashboard-button restart',
on_clicked: () => handleClick('reboot'),
tooltip_text: 'Restart',
vexpand: true,
child: getIconForButton('󰜉'),
}),
Widget.Button({
class_name: 'dashboard-button lock',
on_clicked: () => handleClick('logout'),
tooltip_text: 'Log Out',
vexpand: true,
child: getIconForButton('󰿅'),
}),
Widget.Button({
class_name: 'dashboard-button sleep',
on_clicked: () => handleClick('sleep'),
tooltip_text: 'Sleep',
vexpand: true,
child: getIconForButton('󰤄'),
}),
],
}),
],
});
};
export { Profile };
@@ -0,0 +1,317 @@
const hyprland = await Service.import('hyprland');
import { Attribute, BoxWidget, Child } from 'lib/types/widget';
import options from 'options';
import { Variable as VarType } from 'types/variable';
import Box from 'types/widgets/box';
import Button from 'types/widgets/button';
import Label from 'types/widgets/label';
const { left, right } = options.menus.dashboard.shortcuts;
const Shortcuts = (): BoxWidget => {
const isRecording = Variable(false, {
poll: [
1000,
`${App.configDir}/services/screen_record.sh status`,
(out): boolean => {
if (out === 'recording') {
return true;
}
return false;
},
],
});
const handleClick = (action: string, tOut: number = 250): void => {
App.closeWindow('dashboardmenu');
setTimeout(() => {
Utils.execAsync(action)
.then((res) => {
return res;
})
.catch((err) => err);
}, tOut);
};
const recordingDropdown = Widget.Menu({
class_name: 'dropdown recording',
hpack: 'fill',
hexpand: true,
setup: (self) => {
const renderMonitorList = (): void => {
const displays = hyprland.monitors.map((mon) => {
return Widget.MenuItem({
label: `Display ${mon.name}`,
on_activate: () => {
App.closeWindow('dashboardmenu');
Utils.execAsync(`${App.configDir}/services/screen_record.sh start ${mon.name}`).catch(
(err) => console.error(err),
);
},
});
});
// NOTE: This is disabled since window recording isn't available on wayland
// const apps = hyprland.clients.map((clt) => {
// return Widget.MenuItem({
// label: `${clt.class.charAt(0).toUpperCase() + clt.class.slice(1)} (Workspace ${clt.workspace.name})`,
// on_activate: () => {
// App.closeWindow('dashboardmenu');
// Utils.execAsync(
// `${App.configDir}/services/screen_record.sh start ${clt.focusHistoryID}`,
// ).catch((err) => console.error(err));
// },
// });
// });
self.children = [
...displays,
// Disabled since window recording isn't available on wayland
// ...apps
];
};
self.hook(hyprland, renderMonitorList, 'monitor-added');
self.hook(hyprland, renderMonitorList, 'monitor-removed');
},
});
type ShortcutFixed = {
tooltip: string;
command: string;
icon: string;
configurable: false;
};
type ShortcutVariable = {
tooltip: VarType<string>;
command: VarType<string>;
icon: VarType<string>;
configurable?: true;
};
type Shortcut = ShortcutFixed | ShortcutVariable;
const cmdLn = (sCut: ShortcutVariable): boolean => {
return sCut.command.value.length > 0;
};
const leftCardHidden = Variable(
!(cmdLn(left.shortcut1) || cmdLn(left.shortcut2) || cmdLn(left.shortcut3) || cmdLn(left.shortcut4)),
);
const createButton = (shortcut: Shortcut, className: string): Button<Label<Attribute>, Attribute> => {
if (shortcut.configurable !== false) {
return Widget.Button({
vexpand: true,
tooltip_text: shortcut.tooltip.value,
class_name: className,
on_primary_click: () => handleClick(shortcut.command.value),
child: Widget.Label({
class_name: 'button-label txt-icon',
label: shortcut.icon.value,
}),
});
} else {
// handle non-configurable shortcut
return Widget.Button({
vexpand: true,
tooltip_text: shortcut.tooltip,
class_name: className,
on_primary_click: (_, event) => {
if (shortcut.command === 'settings-dialog') {
App.closeWindow('dashboardmenu');
App.toggleWindow('settings-dialog');
} else if (shortcut.command === 'record') {
if (isRecording.value === true) {
App.closeWindow('dashboardmenu');
return Utils.execAsync(`${App.configDir}/services/screen_record.sh stop`).catch((err) =>
console.error(err),
);
} else {
recordingDropdown.popup_at_pointer(event);
}
}
},
child: Widget.Label({
class_name: 'button-label txt-icon',
label: shortcut.icon,
}),
});
}
};
const createButtonIfCommandExists = (
shortcut: Shortcut,
className: string,
command: string,
): Button<Label<Attribute>, Attribute> | Box<Child, Attribute> => {
if (command.length > 0) {
return createButton(shortcut, className);
}
return Widget.Box();
};
return Widget.Box({
class_name: 'shortcuts-container',
hpack: 'fill',
hexpand: true,
children: [
Widget.Box({
child: Utils.merge(
[
left.shortcut1.command.bind('value'),
left.shortcut2.command.bind('value'),
left.shortcut1.tooltip.bind('value'),
left.shortcut2.tooltip.bind('value'),
left.shortcut1.icon.bind('value'),
left.shortcut2.icon.bind('value'),
left.shortcut3.command.bind('value'),
left.shortcut4.command.bind('value'),
left.shortcut3.tooltip.bind('value'),
left.shortcut4.tooltip.bind('value'),
left.shortcut3.icon.bind('value'),
left.shortcut4.icon.bind('value'),
],
() => {
const isVisibleLeft = cmdLn(left.shortcut1) || cmdLn(left.shortcut2);
const isVisibleRight = cmdLn(left.shortcut3) || cmdLn(left.shortcut4);
if (!isVisibleLeft && !isVisibleRight) {
leftCardHidden.value = true;
return Widget.Box();
}
leftCardHidden.value = false;
return Widget.Box({
class_name: 'container most-used dashboard-card',
children: [
Widget.Box({
className: `card-button-section-container ${isVisibleRight && isVisibleLeft ? 'visible' : ''}`,
child: isVisibleLeft
? Widget.Box({
vertical: true,
hexpand: true,
vexpand: true,
children: [
createButtonIfCommandExists(
left.shortcut1,
`dashboard-button top-button ${cmdLn(left.shortcut2) ? 'paired' : ''}`,
left.shortcut1.command.value,
),
createButtonIfCommandExists(
left.shortcut2,
'dashboard-button',
left.shortcut2.command.value,
),
],
})
: Widget.Box({
children: [],
}),
}),
Widget.Box({
className: 'card-button-section-container',
child: isVisibleRight
? Widget.Box({
vertical: true,
hexpand: true,
vexpand: true,
children: [
createButtonIfCommandExists(
left.shortcut3,
`dashboard-button top-button ${cmdLn(left.shortcut4) ? 'paired' : ''}`,
left.shortcut3.command.value,
),
createButtonIfCommandExists(
left.shortcut4,
'dashboard-button',
left.shortcut4.command.value,
),
],
})
: Widget.Box({
children: [],
}),
}),
],
});
},
),
}),
Widget.Box({
child: Utils.merge(
[
right.shortcut1.command.bind('value'),
right.shortcut1.tooltip.bind('value'),
right.shortcut1.icon.bind('value'),
right.shortcut3.command.bind('value'),
right.shortcut3.tooltip.bind('value'),
right.shortcut3.icon.bind('value'),
leftCardHidden.bind('value'),
isRecording.bind('value'),
],
() => {
return Widget.Box({
class_name: `container utilities dashboard-card ${!leftCardHidden.value ? 'paired' : ''}`,
children: [
Widget.Box({
className: `card-button-section-container visible`,
child: Widget.Box({
vertical: true,
hexpand: true,
vexpand: true,
children: [
createButtonIfCommandExists(
right.shortcut1,
'dashboard-button top-button paired',
right.shortcut1.command.value,
),
createButtonIfCommandExists(
{
tooltip: 'HyprPanel Configuration',
command: 'settings-dialog',
icon: '󰒓',
configurable: false,
},
'dashboard-button',
'settings-dialog',
),
],
}),
}),
Widget.Box({
className: 'card-button-section-container',
child: Widget.Box({
vertical: true,
hexpand: true,
vexpand: true,
children: [
createButtonIfCommandExists(
right.shortcut3,
'dashboard-button top-button paired',
right.shortcut3.command.value,
),
createButtonIfCommandExists(
{
tooltip: 'Record Screen',
command: 'record',
icon: '󰑊',
configurable: false,
},
`dashboard-button record ${isRecording.value ? 'active' : ''}`,
'record',
),
],
}),
}),
],
});
},
),
}),
],
});
};
export { Shortcuts };
@@ -0,0 +1,362 @@
import options from 'options';
import { GPU_Stat } from 'lib/types/gpustat';
import { dependencies } from 'lib/utils';
import { BoxWidget } from 'lib/types/widget';
import { GenericResourceMetrics } from 'lib/types/customModules/generic';
const { terminal } = options;
const { enable_gpu } = options.menus.dashboard.stats;
const Stats = (): BoxWidget => {
const divide = ([total, free]: number[]): number => free / total;
const formatSizeInGB = (sizeInKB: number): number => Number((sizeInKB / 1024 ** 2).toFixed(2));
const cpu = Variable(0, {
poll: [
2000,
'top -b -n 1',
(out): number => {
if (typeof out !== 'string') {
return 0;
}
const cpuOut = out.split('\n').find((line) => line.includes('Cpu(s)'));
if (cpuOut === undefined) {
return 0;
}
const freeCpu = parseFloat(cpuOut.split(/\s+/)[1].replace(',', '.'));
return divide([100, freeCpu]);
},
],
});
const ram = Variable(
{ total: 0, used: 0, percentage: 0 },
{
poll: [
2000,
'free',
(out): GenericResourceMetrics => {
if (typeof out !== 'string') {
return { total: 0, used: 0, percentage: 0 };
}
const ramOut = out.split('\n').find((line) => line.includes('Mem:'));
if (ramOut === undefined) {
return { total: 0, used: 0, percentage: 0 };
}
const [totalRam, usedRam] = ramOut.split(/\s+/).splice(1, 2).map(Number);
return {
percentage: divide([totalRam, usedRam]),
total: formatSizeInGB(totalRam),
used: formatSizeInGB(usedRam),
};
},
],
},
);
const gpu = Variable(0);
const GPUStat = Widget.Box({
child: enable_gpu.bind('value').as((gpStat) => {
if (!gpStat || !dependencies('gpustat')) {
return Widget.Box();
}
return Widget.Box({
vertical: true,
children: [
Widget.Box({
class_name: 'stat gpu',
hexpand: true,
vpack: 'center',
setup: (self) => {
const getGpuUsage = (): void => {
if (!enable_gpu.value) {
gpu.value = 0;
return;
}
Utils.execAsync('gpustat --json')
.then((out) => {
if (typeof out !== 'string') {
return 0;
}
try {
const data = JSON.parse(out);
const totalGpu = 100;
const usedGpu =
data.gpus.reduce((acc: number, gpu: GPU_Stat) => {
return acc + gpu['utilization.gpu'];
}, 0) / data.gpus.length;
gpu.value = divide([totalGpu, usedGpu]);
} catch (e) {
console.error('Error getting GPU stats:', e);
gpu.value = 0;
}
})
.catch((err) => {
console.error(`An error occurred while fetching GPU stats: ${err}`);
});
};
self.poll(2000, getGpuUsage);
Utils.merge([gpu.bind('value'), enable_gpu.bind('value')], (gpu, enableGpu) => {
if (!enableGpu) {
return (self.children = []);
}
return (self.children = [
Widget.Button({
on_primary_click: terminal.bind('value').as((term) => {
return (): void => {
App.closeWindow('dashboardmenu');
Utils.execAsync(`bash -c "${term} -e btop"`).catch(
(err) => `Failed to open btop: ${err}`,
);
};
}),
child: Widget.Label({
class_name: 'txt-icon',
label: '󰢮',
}),
}),
Widget.Button({
on_primary_click: terminal.bind('value').as((term) => {
return (): void => {
App.closeWindow('dashboardmenu');
Utils.execAsync(`bash -c "${term} -e btop"`).catch(
(err) => `Failed to open btop: ${err}`,
);
};
}),
child: Widget.LevelBar({
class_name: 'stats-bar',
hexpand: true,
vpack: 'center',
value: gpu,
}),
}),
]);
});
},
}),
Widget.Box({
hpack: 'end',
children: Utils.merge([gpu.bind('value'), enable_gpu.bind('value')], (gpuUsed, enableGpu) => {
if (!enableGpu) {
return [];
}
return [
Widget.Label({
class_name: 'stat-value gpu',
label: `${Math.floor(gpuUsed * 100)}%`,
}),
];
}),
}),
],
});
}),
});
const storage = Variable(
{ total: 0, used: 0, percentage: 0 },
{
poll: [
2000,
'df -B1 /',
(out): GenericResourceMetrics => {
if (typeof out !== 'string') {
return { total: 0, used: 0, percentage: 0 };
}
const dfOut = out.split('\n').find((line) => line.startsWith('/'));
if (dfOut === undefined) {
return { total: 0, used: 0, percentage: 0 };
}
const parts = dfOut.split(/\s+/);
const size = parseInt(parts[1], 10);
const used = parseInt(parts[2], 10);
const sizeInGB = formatSizeInGB(size);
const usedInGB = formatSizeInGB(used);
return {
total: Math.floor(sizeInGB / 1000),
used: Math.floor(usedInGB / 1000),
percentage: divide([size, used]),
};
},
],
},
);
return Widget.Box({
class_name: 'dashboard-card stats-container',
vertical: true,
vpack: 'fill',
hpack: 'fill',
expand: true,
children: [
Widget.Box({
vertical: true,
children: [
Widget.Box({
class_name: 'stat cpu',
hexpand: true,
vpack: 'center',
children: [
Widget.Button({
on_primary_click: terminal.bind('value').as((term) => {
return () => {
App.closeWindow('dashboardmenu');
Utils.execAsync(`bash -c "${term} -e btop"`).catch(
(err) => `Failed to open btop: ${err}`,
);
};
}),
child: Widget.Label({
class_name: 'txt-icon',
label: '',
}),
}),
Widget.Button({
on_primary_click: terminal.bind('value').as((term) => {
return () => {
App.closeWindow('dashboardmenu');
Utils.execAsync(`bash -c "${term} -e btop"`).catch(
(err) => `Failed to open btop: ${err}`,
);
};
}),
child: Widget.LevelBar({
class_name: 'stats-bar',
hexpand: true,
vpack: 'center',
bar_mode: 'continuous',
max_value: 1,
value: cpu.bind('value'),
}),
}),
],
}),
Widget.Label({
hpack: 'end',
class_name: 'stat-value cpu',
label: cpu.bind('value').as((v) => `${Math.floor(v * 100)}%`),
}),
],
}),
Widget.Box({
vertical: true,
children: [
Widget.Box({
class_name: 'stat ram',
vpack: 'center',
hexpand: true,
children: [
Widget.Button({
on_primary_click: terminal.bind('value').as((term) => {
return () => {
App.closeWindow('dashboardmenu');
Utils.execAsync(`bash -c "${term} -e btop"`).catch(
(err) => `Failed to open btop: ${err}`,
);
};
}),
child: Widget.Label({
class_name: 'txt-icon',
label: '',
}),
}),
Widget.Button({
on_primary_click: terminal.bind('value').as((term) => {
return () => {
App.closeWindow('dashboardmenu');
Utils.execAsync(`bash -c "${term} -e btop"`).catch(
(err) => `Failed to open btop: ${err}`,
);
};
}),
child: Widget.LevelBar({
class_name: 'stats-bar',
hexpand: true,
vpack: 'center',
value: ram.bind('value').as((v) => v.percentage),
}),
}),
],
}),
Widget.Label({
hpack: 'end',
class_name: 'stat-value ram',
label: ram.bind('value').as((v) => `${v.used}/${v.total} GB`),
}),
],
}),
GPUStat,
Widget.Box({
vertical: true,
children: [
Widget.Box({
class_name: 'stat storage',
hexpand: true,
vpack: 'center',
children: [
Widget.Button({
on_primary_click: terminal.bind('value').as((term) => {
return () => {
App.closeWindow('dashboardmenu');
Utils.execAsync(`bash -c "${term} -e btop"`).catch(
(err) => `Failed to open btop: ${err}`,
);
};
}),
child: Widget.Label({
class_name: 'txt-icon',
label: '󰋊',
}),
}),
Widget.Button({
on_primary_click: terminal.bind('value').as((term) => {
return () => {
App.closeWindow('dashboardmenu');
Utils.execAsync(`bash -c "${term} -e btop"`).catch(
(err) => `Failed to open btop: ${err}`,
);
};
}),
child: Widget.LevelBar({
class_name: 'stats-bar',
hexpand: true,
vpack: 'center',
value: storage.bind('value').as((v) => v.percentage),
}),
}),
],
}),
Widget.Label({
hpack: 'end',
class_name: 'stat-value storage',
label: storage.bind('value').as((v) => `${v.used}/${v.total} GB`),
}),
],
}),
],
});
};
export { Stats };
@@ -0,0 +1,58 @@
import { BoxWidget } from 'lib/types/widget.js';
import brightness from '../../../../services/Brightness.js';
import icons from '../../../icons/index.js';
const Brightness = (): BoxWidget => {
return Widget.Box({
class_name: 'menu-section-container brightness',
vertical: true,
children: [
Widget.Box({
class_name: 'menu-label-container',
hpack: 'fill',
child: Widget.Label({
class_name: 'menu-label',
hexpand: true,
hpack: 'start',
label: 'Brightness',
}),
}),
Widget.Box({
class_name: 'menu-items-section',
vpack: 'fill',
vexpand: true,
vertical: true,
child: Widget.Box({
class_name: 'brightness-container',
children: [
Widget.Icon({
vexpand: true,
vpack: 'center',
class_name: 'brightness-slider-icon',
icon: icons.brightness.screen,
}),
Widget.Slider({
vpack: 'center',
vexpand: true,
value: brightness.bind('screen'),
class_name: 'menu-active-slider menu-slider brightness',
draw_value: false,
hexpand: true,
min: 0,
max: 1,
onChange: ({ value }) => (brightness.screen = value),
}),
Widget.Label({
vpack: 'center',
vexpand: true,
class_name: 'brightness-slider-label',
label: brightness.bind('screen').as((b) => `${Math.round(b * 100)}%`),
}),
],
}),
}),
],
});
};
export { Brightness };
@@ -0,0 +1,25 @@
import DropdownMenu from '../shared/dropdown/index.js';
import { EnergyProfiles } from './profiles/index.js';
import { Brightness } from './brightness/index.js';
import { Attribute, Child } from 'lib/types/widget.js';
import Window from 'types/widgets/window.js';
import options from 'options.js';
export default (): Window<Child, Attribute> => {
return DropdownMenu({
name: 'energymenu',
transition: options.menus.transition.bind('value'),
child: Widget.Box({
class_name: 'menu-items energy',
hpack: 'fill',
hexpand: true,
child: Widget.Box({
vertical: true,
hpack: 'fill',
hexpand: true,
class_name: 'menu-items-container energy',
children: [Brightness(), EnergyProfiles()],
}),
}),
});
};
@@ -0,0 +1,85 @@
const powerProfiles = await Service.import('powerprofiles');
import { PowerProfile, PowerProfileObject, PowerProfiles } from 'lib/types/powerprofiles.js';
import { BoxWidget } from 'lib/types/widget.js';
import icons from '../../../icons/index.js';
import { uptime } from 'lib/variables.js';
const EnergyProfiles = (): BoxWidget => {
const isValidProfile = (profile: string): profile is PowerProfile =>
profile === 'power-saver' || profile === 'balanced' || profile === 'performance';
function renderUptime(curUptime: number): string {
const days = Math.floor(curUptime / (60 * 24));
const hours = Math.floor((curUptime % (60 * 24)) / 60);
const minutes = Math.floor(curUptime % 60);
return ` : ${days}d ${hours}h ${minutes}m`;
}
return Widget.Box({
class_name: 'menu-section-container energy',
vertical: true,
children: [
Widget.Box({
class_name: 'menu-label-container',
hpack: 'fill',
children: [
Widget.Label({
class_name: 'menu-label',
hexpand: true,
hpack: 'start',
label: 'Power Profile',
}),
Widget.Label({
class_name: 'menu-label uptime',
label: uptime.bind().as(renderUptime),
tooltipText: 'Uptime',
}),
],
}),
Widget.Box({
class_name: 'menu-items-section',
vpack: 'fill',
vexpand: true,
vertical: true,
children: powerProfiles.bind('profiles').as((profiles: PowerProfiles) => {
return profiles.map((prof: PowerProfileObject) => {
const profileLabels = {
'power-saver': 'Power Saver',
balanced: 'Balanced',
performance: 'Performance',
};
const profileType = prof.Profile;
if (!isValidProfile(profileType)) {
return profileLabels.balanced;
}
return Widget.Button({
on_primary_click: () => {
powerProfiles.active_profile = prof.Profile;
},
class_name: powerProfiles.bind('active_profile').as((active) => {
return `power-profile-item ${active === prof.Profile ? 'active' : ''}`;
}),
child: Widget.Box({
children: [
Widget.Icon({
class_name: 'power-profile-icon',
icon: icons.powerprofile[profileType],
}),
Widget.Label({
class_name: 'power-profile-label',
label: profileLabels[profileType],
}),
],
}),
});
});
}),
}),
],
});
};
export { EnergyProfiles };
+25
View File
@@ -0,0 +1,25 @@
import PowerMenu from './power/index.js';
import Verification from './power/verification.js';
import AudioMenu from './audio/index.js';
import NetworkMenu from './network/index.js';
import BluetoothMenu from './bluetooth/index.js';
import MediaMenu from './media/index.js';
import NotificationsMenu from './notifications/index.js';
import CalendarMenu from './calendar/index.js';
import EnergyMenu from './energy/index.js';
import DashboardMenu from './dashboard/index.js';
import PowerDropdown from './powerDropdown/index.js';
export default [
PowerMenu(),
Verification(),
AudioMenu(),
NetworkMenu(),
BluetoothMenu(),
MediaMenu(),
NotificationsMenu(),
CalendarMenu(),
EnergyMenu(),
DashboardMenu(),
PowerDropdown(),
];
@@ -0,0 +1,71 @@
import { BoxWidget } from 'lib/types/widget';
import { Mpris, MprisPlayer } from 'types/service/mpris';
const media = await Service.import('mpris');
const Bar = (getPlayerInfo: (media: Mpris) => MprisPlayer): BoxWidget => {
return Widget.Box({
class_name: 'media-indicator-current-progress-bar',
hexpand: true,
children: [
Widget.Box({
hexpand: true,
child: Widget.Slider({
hexpand: true,
tooltip_text: '--',
class_name: 'menu-slider media progress',
draw_value: false,
on_change: ({ value }) => {
const foundPlayer = getPlayerInfo(media);
if (foundPlayer === undefined) {
return;
}
return (foundPlayer.position = value * foundPlayer.length);
},
setup: (self) => {
const update = (): void => {
const foundPlayer = getPlayerInfo(media);
if (foundPlayer !== undefined) {
const value = foundPlayer.length ? foundPlayer.position / foundPlayer.length : 0;
self.value = value > 0 ? value : 0;
} else {
self.value = 0;
}
};
self.hook(media, update);
self.poll(1000, update);
const updateTooltip = (): void => {
const foundPlayer = getPlayerInfo(media);
if (foundPlayer === undefined) {
self.tooltip_text = '00:00';
return;
}
const curHour = Math.floor(foundPlayer.position / 3600);
const curMin = Math.floor((foundPlayer.position % 3600) / 60);
const curSec = Math.floor(foundPlayer.position % 60);
if (typeof foundPlayer.position === 'number' && foundPlayer.position >= 0) {
const formatTime = (time: number): string => {
return time.toString().padStart(2, '0');
};
const formatHour = (hour: number): string => {
return hour > 0 ? formatTime(hour) + ':' : '';
};
self.tooltip_text = `${formatHour(curHour)}${formatTime(curMin)}:${formatTime(curSec)}`;
} else {
self.tooltip_text = `00:00`;
}
};
self.poll(1000, updateTooltip);
self.hook(media, updateTooltip);
},
}),
}),
],
});
};
export { Bar };
@@ -0,0 +1,198 @@
import { MprisPlayer } from 'types/service/mpris.js';
import icons from '../../../icons/index.js';
import { LoopStatus, PlaybackStatus } from 'lib/types/mpris.js';
import { BoxWidget } from 'lib/types/widget.js';
const media = await Service.import('mpris');
const Controls = (getPlayerInfo: () => MprisPlayer): BoxWidget => {
const isValidLoopStatus = (status: string): status is LoopStatus => ['none', 'track', 'playlist'].includes(status);
const isValidPlaybackStatus = (status: string): status is PlaybackStatus =>
['playing', 'paused', 'stopped'].includes(status);
const isLoopActive = (player: MprisPlayer): string => {
return player['loop_status'] !== null && ['track', 'playlist'].includes(player['loop_status'].toLowerCase())
? 'active'
: '';
};
const isShuffleActive = (player: MprisPlayer): string => {
return player['shuffle_status'] !== null && player['shuffle_status'] ? 'active' : '';
};
return Widget.Box({
class_name: 'media-indicator-current-player-controls',
vertical: true,
children: [
Widget.Box({
class_name: 'media-indicator-current-controls',
hpack: 'center',
children: [
Widget.Box({
class_name: 'media-indicator-control shuffle',
children: [
Widget.Button({
hpack: 'center',
hasTooltip: true,
setup: (self) => {
self.hook(media, () => {
const foundPlayer = getPlayerInfo();
if (foundPlayer === undefined) {
self.tooltip_text = 'Unavailable';
self.class_name = 'media-indicator-control-button shuffle disabled';
return;
}
self.tooltip_text =
foundPlayer.shuffle_status !== null
? foundPlayer.shuffle_status
? 'Shuffling'
: 'Not Shuffling'
: null;
self.on_primary_click = (): void => {
foundPlayer.shuffle();
};
self.class_name = `media-indicator-control-button shuffle ${isShuffleActive(foundPlayer)} ${foundPlayer.shuffle_status !== null ? 'enabled' : 'disabled'}`;
});
},
child: Widget.Icon(icons.mpris.shuffle['enabled']),
}),
],
}),
Widget.Box({
children: [
Widget.Button({
hpack: 'center',
child: Widget.Icon(icons.mpris.prev),
setup: (self) => {
self.hook(media, () => {
const foundPlayer = getPlayerInfo();
if (foundPlayer === undefined) {
self.class_name = 'media-indicator-control-button prev disabled';
return;
}
self.on_primary_click = (): void => {
foundPlayer.previous();
};
self.class_name = `media-indicator-control-button prev ${foundPlayer.can_go_prev !== null && foundPlayer.can_go_prev ? 'enabled' : 'disabled'}`;
});
},
}),
],
}),
Widget.Box({
children: [
Widget.Button({
hpack: 'center',
setup: (self) => {
self.hook(media, () => {
const foundPlayer = getPlayerInfo();
if (foundPlayer === undefined) {
self.class_name = 'media-indicator-control-button play disabled';
return;
}
self.on_primary_click = (): void => {
foundPlayer.playPause();
};
self.class_name = `media-indicator-control-button play ${foundPlayer.can_play !== null ? 'enabled' : 'disabled'}`;
});
},
child: Widget.Icon({
icon: Utils.watch(icons.mpris.paused, media, 'changed', () => {
const foundPlayer: MprisPlayer = getPlayerInfo();
if (foundPlayer === undefined) {
return icons.mpris['paused'];
}
const playbackStatus = foundPlayer.play_back_status?.toLowerCase();
if (playbackStatus && isValidPlaybackStatus(playbackStatus)) {
return icons.mpris[playbackStatus];
} else {
return icons.mpris['paused'];
}
}),
}),
}),
],
}),
Widget.Box({
class_name: `media-indicator-control next`,
children: [
Widget.Button({
hpack: 'center',
child: Widget.Icon(icons.mpris.next),
setup: (self) => {
self.hook(media, () => {
const foundPlayer = getPlayerInfo();
if (foundPlayer === undefined) {
self.class_name = 'media-indicator-control-button next disabled';
return;
}
self.on_primary_click = (): void => {
foundPlayer.next();
};
self.class_name = `media-indicator-control-button next ${foundPlayer.can_go_next !== null && foundPlayer.can_go_next ? 'enabled' : 'disabled'}`;
});
},
}),
],
}),
Widget.Box({
class_name: 'media-indicator-control loop',
children: [
Widget.Button({
hpack: 'center',
setup: (self) => {
self.hook(media, () => {
const foundPlayer = getPlayerInfo();
if (foundPlayer === undefined) {
self.tooltip_text = 'Unavailable';
self.class_name = 'media-indicator-control-button shuffle disabled';
return;
}
self.tooltip_text =
foundPlayer.loop_status !== null
? foundPlayer.loop_status
? 'Shuffling'
: 'Not Shuffling'
: null;
self.on_primary_click = (): void => {
foundPlayer.loop();
};
self.class_name = `media-indicator-control-button loop ${isLoopActive(foundPlayer)} ${foundPlayer.loop_status !== null ? 'enabled' : 'disabled'}`;
});
},
child: Widget.Icon({
setup: (self) => {
self.hook(media, () => {
const foundPlayer: MprisPlayer = getPlayerInfo();
if (foundPlayer === undefined) {
self.icon = icons.mpris.loop['none'];
return;
}
const loopStatus = foundPlayer.loop_status?.toLowerCase();
if (loopStatus && isValidLoopStatus(loopStatus)) {
self.icon = icons.mpris.loop[loopStatus];
} else {
self.icon = icons.mpris.loop['none'];
}
});
},
}),
}),
],
}),
],
}),
],
});
};
export { Controls };
@@ -0,0 +1,89 @@
import { BoxWidget } from 'lib/types/widget';
import { MprisPlayer } from 'types/service/mpris';
const media = await Service.import('mpris');
const MediaInfo = (getPlayerInfo: () => MprisPlayer): BoxWidget => {
return Widget.Box({
class_name: 'media-indicator-current-media-info',
hpack: 'center',
hexpand: true,
vertical: true,
children: [
Widget.Box({
class_name: 'media-indicator-current-song-name',
hpack: 'center',
children: [
Widget.Label({
truncate: 'end',
max_width_chars: 31,
wrap: true,
class_name: 'media-indicator-current-song-name-label',
setup: (self) => {
self.hook(media, () => {
const curPlayer = getPlayerInfo();
return (self.label =
curPlayer !== undefined && curPlayer['track_title'].length
? curPlayer['track_title']
: 'No Media Currently Playing');
});
},
}),
],
}),
Widget.Box({
class_name: 'media-indicator-current-song-author',
hpack: 'center',
children: [
Widget.Label({
truncate: 'end',
wrap: true,
max_width_chars: 35,
class_name: 'media-indicator-current-song-author-label',
setup: (self) => {
self.hook(media, () => {
const curPlayer = getPlayerInfo();
const makeArtistList = (trackArtists: string[]): string => {
if (trackArtists.length === 1 && !trackArtists[0].length) {
return '-----';
}
return trackArtists.join(', ');
};
return (self.label =
curPlayer !== undefined && curPlayer['track_artists'].length
? makeArtistList(curPlayer['track_artists'])
: '-----');
});
},
}),
],
}),
Widget.Box({
class_name: 'media-indicator-current-song-album',
hpack: 'center',
children: [
Widget.Label({
truncate: 'end',
wrap: true,
max_width_chars: 40,
class_name: 'media-indicator-current-song-album-label',
setup: (self) => {
self.hook(media, () => {
const curPlayer = getPlayerInfo();
return (self.label =
curPlayer !== undefined && curPlayer['track_album'].length
? curPlayer['track_album']
: '---');
});
},
}),
],
}),
],
});
};
export { MediaInfo };
@@ -0,0 +1,23 @@
import Window from 'types/widgets/window.js';
import DropdownMenu from '../shared/dropdown/index.js';
import { Media } from './media.js';
import { Attribute, Child } from 'lib/types/widget.js';
import options from 'options.js';
export default (): Window<Child, Attribute> => {
return DropdownMenu({
name: 'mediamenu',
transition: options.menus.transition.bind('value'),
child: Widget.Box({
class_name: 'menu-items media',
hpack: 'fill',
hexpand: true,
child: Widget.Box({
class_name: 'menu-items-container media',
hpack: 'fill',
hexpand: true,
child: Media(),
}),
}),
});
};
@@ -0,0 +1,97 @@
const media = await Service.import('mpris');
import { MediaInfo } from './components/mediainfo.js';
import { Controls } from './components/controls.js';
import { Bar } from './components/bar.js';
import { MprisPlayer } from 'types/service/mpris.js';
import options from 'options.js';
import { BoxWidget } from 'lib/types/widget.js';
const { tint, color } = options.theme.bar.menus.menu.media.card;
const generateAlbumArt = (imageUrl: string): string => {
const userTint = tint.value;
const userHexColor = color.value;
const r = parseInt(userHexColor.slice(1, 3), 16);
const g = parseInt(userHexColor.slice(3, 5), 16);
const b = parseInt(userHexColor.slice(5, 7), 16);
const alpha = userTint / 100;
const css = `background-image: linear-gradient(
rgba(${r}, ${g}, ${b}, ${alpha}),
rgba(${r}, ${g}, ${b}, ${alpha}),
${userHexColor} 65em
), url("${imageUrl}");`;
return css;
};
const Media = (): BoxWidget => {
const curPlayer = Variable('');
media.connect('changed', () => {
const statusOrder = {
Playing: 1,
Paused: 2,
Stopped: 3,
};
const isPlaying = media.players.find((p) => p['play_back_status'] === 'Playing');
const playerStillExists = media.players.some((p) => curPlayer.value === p['bus_name']);
const nextPlayerUp = media.players.sort(
(a, b) => statusOrder[a['play_back_status']] - statusOrder[b['play_back_status']],
)[0].bus_name;
if (isPlaying || !playerStillExists) {
curPlayer.value = nextPlayerUp;
}
});
const getPlayerInfo = (): MprisPlayer => {
return media.players.find((p) => p.bus_name === curPlayer.value) || media.players[0];
};
return Widget.Box({
class_name: 'menu-section-container',
children: [
Widget.Box({
class_name: 'menu-items-section',
vertical: false,
child: Widget.Box({
class_name: 'menu-content',
children: [
Widget.Box({
class_name: 'media-content',
child: Widget.Box({
class_name: 'media-indicator-right-section',
hpack: 'fill',
hexpand: true,
vertical: true,
children: [MediaInfo(getPlayerInfo), Controls(getPlayerInfo), Bar(getPlayerInfo)],
}),
}),
],
setup: (self) => {
self.hook(media, () => {
const curPlayer = getPlayerInfo();
if (curPlayer !== undefined) {
self.css = generateAlbumArt(curPlayer.track_cover_url);
}
});
Utils.merge([color.bind('value'), tint.bind('value')], () => {
const curPlayer = getPlayerInfo();
if (curPlayer !== undefined) {
self.css = generateAlbumArt(curPlayer.track_cover_url);
}
});
},
}),
}),
],
});
};
export { Media };
@@ -0,0 +1,69 @@
import { BoxWidget } from 'lib/types/widget';
const network = await Service.import('network');
const Ethernet = (): BoxWidget => {
return Widget.Box({
class_name: 'menu-section-container ethernet',
vertical: true,
children: [
Widget.Box({
class_name: 'menu-label-container',
hpack: 'fill',
child: Widget.Label({
class_name: 'menu-label',
hexpand: true,
hpack: 'start',
label: 'Ethernet',
}),
}),
Widget.Box({
class_name: 'menu-items-section',
vertical: true,
child: Widget.Box({
class_name: 'menu-content',
vertical: true,
setup: (self) => {
self.hook(network, () => {
return (self.child = Widget.Box({
class_name: 'network-element-item',
child: Widget.Box({
hpack: 'start',
children: [
Widget.Icon({
class_name: `network-icon ethernet ${network.wired.state === 'activated' ? 'active' : ''}`,
tooltip_text: network.wired.internet,
icon: `${network.wired['icon_name']}`,
}),
Widget.Box({
class_name: 'connection-container',
vertical: true,
children: [
Widget.Label({
class_name: 'active-connection',
hpack: 'start',
truncate: 'end',
wrap: true,
label: `Ethernet Connection ${network.wired.state !== 'unknown' && typeof network.wired?.speed === 'number' ? `(${network.wired?.speed / 1000} Gbps)` : ''}`,
}),
Widget.Label({
hpack: 'start',
class_name: 'connection-status dim',
label:
network.wired.internet.charAt(0).toUpperCase() +
network.wired.internet.slice(1),
}),
],
}),
],
}),
}));
});
},
}),
}),
],
});
};
export { Ethernet };
@@ -0,0 +1,22 @@
import Window from 'types/widgets/window.js';
import DropdownMenu from '../shared/dropdown/index.js';
import { Ethernet } from './ethernet/index.js';
import { Wifi } from './wifi/index.js';
import { Attribute, Child } from 'lib/types/widget.js';
import options from 'options.js';
export default (): Window<Child, Attribute> => {
return DropdownMenu({
name: 'networkmenu',
transition: options.menus.transition.bind('value'),
child: Widget.Box({
class_name: 'menu-items network',
child: Widget.Box({
vertical: true,
hexpand: true,
class_name: 'menu-items-container network',
children: [Ethernet(), Wifi()],
}),
}),
});
};
@@ -0,0 +1,23 @@
import { WifiIcon } from 'lib/types/network';
const getWifiIcon = (iconName: string): WifiIcon => {
const deviceIconMap: [string, WifiIcon][] = [
['network-wireless-acquiring', '󰤩'],
['network-wireless-connected', '󰤨'],
['network-wireless-encrypted', '󰤪'],
['network-wireless-hotspot', '󰤨'],
['network-wireless-no-route', '󰤩'],
['network-wireless-offline', '󰤮'],
['network-wireless-signal-excellent', '󰤨'],
['network-wireless-signal-good', '󰤥'],
['network-wireless-signal-ok', '󰤢'],
['network-wireless-signal-weak', '󰤟'],
['network-wireless-signal-none', '󰤯'],
];
const foundMatch = deviceIconMap.find((icon) => RegExp(icon[0]).test(iconName.toLowerCase()));
return foundMatch ? foundMatch[1] : '󰤨';
};
export { getWifiIcon };
@@ -0,0 +1,104 @@
import { Network } from 'types/service/network';
import { Variable } from 'types/variable';
import { AccessPoint } from 'lib/types/network';
import Box from 'types/widgets/box';
import { Attribute, Child } from 'lib/types/widget';
const renderWapStaging = (
self: Box<Child, Attribute>,
network: Network,
staging: Variable<AccessPoint>,
connecting: Variable<string>,
): void => {
Utils.merge([network.bind('wifi'), staging.bind('value')], () => {
if (!Object.keys(staging.value).length) {
return (self.child = Widget.Box());
}
return (self.child = Widget.Box({
class_name: 'network-element-item staging',
vertical: true,
children: [
Widget.Box({
hpack: 'fill',
hexpand: true,
children: [
Widget.Icon({
class_name: `network-icon wifi`,
icon: `${staging.value.iconName}`,
}),
Widget.Box({
class_name: 'connection-container',
hexpand: true,
vertical: true,
children: [
Widget.Label({
class_name: 'active-connection',
hpack: 'start',
truncate: 'end',
wrap: true,
label: staging.value.ssid,
}),
],
}),
Widget.Revealer({
hpack: 'end',
reveal_child: connecting.bind('value').as((c) => staging.value.bssid === c),
child: Widget.Spinner({
class_name: 'spinner wap',
}),
}),
],
}),
Widget.Box({
class_name: 'network-password-input-container',
hpack: 'fill',
hexpand: true,
children: [
Widget.Entry({
hpack: 'start',
hexpand: true,
visibility: false,
class_name: 'network-password-input',
placeholder_text: 'enter password',
onAccept: (selfInp) => {
connecting.value = staging.value.bssid || '';
Utils.execAsync(
`nmcli dev wifi connect ${staging.value.bssid} password ${selfInp.text}`,
)
.catch((err) => {
connecting.value = '';
console.error(`Failed to connect to wifi: ${staging.value.ssid}... ${err}`);
Utils.notify({
summary: 'Network',
body: err,
timeout: 5000,
});
})
.then(() => {
connecting.value = '';
staging.value = {} as AccessPoint;
});
selfInp.text = '';
},
}),
Widget.Button({
hpack: 'end',
class_name: 'close-network-password-input-button',
on_primary_click: () => {
connecting.value = '';
staging.value = {} as AccessPoint;
},
child: Widget.Icon({
class_name: 'close-network-password-input-icon',
icon: 'window-close-symbolic',
}),
}),
],
}),
],
}));
});
};
export { renderWapStaging };
@@ -0,0 +1,219 @@
import { Network } from 'types/service/network.js';
import { AccessPoint, WifiStatus } from 'lib/types/network.js';
import { Variable } from 'types/variable.js';
import { getWifiIcon } from '../utils.js';
import { WIFI_STATUS_MAP } from 'globals/network.js';
import { Attribute, Child } from 'lib/types/widget.js';
import Box from 'types/widgets/box.js';
const renderWAPs = (
self: Box<Child, Attribute>,
network: Network,
staging: Variable<AccessPoint>,
connecting: Variable<string>,
): void => {
const getIdBySsid = (ssid: string, nmcliOutput: string): string | undefined => {
const lines = nmcliOutput.trim().split('\n');
for (const line of lines) {
const columns = line.trim().split(/\s{2,}/);
if (columns[0].includes(ssid)) {
return columns[1];
}
}
};
const isValidWifiStatus = (status: string): status is WifiStatus => {
return status in WIFI_STATUS_MAP;
};
const getWifiStatus = (): string => {
const wifiState = network.wifi.state?.toLowerCase();
if (wifiState && isValidWifiStatus(wifiState)) {
return WIFI_STATUS_MAP[wifiState];
}
return WIFI_STATUS_MAP['unknown'];
};
self.hook(network, () => {
Utils.merge([staging.bind('value'), connecting.bind('value')], () => {
// NOTE: Sometimes the network service will yield a "this._device is undefined" when
// trying to access the "access_points" property. So we must validate that
// it's not 'undefined'
// --
// Also this is an AGS bug that needs to be fixed
// TODO: Remove @ts-ignore once AGS bug is fixed
// @ts-expect-error to fix AGS bug
let WAPs = network.wifi._device !== undefined ? network.wifi['access_points'] : [];
const dedupeWAPs = (): AccessPoint[] => {
const dedupMap: Record<string, AccessPoint> = {};
WAPs.forEach((item: AccessPoint) => {
if (item.ssid !== null && !Object.hasOwnProperty.call(dedupMap, item.ssid)) {
dedupMap[item.ssid] = item;
}
});
return Object.keys(dedupMap).map((itm) => dedupMap[itm]);
};
WAPs = dedupeWAPs();
const isInStaging = (wap: AccessPoint): boolean => {
if (Object.keys(staging.value).length === 0) {
return false;
}
return wap.bssid === staging.value.bssid;
};
const isDisconnecting = (wap: AccessPoint): boolean => {
if (wap.ssid === network.wifi.ssid) {
return network.wifi.state.toLowerCase() === 'deactivating';
}
return false;
};
const filteredWAPs = WAPs.filter((ap: AccessPoint) => {
return ap.ssid !== 'Unknown' && !isInStaging(ap);
}).sort((a: AccessPoint, b: AccessPoint) => {
if (network.wifi.ssid === a.ssid) {
return -1;
}
if (network.wifi.ssid === b.ssid) {
return 1;
}
return b.strength - a.strength;
});
if (filteredWAPs.length <= 0 && Object.keys(staging.value).length === 0) {
return (self.child = Widget.Label({
class_name: 'waps-not-found dim',
expand: true,
hpack: 'center',
vpack: 'center',
label: 'No Wi-Fi Networks Found',
}));
}
return (self.children = filteredWAPs.map((ap: AccessPoint) => {
return Widget.Box({
children: [
Widget.Button({
on_primary_click: () => {
if (ap.bssid === connecting.value || ap.active) {
return;
}
connecting.value = ap.bssid || '';
Utils.execAsync(`nmcli device wifi connect ${ap.bssid}`)
.then(() => {
connecting.value = '';
staging.value = {} as AccessPoint;
})
.catch((err) => {
if (err.toLowerCase().includes('secrets were required, but not provided')) {
staging.value = ap;
} else {
Utils.notify({
summary: 'Network',
body: err,
timeout: 5000,
});
}
connecting.value = '';
});
},
class_name: 'network-element-item',
child: Widget.Box({
hexpand: true,
children: [
Widget.Box({
hpack: 'start',
hexpand: true,
children: [
Widget.Label({
vpack: 'start',
class_name: `network-icon wifi ${ap.ssid === network.wifi.ssid ? 'active' : ''} txt-icon`,
label: getWifiIcon(`${ap['iconName']}`),
}),
Widget.Box({
class_name: 'connection-container',
vpack: 'center',
vertical: true,
children: [
Widget.Label({
vpack: 'center',
class_name: 'active-connection',
hpack: 'start',
truncate: 'end',
wrap: true,
label: ap.ssid,
}),
Widget.Revealer({
revealChild: ap.ssid === network.wifi.ssid,
child: Widget.Label({
hpack: 'start',
class_name: 'connection-status dim',
label: getWifiStatus(),
}),
}),
],
}),
],
}),
Widget.Revealer({
hpack: 'end',
vpack: 'start',
reveal_child: ap.bssid === connecting.value || isDisconnecting(ap),
child: Widget.Spinner({
vpack: 'start',
class_name: 'spinner wap',
}),
}),
],
}),
}),
Widget.Revealer({
vpack: 'start',
reveal_child: ap.bssid !== connecting.value && ap.active,
child: Widget.Button({
tooltip_text: 'Delete/Forget Network',
class_name: 'menu-icon-button network disconnect',
on_primary_click: () => {
connecting.value = ap.bssid || '';
Utils.execAsync('nmcli connection show --active').then(() => {
Utils.execAsync('nmcli connection show --active').then((res) => {
const connectionId = getIdBySsid(ap.ssid || '', res);
if (connectionId === undefined) {
console.error(
`Error while forgetting "${ap.ssid}": Connection ID not found`,
);
return;
}
Utils.execAsync(`nmcli connection delete ${connectionId} "${ap.ssid}"`)
.then(() => (connecting.value = ''))
.catch((err) => {
connecting.value = '';
console.error(`Error while forgetting "${ap.ssid}": ${err}`);
});
});
});
},
child: Widget.Label({
class_name: 'txt-icon delete-network',
label: '󰚃',
}),
}),
}),
],
});
}));
});
});
};
export { renderWAPs };
@@ -0,0 +1,81 @@
const network = await Service.import('network');
import { renderWAPs } from './WirelessAPs.js';
import { renderWapStaging } from './APStaging.js';
import { AccessPoint } from 'lib/types/network.js';
import { BoxWidget } from 'lib/types/widget.js';
const Staging = Variable({} as AccessPoint);
const Connecting = Variable('');
const searchInProgress = Variable(false);
const startRotation = (): void => {
searchInProgress.value = true;
setTimeout(() => {
searchInProgress.value = false;
}, 5 * 1000);
};
const Wifi = (): BoxWidget => {
return Widget.Box({
class_name: 'menu-section-container wifi',
vertical: true,
children: [
Widget.Box({
class_name: 'menu-label-container',
hpack: 'fill',
children: [
Widget.Label({
class_name: 'menu-label',
hexpand: true,
hpack: 'start',
label: 'Wi-Fi',
}),
Widget.Switch({
class_name: 'menu-switch network',
vpack: 'center',
tooltip_text: 'Toggle Wifi',
active: network.wifi.enabled,
on_activate: () => {
network.toggleWifi();
},
}),
Widget.Button({
vpack: 'center',
hpack: 'end',
class_name: 'menu-icon-button search network',
on_primary_click: () => {
startRotation();
network.wifi.scan();
},
child: Widget.Icon({
class_name: searchInProgress.bind('value').as((v) => (v ? 'spinning' : '')),
icon: 'view-refresh-symbolic',
}),
}),
],
}),
Widget.Box({
class_name: 'menu-items-section',
vertical: true,
children: [
Widget.Box({
class_name: 'wap-staging',
setup: (self) => {
renderWapStaging(self, network, Staging, Connecting);
},
}),
Widget.Box({
class_name: 'available-waps',
vertical: true,
setup: (self) => {
renderWAPs(self, network, Staging, Connecting);
},
}),
],
}),
],
});
};
export { Wifi };
@@ -0,0 +1,76 @@
import { closeNotifications } from 'globals/notification';
import { BoxWidget } from 'lib/types/widget';
import { Notifications } from 'types/service/notifications';
import options from 'options';
const { clearDelay } = options.notifications;
const Controls = (notifs: Notifications): BoxWidget => {
return Widget.Box({
class_name: 'notification-menu-controls',
expand: false,
vertical: false,
children: [
Widget.Box({
class_name: 'menu-label-container notifications',
hpack: 'start',
vpack: 'center',
expand: true,
children: [
Widget.Label({
class_name: 'menu-label notifications',
label: 'Notifications',
}),
],
}),
Widget.Box({
hpack: 'end',
vpack: 'center',
expand: false,
children: [
Widget.Switch({
class_name: 'menu-switch notifications',
vpack: 'center',
active: notifs.bind('dnd').as((dnd: boolean) => !dnd),
on_activate: ({ active }) => {
notifs.dnd = !active;
},
}),
Widget.Box({
children: [
Widget.Separator({
hpack: 'center',
vexpand: true,
vertical: true,
class_name: 'menu-separator notification-controls',
}),
Widget.Button({
className: 'clear-notifications-button',
tooltip_text: 'Clear Notifications',
on_primary_click: clearDelay.bind('value').as((delay) => {
return () => {
if (removingNotifications.value) {
return;
}
return closeNotifications(notifs.notifications, delay);
};
}),
child: Widget.Label({
class_name: removingNotifications.bind('value').as((removing: boolean) => {
return removing
? 'clear-notifications-label txt-icon removing'
: 'clear-notifications-label txt-icon';
}),
label: '',
}),
}),
],
}),
],
}),
],
});
};
export { Controls };
@@ -0,0 +1,47 @@
import { Notification } from 'types/service/notifications.js';
import DropdownMenu from '../shared/dropdown/index.js';
const notifs = await Service.import('notifications');
import { Controls } from './controls/index.js';
import { NotificationCard } from './notification/index.js';
import { NotificationPager } from './pager/index.js';
import options from 'options';
import Window from 'types/widgets/window.js';
import { Attribute, Child } from 'lib/types/widget.js';
const { displayedTotal } = options.notifications;
export default (): Window<Child, Attribute> => {
const curPage = Variable(1);
Utils.merge(
[curPage.bind('value'), displayedTotal.bind('value'), notifs.bind('notifications')],
(currentPage: number, dispTotal: number, notifications: Notification[]) => {
// If the page doesn't have enough notifications to display, go back
// to the previous page.
if (notifications.length <= (currentPage - 1) * dispTotal) {
curPage.value = currentPage <= 1 ? 1 : currentPage - 1;
}
},
);
return DropdownMenu({
name: 'notificationsmenu',
transition: options.menus.transition.bind('value'),
child: Widget.Box({
class_name: 'notification-menu-content',
css: 'padding: 1px; margin: -1px;',
hexpand: true,
vexpand: false,
children: [
Widget.Box({
class_name: 'notification-card-container menu',
vertical: true,
hexpand: false,
vexpand: false,
children: [Controls(notifs), NotificationCard(notifs, curPage), NotificationPager(curPage)],
}),
],
}),
});
};
@@ -0,0 +1,49 @@
import { BoxWidget } from 'lib/types/widget';
import { Notification, Notifications } from 'types/service/notifications';
const Actions = (notif: Notification, notifs: Notifications): BoxWidget => {
if (notif.actions !== undefined && notif.actions.length > 0) {
return Widget.Box({
class_name: 'notification-card-actions menu',
hexpand: true,
vpack: 'end',
children: notif.actions.map((action) => {
return Widget.Button({
hexpand: true,
class_name: 'notification-action-buttons menu',
on_primary_click: () => {
if (action.id.includes('scriptAction:-')) {
App.closeWindow('notificationsmenu');
Utils.execAsync(`${action.id.replace('scriptAction:-', '')}`).catch((err) =>
console.error(err),
);
notifs.CloseNotification(notif.id);
} else {
App.closeWindow('notificationsmenu');
notif.invoke(action.id);
}
},
child: Widget.Box({
hpack: 'center',
hexpand: true,
children: [
Widget.Label({
class_name: 'notification-action-buttons-label menu',
hexpand: true,
max_width_chars: 15,
truncate: 'end',
wrap: true,
label: action.label,
}),
],
}),
});
}),
});
}
return Widget.Box({
class_name: 'spacer',
});
};
export { Actions };
@@ -0,0 +1,25 @@
import { BoxWidget } from 'lib/types/widget.js';
import { notifHasImg } from '../../utils.js';
import { Notification } from 'types/service/notifications';
export const Body = (notif: Notification): BoxWidget => {
return Widget.Box({
vpack: 'start',
hexpand: true,
class_name: 'notification-card-body menu',
children: [
Widget.Label({
hexpand: true,
use_markup: true,
xalign: 0,
justification: 'left',
truncate: 'end',
lines: 2,
max_width_chars: !notifHasImg(notif) ? 35 : 28,
wrap: true,
class_name: 'notification-card-body-label menu',
label: notif['body'],
}),
],
});
};
@@ -0,0 +1,17 @@
import { Attribute } from 'lib/types/widget';
import { Notification, Notifications } from 'types/service/notifications';
import Button from 'types/widgets/button';
import Label from 'types/widgets/label';
export const CloseButton = (notif: Notification, notifs: Notifications): Button<Label<Attribute>, Attribute> => {
return Widget.Button({
class_name: 'close-notification-button menu',
on_primary_click: () => {
notifs.CloseNotification(notif.id);
},
child: Widget.Label({
class_name: 'txt-icon notif-close',
label: '󰅜',
hpack: 'center',
}),
});
};
@@ -0,0 +1,19 @@
import { Notification } from 'types/service/notifications.js';
import { NotificationIcon } from 'lib/types/notification.js';
import { getNotificationIcon } from 'globals/notification';
import { BoxWidget } from 'lib/types/widget';
const NotificationIcon = ({ app_entry = '', app_icon = '', app_name = '' }: Partial<Notification>): BoxWidget => {
return Widget.Box({
css: `
min-width: 2rem;
min-height: 2rem;
`,
child: Widget.Icon({
class_name: 'notification-icon menu',
icon: getNotificationIcon(app_name, app_icon, app_entry),
}),
});
};
export { NotificationIcon };
@@ -0,0 +1,54 @@
import GLib from 'gi://GLib';
import { Notification } from 'types/service/notifications';
import { NotificationIcon } from './icon.js';
import { notifHasImg } from '../../utils.js';
import options from 'options.js';
import { BoxWidget } from 'lib/types/widget.js';
const { military } = options.menus.clock.time;
export const Header = (notif: Notification): BoxWidget => {
const time = (time: number, format = '%I:%M %p'): string => {
return GLib.DateTime.new_from_unix_local(time).format(military.value ? '%H:%M' : format) || '--:--';
};
return Widget.Box({
vertical: false,
hexpand: true,
children: [
Widget.Box({
class_name: 'notification-card-header menu',
hpack: 'start',
children: [NotificationIcon(notif)],
}),
Widget.Box({
class_name: 'notification-card-header menu',
hexpand: true,
vpack: 'start',
children: [
Widget.Label({
class_name: 'notification-card-header-label menu',
hpack: 'start',
hexpand: true,
vexpand: true,
max_width_chars: !notifHasImg(notif) ? 34 : 22,
truncate: 'end',
wrap: true,
label: notif['summary'],
}),
],
}),
Widget.Box({
class_name: 'notification-card-header menu',
hpack: 'end',
vpack: 'start',
hexpand: true,
child: Widget.Label({
vexpand: true,
class_name: 'notification-time',
label: time(notif.time),
}),
}),
],
});
};
@@ -0,0 +1,24 @@
import { Notification } from 'types/service/notifications';
import { notifHasImg } from '../../utils.js';
import { BoxWidget } from 'lib/types/widget.js';
const Image = (notif: Notification): BoxWidget => {
if (notifHasImg(notif)) {
return Widget.Box({
class_name: 'notification-card-image-container menu',
hpack: 'center',
vpack: 'center',
vexpand: false,
child: Widget.Box({
hpack: 'center',
vexpand: false,
class_name: 'notification-card-image menu',
css: `background-image: url("${notif.image}")`,
}),
});
}
return Widget.Box();
};
export { Image };
@@ -0,0 +1,78 @@
import { Notifications, Notification } from 'types/service/notifications';
import { notifHasImg } from '../utils.js';
import { Header } from './header/index.js';
import { Actions } from './actions/index.js';
import { Image } from './image/index.js';
import { Placeholder } from './placeholder/index.js';
import { Body } from './body/index.js';
import { CloseButton } from './close/index.js';
import options from 'options.js';
import { Variable } from 'types/variable.js';
import { filterNotifications } from 'lib/shared/notifications.js';
import Scrollable from 'types/widgets/scrollable.js';
import { Attribute, Child } from 'lib/types/widget.js';
const { displayedTotal, ignore } = options.notifications;
const NotificationCard = (notifs: Notifications, curPage: Variable<number>): Scrollable<Child, Attribute> => {
return Widget.Scrollable({
vscroll: 'automatic',
child: Widget.Box({
class_name: 'menu-content-container notifications',
hpack: 'center',
vexpand: true,
spacing: 0,
vertical: true,
setup: (self) => {
Utils.merge(
[
notifs.bind('notifications'),
curPage.bind('value'),
displayedTotal.bind('value'),
ignore.bind('value'),
],
(notifications, currentPage, dispTotal, ignoredNotifs) => {
const filteredNotifications = filterNotifications(notifications, ignoredNotifs);
const sortedNotifications = filteredNotifications.sort((a, b) => b.time - a.time);
if (filteredNotifications.length <= 0) {
return (self.children = [Placeholder(notifs)]);
}
const pageStart = (currentPage - 1) * dispTotal;
const pageEnd = currentPage * dispTotal;
return (self.children = sortedNotifications
.slice(pageStart, pageEnd)
.map((notif: Notification) => {
return Widget.Box({
class_name: 'notification-card-content-container',
children: [
Widget.Box({
class_name: 'notification-card menu',
vpack: 'start',
hexpand: true,
vexpand: false,
children: [
Image(notif),
Widget.Box({
vpack: 'center',
vertical: true,
hexpand: true,
class_name: `notification-card-content ${!notifHasImg(notif) ? 'noimg' : ' menu'}`,
children: [Header(notif), Body(notif), Actions(notif, notifs)],
}),
],
}),
CloseButton(notif, notifs),
],
});
}));
},
);
},
}),
});
};
export { NotificationCard };
@@ -0,0 +1,30 @@
import { BoxWidget } from 'lib/types/widget';
import { Notifications } from 'types/service/notifications';
const Placeholder = (notifs: Notifications): BoxWidget => {
return Widget.Box({
class_name: 'notification-label-container',
vpack: 'fill',
hpack: 'center',
expand: true,
child: Widget.Box({
vpack: 'center',
vertical: true,
expand: true,
children: [
Widget.Label({
vpack: 'center',
class_name: 'placeholder-label dim bell txt-icon',
label: notifs.bind('dnd').as((dnd) => (dnd ? '󰂛' : '󰂚')),
}),
Widget.Label({
vpack: 'start',
class_name: 'placehold-label dim message',
label: "You're all caught up :)",
}),
],
}),
});
};
export { Placeholder };
@@ -0,0 +1,88 @@
const notifs = await Service.import('notifications');
import { BoxWidget } from 'lib/types/widget';
import options from 'options';
import { Notification } from 'types/service/notifications';
import { Variable } from 'types/variable';
const { displayedTotal } = options.notifications;
const { show: showPager } = options.theme.bar.menus.menu.notifications.pager;
export const NotificationPager = (curPage: Variable<number>): BoxWidget => {
return Widget.Box({
class_name: 'notification-menu-pager',
hexpand: true,
vexpand: false,
children: Utils.merge(
[
curPage.bind('value'),
displayedTotal.bind('value'),
notifs.bind('notifications'),
showPager.bind('value'),
],
(currentPage: number, dispTotal: number, _: Notification[], showPgr: boolean) => {
if (showPgr === false || (currentPage === 1 && notifs.notifications.length <= dispTotal)) {
return [];
}
return [
Widget.Button({
hexpand: true,
hpack: 'start',
class_name: `pager-button left ${currentPage <= 1 ? 'disabled' : ''}`,
onPrimaryClick: () => {
curPage.value = 1;
},
child: Widget.Label({
className: 'pager-button-label',
label: '',
}),
}),
Widget.Button({
hexpand: true,
hpack: 'start',
class_name: `pager-button left ${currentPage <= 1 ? 'disabled' : ''}`,
onPrimaryClick: () => {
curPage.value = currentPage <= 1 ? 1 : currentPage - 1;
},
child: Widget.Label({
className: 'pager-button-label',
label: '',
}),
}),
Widget.Label({
hexpand: true,
hpack: 'center',
class_name: 'pager-label',
label: `${currentPage} / ${Math.ceil(notifs.notifications.length / dispTotal) || 1}`,
}),
Widget.Button({
hexpand: true,
hpack: 'end',
class_name: `pager-button right ${currentPage >= Math.ceil(notifs.notifications.length / dispTotal) ? 'disabled' : ''}`,
onPrimaryClick: () => {
const maxPage = Math.ceil(notifs.notifications.length / displayedTotal.value);
curPage.value = currentPage >= maxPage ? currentPage : currentPage + 1;
},
child: Widget.Label({
className: 'pager-button-label',
label: '',
}),
}),
Widget.Button({
hexpand: true,
hpack: 'end',
class_name: `pager-button right ${currentPage >= Math.ceil(notifs.notifications.length / dispTotal) ? 'disabled' : ''}`,
onPrimaryClick: () => {
const maxPage = Math.ceil(notifs.notifications.length / displayedTotal.value);
curPage.value = maxPage;
},
child: Widget.Label({
className: 'pager-button-label',
label: '󰄾',
}),
}),
];
},
),
});
};
@@ -0,0 +1,7 @@
import { Notification } from 'types/service/notifications';
const notifHasImg = (notif: Notification): boolean => {
return notif.image !== undefined && notif.image.length ? true : false;
};
export { notifHasImg };
@@ -0,0 +1,61 @@
import { Action } from 'lib/types/power';
import options from 'options';
const { sleep, reboot, logout, shutdown } = options.menus.dashboard.powermenu;
class PowerMenu extends Service {
static {
Service.register(
this,
{},
{
title: ['string'],
cmd: ['string'],
},
);
}
#title = '';
#cmd = '';
get title(): string {
return this.#title;
}
action(action: Action): void {
[this.#cmd, this.#title] = {
sleep: [sleep.value, 'Sleep'],
reboot: [reboot.value, 'Reboot'],
logout: [logout.value, 'Log Out'],
shutdown: [shutdown.value, 'Shutdown'],
}[action];
this.notify('cmd');
this.notify('title');
this.emit('changed');
App.closeWindow('powermenu');
App.openWindow('verification');
}
customAction(action: Action, cmnd: string): void {
[this.#cmd, this.#title] = [cmnd, action];
this.notify('cmd');
this.notify('title');
this.emit('changed');
App.closeWindow('powermenu');
App.openWindow('verification');
}
shutdown = (): void => {
this.action('shutdown');
};
exec = (): void => {
App.closeWindow('verification');
Utils.execAsync(this.#cmd);
};
}
const powermenu = new PowerMenu();
Object.assign(globalThis, { powermenu });
export default powermenu;
@@ -0,0 +1,41 @@
import { Action } from 'lib/types/power.js';
import PopupWindow from '../shared/popup/index.js';
import powermenu from './helpers/actions.js';
import icons from '../../icons/index.js';
import Window from 'types/widgets/window.js';
import { Attribute, Child, GButton } from 'lib/types/widget.js';
import options from 'options.js';
const SysButton = (action: Action, label: string): GButton =>
Widget.Button({
class_name: `widget-button powermenu-button-${action}`,
on_clicked: () => powermenu.action(action),
child: Widget.Box({
vertical: true,
class_name: 'system-button widget-box',
children: [
Widget.Icon({
class_name: `system-button_icon ${action}`,
icon: icons.powermenu[action],
}),
Widget.Label({
class_name: `system-button_label ${action}`,
label,
}),
],
}),
});
export default (): Window<Child, Attribute> =>
PopupWindow({
name: 'powermenu',
transition: options.menus.transition.bind('value'),
child: Widget.Box({
class_name: 'powermenu horizontal',
children: [
SysButton('shutdown', 'SHUTDOWN'),
SysButton('logout', 'LOG OUT'),
SysButton('reboot', 'REBOOT'),
SysButton('sleep', 'SLEEP'),
],
}),
});
@@ -0,0 +1,54 @@
import Window from 'types/widgets/window.js';
import PopupWindow from '../shared/popup/index.js';
import powermenu from './helpers/actions.js';
import { Attribute, Child } from 'lib/types/widget.js';
export default (): Window<Child, Attribute> =>
PopupWindow({
name: 'verification',
transition: 'crossfade',
child: Widget.Box({
class_name: 'verification',
child: Widget.Box({
class_name: 'verification-content',
expand: true,
vertical: true,
children: [
Widget.Box({
class_name: 'text-box',
vertical: true,
children: [
Widget.Label({
class_name: 'title',
label: powermenu.bind('title').as((t) => t.toUpperCase()),
}),
Widget.Label({
class_name: 'desc',
label: powermenu
.bind('title')
.as((p) => `Are you sure you want to ${p.toLowerCase()}?`),
}),
],
}),
Widget.Box({
class_name: 'buttons horizontal',
vexpand: true,
vpack: 'end',
homogeneous: true,
children: [
Widget.Button({
class_name: 'verification-button bar-verification_yes',
child: Widget.Label('Yes'),
on_clicked: powermenu.exec,
}),
Widget.Button({
class_name: 'verification-button bar-verification_no',
child: Widget.Label('No'),
on_clicked: () => App.toggleWindow('verification'),
}),
],
}),
],
}),
}),
});
@@ -0,0 +1,65 @@
import { PowerOptions } from 'lib/types/options';
import options from 'options';
import powermenu from '../power/helpers/actions';
import { GButton } from 'lib/types/widget';
const { confirmation, shutdown, logout, sleep, reboot, showLabel } = options.menus.power;
export const PowerButton = (action: PowerOptions): GButton => {
const handleClick = (action: PowerOptions): void => {
const actions = {
shutdown: shutdown.value,
reboot: reboot.value,
logout: logout.value,
sleep: sleep.value,
};
App.closeWindow('powerdropdownmenu');
if (!confirmation.value) {
Utils.execAsync(actions[action]).catch((err) =>
console.error(`Failed to execute ${action} command. Error: ${err}`),
);
} else {
powermenu.customAction(action, actions[action]);
}
};
const powerIconMap = {
shutdown: '󰐥',
reboot: '󰜉',
logout: '󰿅',
sleep: '󰤄',
};
return Widget.Button({
className: showLabel.bind('value').as((shwLbl) => {
return `power-menu-button ${action} ${!shwLbl ? 'no-label' : ''}`;
}),
on_clicked: () => handleClick(action),
child: Widget.Box({
vertical: false,
children: showLabel.bind('value').as((shwLbl) => {
if (shwLbl) {
return [
Widget.Label({
label: powerIconMap[action],
className: `power-button-icon ${action}-icon txt-icon`,
}),
Widget.Label({
hpack: 'center',
hexpand: true,
label: action.charAt(0).toUpperCase() + action.slice(1),
className: `power-button-label ${action}-label show-label`,
}),
];
}
return [
Widget.Label({
label: powerIconMap[action],
className: `power-button-icon ${action}-icon no-label txt-icon`,
}),
];
}),
}),
});
};
@@ -0,0 +1,21 @@
import Window from 'types/widgets/window.js';
import DropdownMenu from '../shared/dropdown/index.js';
import { PowerButton } from './button.js';
import { Attribute, Child } from 'lib/types/widget.js';
import options from 'options.js';
export default (): Window<Child, Attribute> => {
return DropdownMenu({
name: 'powerdropdownmenu',
transition: options.menus.transition.bind('value'),
child: Widget.Box({
class_name: 'menu-items power-dropdown',
child: Widget.Box({
vertical: true,
hexpand: true,
class_name: 'menu-items-container power-dropdown',
children: [PowerButton('shutdown'), PowerButton('reboot'), PowerButton('logout'), PowerButton('sleep')],
}),
}),
});
};
@@ -0,0 +1,33 @@
import { Attribute, BoxWidget } from 'lib/types/widget';
import EventBox from 'types/widgets/eventbox';
import { BarLocation } from 'lib/types/options';
const createEventBox = (className: string, windowName: string): EventBox<BoxWidget, Attribute> => {
return Widget.EventBox({
class_name: className,
hexpand: true,
vexpand: false,
can_focus: false,
child: Widget.Box(),
setup: (w) => {
w.on('button-press-event', () => App.toggleWindow(windowName));
},
});
};
export const barEventMargins = (
windowName: string,
location: BarLocation = 'top',
): [EventBox<BoxWidget, Attribute>, EventBox<BoxWidget, Attribute>] => {
if (location === 'top') {
return [
createEventBox('mid-eb event-top-padding-static', windowName),
createEventBox('mid-eb event-top-padding', windowName),
];
} else {
return [
createEventBox('mid-eb event-bottom-padding', windowName),
createEventBox('mid-eb event-bottom-padding-static', windowName),
];
}
};
@@ -0,0 +1,98 @@
import options from 'options';
import { DropdownMenuProps } from 'lib/types/dropdownmenu';
import { Attribute, Child, Exclusivity } from 'lib/types/widget';
import Window from 'types/widgets/window';
import { barEventMargins } from './eventBoxes/index';
import { globalEventBoxes } from 'globals/dropdown';
const { location } = options.theme.bar;
// NOTE: We make the window visible for 2 seconds (on startup) so the child
// elements can allocat their proper dimensions.
// Otherwise the width that we rely on for menu positioning is set improperly
// for the first time we open a menu of each type.
const initRender = Variable(true);
setTimeout(() => {
initRender.value = false;
}, 2000);
export default ({
name,
child,
transition,
exclusivity = 'ignore' as Exclusivity,
...props
}: DropdownMenuProps): Window<Child, Attribute> =>
Widget.Window({
name,
class_names: [name, 'dropdown-menu'],
setup: (w) => w.keybind('Escape', () => App.closeWindow(name)),
visible: initRender.bind('value'),
keymode: 'on-demand',
exclusivity,
layer: 'top',
anchor: location.bind('value').as((ln) => [ln, 'left']),
child: Widget.EventBox({
class_name: 'parent-event',
on_primary_click: () => App.closeWindow(name),
on_secondary_click: () => App.closeWindow(name),
child: Widget.Box({
class_name: 'top-eb',
vertical: true,
children: [
Widget.Box({
className: 'event-box-container',
children: location.bind('value').as((lcn) => {
if (lcn === 'top') {
return barEventMargins(name);
} else {
return [];
}
}),
}),
Widget.EventBox({
class_name: 'in-eb menu-event-box',
on_primary_click: () => {
return true;
},
on_secondary_click: () => {
return true;
},
setup: (self) => {
globalEventBoxes.value[name] = self;
},
child: Widget.Box({
class_name: 'dropdown-menu-container',
css: 'padding: 1px; margin: -1px;',
child: Widget.Revealer({
revealChild: false,
setup: (self) =>
self.hook(App, (_, wname, visible) => {
if (wname === name) self.reveal_child = visible;
}),
transition,
transitionDuration: options.menus.transitionTime.bind('value'),
child: Widget.Box({
class_name: 'dropdown-menu-container',
can_focus: true,
children: [child],
}),
}),
}),
}),
Widget.Box({
className: 'event-box-container',
children: location.bind('value').as((lcn) => {
if (lcn === 'bottom') {
return barEventMargins(name);
} else {
return [];
}
}),
}),
],
}),
}),
...props,
});
@@ -0,0 +1,98 @@
const hyprland = await Service.import('hyprland');
import options from 'options';
import { bash } from 'lib/utils';
import { Widget as TWidget } from 'types/@girs/gtk-3.0/gtk-3.0.cjs';
import { Monitor } from 'types/service/hyprland';
import Box from 'types/widgets/box';
import EventBox from 'types/widgets/eventbox';
import Revealer from 'types/widgets/revealer';
import { globalEventBoxes } from 'globals/dropdown';
type NestedRevealer = Revealer<Box<TWidget, unknown>, unknown>;
type NestedBox = Box<NestedRevealer, unknown>;
type NestedEventBox = EventBox<NestedBox, unknown>;
const { location } = options.theme.bar;
const { scalingPriority } = options;
export const calculateMenuPosition = async (pos: number[], windowName: string): Promise<void> => {
const self = globalEventBoxes.value[windowName] as NestedEventBox;
const curHyprlandMonitor = hyprland.monitors.find((m) => m.id === hyprland.active.monitor.id);
const dropdownWidth = self.child.get_allocation().width;
const dropdownHeight = self.child.get_allocation().height;
let hyprScaling = 1;
try {
const monitorInfo = await bash('hyprctl monitors -j');
const parsedMonitorInfo = JSON.parse(monitorInfo);
const foundMonitor = parsedMonitorInfo.find((monitor: Monitor) => monitor.id === hyprland.active.monitor.id);
hyprScaling = foundMonitor?.scale || 1;
} catch (error) {
console.error(`Error parsing hyprland monitors: ${error}`);
}
let monWidth = curHyprlandMonitor?.width;
let monHeight = curHyprlandMonitor?.height;
if (monWidth === undefined || monHeight === undefined || hyprScaling === undefined) {
return;
}
// If GDK Scaling is applied, then get divide width by scaling
// to get the proper coordinates.
// Ex: On a 2860px wide monitor... if scaling is set to 2, then the right
// end of the monitor is the 1430th pixel.
const gdkScale = Utils.exec('bash -c "echo $GDK_SCALE"');
if (scalingPriority.value === 'both') {
const scale = parseFloat(gdkScale);
monWidth = monWidth / scale;
monHeight = monHeight / scale;
monWidth = monWidth / hyprScaling;
monHeight = monHeight / hyprScaling;
} else if (/^\d+(.\d+)?$/.test(gdkScale) && scalingPriority.value === 'gdk') {
const scale = parseFloat(gdkScale);
monWidth = monWidth / scale;
monHeight = monHeight / scale;
} else {
monWidth = monWidth / hyprScaling;
monHeight = monHeight / hyprScaling;
}
// If monitor is vertical (transform = 1 || 3) swap height and width
const isVertical = curHyprlandMonitor?.transform !== undefined ? curHyprlandMonitor.transform % 2 !== 0 : false;
if (isVertical) {
[monWidth, monHeight] = [monHeight, monWidth];
}
let marginRight = monWidth - dropdownWidth / 2;
marginRight = marginRight - pos[0];
let marginLeft = monWidth - dropdownWidth - marginRight;
const minimumMargin = 0;
if (marginRight < minimumMargin) {
marginRight = minimumMargin;
marginLeft = monWidth - dropdownWidth - minimumMargin;
}
if (marginLeft < minimumMargin) {
marginLeft = minimumMargin;
marginRight = monWidth - dropdownWidth - minimumMargin;
}
self.set_margin_left(marginLeft);
self.set_margin_right(marginRight);
if (location.value === 'top') {
self.set_margin_top(0);
self.set_margin_bottom(monHeight);
} else {
self.set_margin_bottom(0);
self.set_margin_top(monHeight - dropdownHeight);
}
};
@@ -0,0 +1,186 @@
import { WINDOW_LAYOUTS } from 'globals/window';
import { LayoutFunction, Layouts, PopupWindowProps } from 'lib/types/popupwindow';
import { Attribute, Child, Exclusivity, GtkWidget, Transition } from 'lib/types/widget';
import Box from 'types/widgets/box';
import EventBox from 'types/widgets/eventbox';
import Window from 'types/widgets/window';
type Opts = {
className: string;
vexpand: boolean;
};
export const Padding = (name: string, opts: Opts): EventBox<Box<GtkWidget, Attribute>, unknown> =>
Widget.EventBox({
class_name: opts?.className || '',
hexpand: true,
vexpand: typeof opts?.vexpand === 'boolean' ? opts.vexpand : true,
can_focus: false,
child: Widget.Box(),
setup: (w) => w.on('button-press-event', () => App.toggleWindow(name)),
});
const PopupRevealer = (
name: string,
child: GtkWidget,
transition = 'slide_down' as Transition,
): Box<Child, Attribute> =>
Widget.Box(
{ css: 'padding: 1px;' },
Widget.Revealer({
transition,
child: Widget.Box({
class_name: `window-content ${name}-window`,
child,
}),
transitionDuration: 200,
setup: (self) =>
self.hook(App, (_, wname, visible) => {
if (wname === name) self.reveal_child = visible;
}),
}),
);
const Layout: LayoutFunction = (name: string, child: GtkWidget, transition: Transition) => ({
center: () =>
Widget.CenterBox(
{},
Padding(name, {} as Opts),
Widget.CenterBox(
{ vertical: true },
Padding(name, {} as Opts),
PopupRevealer(name, child, transition),
Padding(name, {} as Opts),
),
Padding(name, {} as Opts),
),
top: () =>
Widget.CenterBox(
{},
Padding(name, {} as Opts),
Widget.Box({ vertical: true }, PopupRevealer(name, child, transition), Padding(name, {} as Opts)),
Padding(name, {} as Opts),
),
'top-right': () =>
Widget.Box(
{},
Padding(name, {} as Opts),
Widget.Box(
{
hexpand: false,
vertical: true,
},
Padding(name, {
vexpand: false,
className: 'event-top-padding',
}),
PopupRevealer(name, child, transition),
Padding(name, {} as Opts),
),
),
'top-center': () =>
Widget.Box(
{},
Padding(name, {} as Opts),
Widget.Box(
{
hexpand: false,
vertical: true,
},
Padding(name, {
vexpand: false,
className: 'event-top-padding',
}),
PopupRevealer(name, child, transition),
Padding(name, {} as Opts),
),
Padding(name, {} as Opts),
),
'top-left': () =>
Widget.Box(
{},
Widget.Box(
{
hexpand: false,
vertical: true,
},
Padding(name, {
vexpand: false,
className: 'event-top-padding',
}),
PopupRevealer(name, child, transition),
Padding(name, {} as Opts),
),
Padding(name, {} as Opts),
),
'bottom-left': () =>
Widget.Box(
{},
Widget.Box(
{
hexpand: false,
vertical: true,
},
Padding(name, {} as Opts),
PopupRevealer(name, child, transition),
),
Padding(name, {} as Opts),
),
'bottom-center': () =>
Widget.Box(
{},
Padding(name, {} as Opts),
Widget.Box(
{
hexpand: false,
vertical: true,
},
Padding(name, {} as Opts),
PopupRevealer(name, child, transition),
),
Padding(name, {} as Opts),
),
'bottom-right': () =>
Widget.Box(
{},
Padding(name, {} as Opts),
Widget.Box(
{
hexpand: false,
vertical: true,
},
Padding(name, {} as Opts),
PopupRevealer(name, child, transition),
),
),
});
const isValidLayout = (layout: string): layout is Layouts => {
return WINDOW_LAYOUTS.includes(layout);
};
export default ({
name,
child,
layout = 'center',
transition = 'none',
exclusivity = 'ignore' as Exclusivity,
...props
}: PopupWindowProps): Window<Child, Attribute> => {
const layoutFn = isValidLayout(layout) ? layout : 'center';
const layoutWidget = Layout(name, child, transition)[layoutFn]();
return Widget.Window({
name,
class_names: [name, 'popup-window'],
setup: (w) => w.keybind('Escape', () => App.closeWindow(name)),
visible: false,
keymode: 'on-demand',
exclusivity,
layer: 'top',
anchor: ['top', 'bottom', 'right', 'left'],
child: layoutWidget,
...props,
});
};