Upgrade to Agsv2 + Astal (#533)

* migrate to astal

* Reorganize project structure.

* progress

* Migrate Dashboard and Window Title modules.

* Migrate clock and notification bar modules.

* Remove unused code

* Media menu

* Rework network and volume modules

* Finish custom modules.

* Migrate battery bar module.

* Update battery module and organize helpers.

* Migrate workspace module.

* Wrap up bar modules.

* Checkpoint before I inevitbly blow something up.

* Updates

* Fix event propagation logic.

* Type fixes

* More type fixes

* Fix padding for event boxes.

* Migrate volume menu and refactor scroll event handlers.

* network module WIP

* Migrate network service.

* Migrate bluetooth menu

* Updates

* Migrate notifications

* Update scrolling behavior for custom modules.

* Improve popup notifications and add timer functionality.

* Migration notifications menu header/controls.

* Migrate notifications menu and consolidate notifications menu code.

* Migrate power menu.

* Dashboard progress

* Migrate dashboard

* Migrate media menu.

* Reduce media menu nesting.

* Finish updating media menu bindings to navigate active player.

* Migrate battery menu

* Consolidate code

* Migrate calendar menu

* Fix workspace logic to update on client add/change/remove and consolidate code.

* Migrate osd

* Consolidate hyprland service connections.

* Implement startup dropdown menu position allocation.

* Migrate settings menu (WIP)

* Settings dialo menu fixes

* Finish Dashboard menu

* Type updates

* update submoldule for types

* update github ci

* ci

* Submodule update

* Ci updates

* Remove type checking for now.

* ci fix

* Fix a bunch of stuff, losing track... need rest. Brb coffee

* Validate dropdown menu before render.

* Consolidate code and add auto-hide functionality.

* Improve auto-hide behavior.

* Consolidate audio menu code

* Organize bluetooth code

* Improve active player logic

* Properly dismiss a notification on action button resolution.

* Implement CLI command engine and migrate CLI commands.

* Handle variable disposal

* Bar component fixes and add hyprland startup rules.

* Handle potentially null bindings network and bluetooth bindings.

* Handle potentially null wired adapter.

* Fix GPU stats

* Handle poller for GPU

* Fix gpu bar logic.

* Clean up logic for stat bars.

* Handle wifi and wired bar icon bindings.

* Fix battery percentages

* Fix switch behavior

* Wifi staging fixes

* Reduce redundant hyprland service calls.

* Code cleanup

* Document the option code and reduce redundant calls to optimize performance.

* Remove outdated comment.

* Add JSDocs

* Add meson to build hyprpanel

* Consistency updates

* Organize commands

* Fix images not showing up on notifications.

* Remove todo

* Move hyprpanel configuration to the ~/.config/hyprpanel directory and add utility commands.

* Handle SRC directory for the bundled/built hyprpanel.

* Add namespaces to all windows

* Migrate systray

* systray updates

* Update meson to include ts, tsx and scss files.

* Remove log from meson

* Fix file choose path and make it float.

* Added a command to check the dependency status

* Update dep names.

* Get scale directly from env

* Add todo
This commit is contained in:
Jas Singh
2024-12-20 18:10:10 -08:00
committed by GitHub
parent 955eed6c60
commit 2ffd602910
605 changed files with 19543 additions and 15999 deletions

View File

@@ -0,0 +1,40 @@
import { bind } from 'astal';
import { Gtk } from 'astal/gtk3';
import AstalWp from 'gi://AstalWp?version=0.1';
import options from 'src/options';
const { raiseMaximumVolume } = options.menus.volume;
export const Slider = ({ device, type }: SliderProps): JSX.Element => {
return (
<box vertical>
<label
className={`menu-active ${type}`}
halign={Gtk.Align.START}
truncate
expand
wrap
label={bind(device, 'description').as((description) => description ?? `Unknown ${type} Device`)}
/>
<slider
value={bind(device, 'volume')}
className={`menu-active-slider menu-slider ${type}`}
drawValue={false}
hexpand
min={0}
max={type === 'playback' ? bind(raiseMaximumVolume).as((raise) => (raise ? 1.5 : 1)) : 1}
onDragged={({ value, dragging }) => {
if (dragging) {
device.volume = value;
device.mute = false;
}
}}
/>
</box>
);
};
interface SliderProps {
device: AstalWp.Endpoint;
type: 'playback' | 'input';
}

View File

@@ -0,0 +1,39 @@
import { bind, Variable } from 'astal';
import { Gtk } from 'astal/gtk3';
import { isPrimaryClick } from 'src/lib/utils';
import { getIcon } from '../../utils';
import AstalWp from 'gi://AstalWp?version=0.1';
export const SliderIcon = ({ type, device }: SliderIconProps): JSX.Element => {
const iconBinding = Variable.derive([bind(device, 'volume'), bind(device, 'mute')], (volume, isMuted) => {
const iconType = type === 'playback' ? 'spkr' : 'mic';
const effectiveVolume = volume > 0 ? volume : 100;
const mutedState = volume > 0 ? isMuted : true;
return getIcon(effectiveVolume, mutedState)[iconType];
});
return (
<button
className={bind(device, 'mute').as((isMuted) => `menu-active-button ${type} ${isMuted ? 'muted' : ''}`)}
vexpand={false}
valign={Gtk.Align.END}
onClick={(_, event) => {
if (isPrimaryClick(event)) {
device.mute = !device.mute;
}
}}
onDestroy={() => {
iconBinding.drop();
}}
>
<icon className={`menu-active-icon ${type}`} icon={iconBinding()} />
</button>
);
};
interface SliderIconProps {
type: 'playback' | 'input';
device: AstalWp.Endpoint;
}

View File

@@ -0,0 +1,18 @@
import { bind } from 'astal';
import { Gtk } from 'astal/gtk3';
import AstalWp from 'gi://AstalWp?version=0.1';
export const SliderPercentage = ({ type, device }: SliderPercentageProps): JSX.Element => {
return (
<label
className={`menu-active-percentage ${type}`}
valign={Gtk.Align.END}
label={bind(device, 'volume').as((vol) => `${Math.round(vol * 100)}%`)}
/>
);
};
interface SliderPercentageProps {
type: 'playback' | 'input';
device: AstalWp.Endpoint;
}

View File

@@ -0,0 +1,21 @@
import AstalWp from 'gi://AstalWp?version=0.1';
import { SliderIcon } from './SliderIcon';
import { Slider } from './Slider';
import { SliderPercentage } from './SliderPercentage';
export const ActiveDevice = ({ type, device }: ActiveDeviceProps): JSX.Element => {
return (
<box className={`menu-active-container ${type}`} vertical>
<box className={`menu-slider-container ${type}`}>
<SliderIcon type={type} device={device} />
<Slider type={type} device={device} />
<SliderPercentage type={type} device={device} />
</box>
</box>
);
};
interface ActiveDeviceProps {
type: 'playback' | 'input';
device: AstalWp.Endpoint;
}

View File

@@ -0,0 +1,34 @@
import { Gtk } from 'astal/gtk3';
import { ActiveDevice } from './device/index.js';
import { audioService } from 'src/lib/constants/services.js';
import { BindableChild } from 'astal/gtk3/astalify.js';
export const SelectedDevices = (): JSX.Element => {
const Header = (): JSX.Element => (
<box className={'menu-label-container volume selected'} halign={Gtk.Align.FILL}>
<label className={'menu-label audio volume'} halign={Gtk.Align.START} hexpand label={'Volume'} />
</box>
);
const ActiveDeviceContainer = ({ children }: ActiveDeviceContainerProps): JSX.Element => {
return (
<box className={'menu-items-section selected'} vertical>
{children}
</box>
);
};
return (
<box className={'menu-section-container volume'} vertical>
<Header />
<ActiveDeviceContainer>
<ActiveDevice type={'playback'} device={audioService.defaultSpeaker} />
<ActiveDevice type={'input'} device={audioService.defaultMicrophone} />
</ActiveDeviceContainer>
</box>
);
};
interface ActiveDeviceContainerProps {
children?: BindableChild | BindableChild[];
}

View File

@@ -0,0 +1,52 @@
import { Gtk } from 'astal/gtk3';
import AstalWp from 'gi://AstalWp?version=0.1';
import { isPrimaryClick } from 'src/lib/utils';
import { bind } from 'astal';
const DeviceIcon = ({ device, type, icon }: AudioDeviceProps): JSX.Element => {
return (
<label
className={bind(device, 'isDefault').as((isDefault) => {
return `menu-button-icon ${isDefault ? 'active' : ''} ${type} txt-icon`;
})}
label={icon}
/>
);
};
const DeviceName = ({ device, type }: Omit<AudioDeviceProps, 'icon'>): JSX.Element => {
return (
<label
truncate
wrap
className={bind(device, 'description').as((currentDesc) =>
device.description === currentDesc ? `menu-button-name active ${type}` : `menu-button-name ${type}`,
)}
label={device.description}
/>
);
};
export const AudioDevice = ({ device, type, icon }: AudioDeviceProps): JSX.Element => {
return (
<button
className={`menu-button audio ${type} ${device.id}`}
onClick={(_, event) => {
if (isPrimaryClick(event)) {
device.set_is_default(true);
}
}}
>
<box halign={Gtk.Align.START}>
<DeviceIcon device={device} type={type} icon={icon} />
<DeviceName device={device} type={type} />
</box>
</button>
);
};
interface AudioDeviceProps {
device: AstalWp.Endpoint;
type: 'playback' | 'input';
icon: string;
}

View File

@@ -0,0 +1,14 @@
import { Gtk } from 'astal/gtk3';
export const Header = ({ type, label }: HeaderProps): JSX.Element => {
return (
<box className={`menu-label-container ${type}`} halign={Gtk.Align.FILL}>
<label className={`menu-label audio ${type}`} halign={Gtk.Align.START} hexpand label={label} />
</box>
);
};
interface HeaderProps {
type: 'playback' | 'input';
label: string;
}

View File

@@ -0,0 +1,24 @@
import { audioService } from 'src/lib/constants/services.js';
import { bind } from 'astal/binding.js';
import { AudioDevice } from './Device';
import { NotFoundButton } from './NotFoundButton';
export const InputDevices = (): JSX.Element => {
const inputDevices = bind(audioService, 'microphones');
return (
<box className={'menu-items-section input'} vertical>
<box className={'menu-container input'} vertical>
{inputDevices.as((devices) => {
if (!devices || devices.length === 0) {
return <NotFoundButton type={'input'} />;
}
return devices.map((device) => {
return <AudioDevice device={device} type={'input'} icon={''} />;
});
})}
</box>
</box>
);
};

View File

@@ -0,0 +1,13 @@
import { Gtk } from 'astal/gtk3';
export const NotFoundButton = ({ type }: { type: string }): JSX.Element => {
return (
<button className={`menu-unfound-button ${type}`} sensitive={false}>
<box>
<box halign={Gtk.Align.START}>
<label className={`menu-button-name ${type}`} label={`No ${type} devices found...`} />
</box>
</box>
</button>
);
};

View File

@@ -0,0 +1,24 @@
import { audioService } from 'src/lib/constants/services.js';
import { bind } from 'astal/binding.js';
import { AudioDevice } from './Device';
import { NotFoundButton } from './NotFoundButton';
export const PlaybackDevices = (): JSX.Element => {
const playbackDevices = bind(audioService, 'speakers');
return (
<box className={'menu-items-section playback'} vertical>
<box className={'menu-container playback'} vertical>
{playbackDevices.as((devices) => {
if (!devices || devices.length === 0) {
return <NotFoundButton type={'playback'} />;
}
return devices.map((device) => {
return <AudioDevice device={device} type={'playback'} icon={''} />;
});
})}
</box>
</box>
);
};

View File

@@ -0,0 +1,15 @@
import { PlaybackDevices } from './PlaybackDevices.js';
import { InputDevices } from './InputDevices.js';
import { Header } from './Header.js';
export const AvailableDevices = (): JSX.Element => {
return (
<box vertical className={'menu-section-container playback'}>
<Header type={'playback'} label={'Playback Device'} />
<PlaybackDevices />
<Header type={'input'} label={'Input Device'} />
<InputDevices />
</box>
);
};

View File

@@ -0,0 +1,23 @@
import DropdownMenu from '../shared/dropdown/index.js';
import { SelectedDevices } from './active/index.js';
import options from 'src/options.js';
import { bind } from 'astal/binding.js';
import { Gtk } from 'astal/gtk3';
import { AvailableDevices } from './available/index.js';
import { RevealerTransitionMap } from 'src/lib/constants/options.js';
export default (): JSX.Element => {
return (
<DropdownMenu
name="audiomenu"
transition={bind(options.menus.transition).as((transition) => RevealerTransitionMap[transition])}
>
<box className={'menu-items audio'} halign={Gtk.Align.FILL} hexpand>
<box className={'menu-items-container audio'} halign={Gtk.Align.FILL} vertical hexpand>
<SelectedDevices />
<AvailableDevices />
</box>
</box>
</DropdownMenu>
);
};

View File

@@ -0,0 +1,40 @@
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;
/**
* Retrieves the appropriate speaker and microphone icons based on the audio volume and mute status.
*
* This function determines the correct icons for both the speaker and microphone based on the provided audio volume and mute status.
* It uses predefined thresholds to select the appropriate icons from the `speakerIcons` and `inputIcons` objects.
*
* @param audioVol The current audio volume as a number between 0 and 1.
* @param isMuted A boolean indicating whether the audio is muted.
*
* @returns An object containing the speaker and microphone icons.
*/
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 };

View File

@@ -0,0 +1,9 @@
import { Gtk } from 'astal/gtk3';
export const BluetoothDisabled = (): JSX.Element => {
return (
<box className={'bluetooth-items'} vertical expand valign={Gtk.Align.CENTER} halign={Gtk.Align.CENTER}>
<label className={'bluetooth-disabled dim'} hexpand label={'Bluetooth is disabled'} />
</box>
);
};

View File

@@ -0,0 +1,17 @@
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
import { DeviceControls } from './controls';
import { BluetoothDevice } from './device';
export const DeviceListItem = ({ btDevice, connectedDevices }: DeviceListItemProps): JSX.Element => {
return (
<box>
<BluetoothDevice device={btDevice} connectedDevices={connectedDevices} />
<DeviceControls device={btDevice} connectedDevices={connectedDevices} />
</box>
);
};
interface DeviceListItemProps {
btDevice: AstalBluetooth.Device;
connectedDevices: string[];
}

View File

@@ -0,0 +1,10 @@
import { Gtk } from 'astal/gtk3';
export const NoBluetoothDevices = (): JSX.Element => {
return (
<box className={'bluetooth-items'} vertical expand valign={Gtk.Align.CENTER} halign={Gtk.Align.CENTER}>
<label className={'no-bluetooth-devices dim'} hexpand label={'No devices currently found'} />
<label className={'search-bluetooth-label dim'} hexpand label={"Press '󰑐' to search"} />
</box>
);
};

View File

@@ -0,0 +1,20 @@
import { Binding } from 'astal';
import { ButtonProps } from 'astal/gtk3/widget';
export const ActionButton = ({ name = '', tooltipText = '', label = '', ...props }: ActionButtonProps): JSX.Element => {
return (
<button className={`menu-icon-button ${name} bluetooth`} {...props}>
<label
className={`menu-icon-button-label ${name} bluetooth txt-icon`}
tooltipText={tooltipText}
label={label}
/>
</button>
);
};
interface ActionButtonProps extends ButtonProps {
name: string;
tooltipText: string | Binding<string>;
label: string | Binding<string>;
}

View File

@@ -0,0 +1,29 @@
import { bind } from 'astal';
import { ActionButton } from './ActionButton';
import { isPrimaryClick } from 'src/lib/utils';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
export const ConnectButton = ({ device }: ConnectButtonProps): JSX.Element => {
return (
<ActionButton
name={'disconnect'}
tooltipText={bind(device, 'connected').as((connected) => (connected ? 'Disconnect' : 'Connect'))}
label={bind(device, 'connected').as((connected) => (connected ? '󱘖' : ''))}
onClick={(_, self) => {
if (isPrimaryClick(self) && device.connected) {
device.disconnect_device((res) => {
console.info(res);
});
} else {
device.connect_device((res) => {
console.info(res);
});
}
}}
/>
);
};
interface ConnectButtonProps {
device: AstalBluetooth.Device;
}

View File

@@ -0,0 +1,23 @@
import { ActionButton } from './ActionButton';
import { isPrimaryClick } from 'src/lib/utils';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
import { forgetBluetoothDevice } from '../helpers';
export const ForgetButton = ({ device }: ForgetButtonProps): JSX.Element => {
return (
<ActionButton
name={'delete'}
tooltipText={'Forget'}
label={'󰆴'}
onClick={(_, self) => {
if (isPrimaryClick(self)) {
forgetBluetoothDevice(device);
}
}}
/>
);
};
interface ForgetButtonProps {
device: AstalBluetooth.Device;
}

View File

@@ -0,0 +1,29 @@
import { bind } from 'astal';
import { ActionButton } from './ActionButton';
import { isPrimaryClick } from 'src/lib/utils';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
export const PairButton = ({ device }: PairButtonProps): JSX.Element => {
return (
<ActionButton
name={'unpair'}
tooltipText={bind(device, 'paired').as((paired) => (paired ? 'Unpair' : 'Pair'))}
label={bind(device, 'paired').as((paired) => (paired ? '' : ''))}
onClick={(_, self) => {
if (!isPrimaryClick(self)) {
return;
}
if (device.paired) {
device.pair();
} else {
device.cancel_pairing();
}
}}
/>
);
};
interface PairButtonProps {
device: AstalBluetooth.Device;
}

View File

@@ -0,0 +1,23 @@
import { bind } from 'astal';
import { ActionButton } from './ActionButton';
import { isPrimaryClick } from 'src/lib/utils';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
export const TrustButton = ({ device }: TrustButtonProps): JSX.Element => {
return (
<ActionButton
name={'untrust'}
tooltipText={bind(device, 'trusted').as((trusted) => (trusted ? 'Untrust' : 'Trust'))}
label={bind(device, 'trusted').as((trusted) => (trusted ? '' : '󱖡'))}
onClick={(_, self) => {
if (isPrimaryClick(self)) {
device.set_trusted(!device.trusted);
}
}}
/>
);
};
interface TrustButtonProps {
device: AstalBluetooth.Device;
}

View File

@@ -0,0 +1,26 @@
import { Gtk } from 'astal/gtk3';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
import { PairButton } from './PairButton';
import { ConnectButton } from './ConnectButton';
import { TrustButton } from './TrustButton';
import { ForgetButton } from './ForgetButton';
export const DeviceControls = ({ device, connectedDevices }: DeviceControlsProps): JSX.Element => {
if (!connectedDevices.includes(device.address)) {
return <box />;
}
return (
<box valign={Gtk.Align.START} className={'bluetooth-controls'}>
<PairButton device={device} />
<ConnectButton device={device} />
<TrustButton device={device} />
<ForgetButton device={device} />
</box>
);
};
interface DeviceControlsProps {
device: AstalBluetooth.Device;
connectedDevices: string[];
}

View File

@@ -0,0 +1,22 @@
import { bind } from 'astal';
import { Gtk } from 'astal/gtk3';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
import { getBluetoothIcon } from '../../utils';
export const DeviceIcon = ({ device, connectedDevices }: DeviceIconProps): JSX.Element => {
return (
<label
valign={Gtk.Align.START}
className={bind(device, 'address').as(
(address) =>
`menu-button-icon bluetooth ${connectedDevices.includes(address) ? 'active' : ''} txt-icon`,
)}
label={bind(device, 'icon').as((icon) => getBluetoothIcon(`${icon}-symbolic`))}
/>
);
};
interface DeviceIconProps {
device: AstalBluetooth.Device;
connectedDevices: string[];
}

View File

@@ -0,0 +1,20 @@
import { bind } from 'astal';
import { Gtk } from 'astal/gtk3';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
export const DeviceName = ({ device }: DeviceNameProps): JSX.Element => {
return (
<label
valign={Gtk.Align.CENTER}
halign={Gtk.Align.START}
className="menu-button-name bluetooth"
truncate
wrap
label={bind(device, 'alias')}
/>
);
};
interface DeviceNameProps {
device: AstalBluetooth.Device;
}

View File

@@ -0,0 +1,32 @@
import { bind, Variable } from 'astal';
import { Gtk } from 'astal/gtk3';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
export const DeviceStatus = ({ device }: DeviceStatusProps): JSX.Element => {
const revealerBinding = Variable.derive(
[bind(device, 'connected'), bind(device, 'paired')],
(connected, paired) => {
return connected || paired;
},
);
return (
<revealer
halign={Gtk.Align.START}
revealChild={revealerBinding()}
onDestroy={() => {
revealerBinding.drop();
}}
>
<label
halign={Gtk.Align.START}
className={'connection-status dim'}
label={bind(device, 'connected').as((connected) => (connected ? 'Connected' : 'Paired'))}
/>
</revealer>
);
};
interface DeviceStatusProps {
device: AstalBluetooth.Device;
}

View File

@@ -0,0 +1,50 @@
import { Gtk } from 'astal/gtk3';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
import Spinner from 'src/components/shared/Spinner';
import { isPrimaryClick } from 'src/lib/utils';
import { bind } from 'astal';
import { DeviceIcon } from './DeviceIcon';
import { DeviceName } from './DeviceName';
import { DeviceStatus } from './DeviceStatus';
export const BluetoothDevice = ({ device, connectedDevices }: BluetoothDeviceProps): JSX.Element => {
const IsConnectingSpinner = (): JSX.Element => {
return (
<revealer revealChild={bind(device, 'connecting')}>
<Spinner valign={Gtk.Align.START} className="spinner bluetooth" />
</revealer>
);
};
return (
<button
hexpand
className={`bluetooth-element-item ${device}`}
onClick={(_, event) => {
if (!connectedDevices.includes(device.address) && isPrimaryClick(event)) {
device.connect_device((res) => {
console.info(res);
});
}
}}
>
<box>
<box hexpand halign={Gtk.Align.START} className="menu-button-container">
<DeviceIcon device={device} connectedDevices={connectedDevices} />
<box vertical valign={Gtk.Align.CENTER}>
<DeviceName device={device} />
<DeviceStatus device={device} />
</box>
</box>
<box halign={Gtk.Align.END}>
<IsConnectingSpinner />
</box>
</box>
</button>
);
};
interface BluetoothDeviceProps {
device: AstalBluetooth.Device;
connectedDevices: string[];
}

View File

@@ -0,0 +1,60 @@
import { execAsync } from 'astal';
import AstalBluetooth from 'gi://AstalBluetooth?version=0.1';
import { bluetoothService } from 'src/lib/constants/services';
/**
* Retrieves the list of available Bluetooth devices.
*
* This function filters and sorts the list of Bluetooth devices from the `bluetoothService`.
* It excludes devices with a null name and sorts the devices based on their connection and pairing status.
*
* @returns An array of available Bluetooth devices.
*/
export const getAvailableBluetoothDevices = (): AstalBluetooth.Device[] => {
const availableDevices = bluetoothService.devices
.filter((btDev) => btDev.name !== null)
.sort((a, b) => {
if (a.connected || a.paired) {
return -1;
}
if (b.connected || b.paired) {
return 1;
}
return a.name.localeCompare(b.name);
});
return availableDevices;
};
/**
* Retrieves the list of connected Bluetooth devices.
*
* This function filters the list of available Bluetooth devices to include only those that are connected or paired.
* It returns an array of the addresses of the connected devices.
*
* @returns An array of addresses of connected Bluetooth devices.
*/
export const getConnectedBluetoothDevices = (): string[] => {
const availableDevices = getAvailableBluetoothDevices();
const connectedDeviceNames = availableDevices.filter((d) => d.connected || d.paired).map((d) => d.address);
return connectedDeviceNames;
};
/**
* Forgets a Bluetooth device.
*
* This function removes a Bluetooth device using the `bluetoothctl` command.
* It executes the command asynchronously and emits a 'device-removed' event if the command is successful.
*
* @param device The Bluetooth device to forget.
*/
export const forgetBluetoothDevice = (device: AstalBluetooth.Device): void => {
execAsync(['bash', '-c', `bluetoothctl remove ${device.address}`])
.catch((err) => console.error('Bluetooth Remove', err))
.then(() => {
bluetoothService.emit('device-removed', device);
});
};

View File

@@ -0,0 +1,41 @@
import Variable from 'astal/variable.js';
import { bind } from 'astal/binding.js';
import { bluetoothService } from 'src/lib/constants/services.js';
import { getAvailableBluetoothDevices, getConnectedBluetoothDevices } from './helpers.js';
import { NoBluetoothDevices } from './NoBluetoothDevices.js';
import { BluetoothDisabled } from './BluetoothDisabled.js';
import { DeviceListItem } from './DeviceListItem.js';
export const BluetoothDevices = (): JSX.Element => {
const deviceListBinding = Variable.derive(
[bind(bluetoothService, 'devices'), bind(bluetoothService, 'isPowered')],
() => {
const availableDevices = getAvailableBluetoothDevices();
const connectedDevices = getConnectedBluetoothDevices();
if (availableDevices.length === 0) {
return <NoBluetoothDevices />;
}
if (!bluetoothService.adapter?.powered) {
return <BluetoothDisabled />;
}
return availableDevices.map((btDevice) => {
return <DeviceListItem btDevice={btDevice} connectedDevices={connectedDevices} />;
});
},
);
return (
<box
className={'menu-items-section'}
onDestroy={() => {
deviceListBinding.drop();
}}
>
<box className={'menu-content'} vertical>
{deviceListBinding()}
</box>
</box>
);
};

View File

@@ -0,0 +1,35 @@
import { Gtk } from 'astal/gtk3';
import { bluetoothService } from 'src/lib/constants/services';
import { isPrimaryClick } from 'src/lib/utils';
import { bind, timeout } from 'astal';
import { isDiscovering } from './helper';
export const DiscoverButton = (): JSX.Element => (
<button
className="menu-icon-button search"
valign={Gtk.Align.CENTER}
onClick={(_, self) => {
if (!isPrimaryClick(self)) {
return;
}
if (bluetoothService.adapter?.discovering) {
return bluetoothService.adapter.stop_discovery();
}
bluetoothService.adapter?.start_discovery();
const discoveryTimeout = 12000;
timeout(discoveryTimeout, () => {
if (bluetoothService.adapter?.discovering) {
bluetoothService.adapter.stop_discovery();
}
});
}}
>
<icon
className={bind(isDiscovering).as((isDiscovering) => (isDiscovering ? 'spinning-icon' : ''))}
icon="view-refresh-symbolic"
/>
</button>
);

View File

@@ -0,0 +1,23 @@
import { bind, Variable } from 'astal';
import { Gtk } from 'astal/gtk3';
import { bluetoothService } from 'src/lib/constants/services';
const isPowered = Variable(false);
Variable.derive([bind(bluetoothService, 'isPowered')], (isOn) => {
return isPowered.set(isOn);
});
export const ToggleSwitch = (): JSX.Element => (
<switch
className="menu-switch bluetooth"
halign={Gtk.Align.END}
hexpand
active={bluetoothService.isPowered}
setup={(self) => {
self.connect('notify::active', () => {
bluetoothService.adapter?.set_powered(self.active);
});
}}
/>
);

View File

@@ -0,0 +1,20 @@
import { bind, Variable } from 'astal';
import { bluetoothService } from 'src/lib/constants/services';
export const isDiscovering: Variable<boolean> = Variable(false);
let discoveringBinding: Variable<void>;
Variable.derive([bind(bluetoothService, 'adapter')], () => {
if (discoveringBinding) {
discoveringBinding();
discoveringBinding.drop();
}
if (!bluetoothService.adapter) {
return;
}
discoveringBinding = Variable.derive([bind(bluetoothService.adapter, 'discovering')], (discovering) => {
isDiscovering.set(discovering);
});
});

View File

@@ -0,0 +1,14 @@
import { Gtk } from 'astal/gtk3';
import Separator from 'src/components/shared/Separator';
import { ToggleSwitch } from './ToggleSwitch';
import { DiscoverButton } from './DiscoverButton';
export const Controls = (): JSX.Element => {
return (
<box className="controls-container" valign={Gtk.Align.START}>
<ToggleSwitch />
<Separator className="menu-separator bluetooth" />
<DiscoverButton />
</box>
);
};

View File

@@ -0,0 +1,15 @@
import { Gtk } from 'astal/gtk3';
import { Controls } from './Controls';
export const Header = (): JSX.Element => {
const MenuLabel = (): JSX.Element => {
return <label className="menu-label" valign={Gtk.Align.CENTER} halign={Gtk.Align.START} label="Bluetooth" />;
};
return (
<box className="menu-label-container" halign={Gtk.Align.FILL} valign={Gtk.Align.START}>
<MenuLabel />
<Controls />
</box>
);
};

View File

@@ -0,0 +1,25 @@
import DropdownMenu from '../shared/dropdown/index.js';
import { BluetoothDevices } from './devices/index.js';
import { Header } from './header/index.js';
import options from 'src/options.js';
import { bind } from 'astal/binding.js';
import { Gtk } from 'astal/gtk3';
import { RevealerTransitionMap } from 'src/lib/constants/options.js';
export default (): JSX.Element => {
return (
<DropdownMenu
name={'bluetoothmenu'}
transition={bind(options.menus.transition).as((transition) => RevealerTransitionMap[transition])}
>
<box className={'menu-items bluetooth'} halign={Gtk.Align.FILL} hexpand>
<box className={'menu-items-container bluetooth'} halign={Gtk.Align.FILL} vertical hexpand>
<box className={'menu-section-container bluetooth'} vertical>
<Header />
<BluetoothDevices />
</box>
</box>
</box>
</DropdownMenu>
);
};

View File

@@ -0,0 +1,39 @@
/**
* Retrieves the appropriate Bluetooth icon based on the provided icon name.
*
* This function returns a Bluetooth icon based on the given icon name. If no match is found,
* it returns a default Bluetooth icon. It uses a predefined mapping of device icon names to Bluetooth icons.
*
* @param iconName The name of the icon to look up.
*
* @returns The corresponding Bluetooth icon as a string. If no match is found, returns the default Bluetooth icon.
*/
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 };

View File

@@ -0,0 +1,20 @@
import { Gtk } from 'astal/gtk3';
import Calendar from 'src/components/shared/Calendar';
export const CalendarWidget = (): JSX.Element => {
return (
<box className={'calendar-menu-item-container calendar'} halign={Gtk.Align.FILL} valign={Gtk.Align.FILL} expand>
<box className={'calendar-container-box'}>
<Calendar
className={'calendar-menu-widget'}
halign={Gtk.Align.FILL}
valign={Gtk.Align.FILL}
showDetails={false}
expand
showDayNames
showHeading
/>
</box>
</box>
);
};

View File

@@ -0,0 +1,35 @@
import DropdownMenu from '../shared/dropdown/index.js';
import { TimeWidget } from './time/index';
import { CalendarWidget } from './CalendarWidget.js';
import { WeatherWidget } from './weather/index';
import options from 'src/options';
import { bind } from 'astal';
import { RevealerTransitionMap } from 'src/lib/constants/options.js';
const { transition } = options.menus;
const { enabled: weatherEnabled } = options.menus.clock.weather;
export default (): JSX.Element => {
return (
<DropdownMenu
name={'calendarmenu'}
transition={bind(transition).as((transition) => RevealerTransitionMap[transition])}
>
<box css={'padding: 1px; margin: -1px;'}>
{bind(weatherEnabled).as((isWeatherEnabled) => {
return (
<box className={'calendar-menu-content'} vexpand={false}>
<box className={'calendar-content-container'} vertical>
<box className={'calendar-content-items'} vertical>
<TimeWidget />
<CalendarWidget />
<WeatherWidget isEnabled={isWeatherEnabled} />
</box>
</box>
</box>
);
})}
</box>
</DropdownMenu>
);
};

View File

@@ -0,0 +1,35 @@
import options from 'src/options';
import { bind, Variable } from 'astal';
import { Gtk } from 'astal/gtk3';
import { systemTime } from 'src/globals/time';
const { military, hideSeconds } = options.menus.clock.time;
export const MilitaryTime = (): JSX.Element => {
const timeBinding = Variable.derive([bind(military), bind(hideSeconds)], (is24hr, hideSeconds) => {
if (!is24hr) {
return <box />;
}
return (
<box halign={Gtk.Align.CENTER}>
<label
className={'clock-content-time'}
label={bind(systemTime).as((time) => {
return time?.format(hideSeconds ? '%H:%M' : '%H:%M:%S') || '';
})}
/>
</box>
);
});
return (
<box
onDestroy={() => {
timeBinding.drop();
}}
>
{timeBinding()}
</box>
);
};

View File

@@ -0,0 +1,58 @@
import options from 'src/options';
import { bind, GLib, Variable } from 'astal';
import { Gtk } from 'astal/gtk3';
import { systemTime } from 'src/globals/time';
const { military, hideSeconds } = options.menus.clock.time;
const period = Variable('').poll(1000, (): string => GLib.DateTime.new_now_local().format('%p') || '');
export const StandardTime = (): JSX.Element => {
const CurrentTime = ({ hideSeconds }: CurrentTimeProps): JSX.Element => {
return (
<box halign={Gtk.Align.CENTER}>
<label
className={'clock-content-time'}
label={bind(systemTime).as((time) => {
return time?.format(hideSeconds ? '%I:%M' : '%I:%M:%S') || '';
})}
/>
</box>
);
};
const CurrentPeriod = (): JSX.Element => {
return (
<box halign={Gtk.Align.CENTER}>
<label className={'clock-content-period'} valign={Gtk.Align.END} label={bind(period)} />
</box>
);
};
const timeBinding = Variable.derive([bind(military), bind(hideSeconds)], (is24hr, hideSeconds) => {
if (is24hr) {
return <box />;
}
return (
<box>
<CurrentTime hideSeconds={hideSeconds} />
<CurrentPeriod />
</box>
);
});
return (
<box
onDestroy={() => {
timeBinding.drop();
}}
>
{timeBinding()}
</box>
);
};
interface CurrentTimeProps {
hideSeconds: boolean;
}

View File

@@ -0,0 +1,14 @@
import { Gtk } from 'astal/gtk3';
import { MilitaryTime } from './MilitaryTime';
import { StandardTime } from './StandardTime';
export const TimeWidget = (): JSX.Element => {
return (
<box className={'calendar-menu-item-container clock'} valign={Gtk.Align.CENTER} halign={Gtk.Align.FILL} hexpand>
<box className={'clock-content-items'} valign={Gtk.Align.CENTER} halign={Gtk.Align.CENTER} hexpand>
<StandardTime />
<MilitaryTime />
</box>
</box>
);
};

View File

@@ -0,0 +1,64 @@
import { isValidWeatherIconTitle } from 'src/globals/weather';
import { Weather, WeatherIconTitle } from 'src/lib/types/weather';
/**
* Retrieves the next epoch time for weather data.
*
* This function calculates the next epoch time based on the current weather data and the specified number of hours from now.
* It ensures that the prediction remains within the current day by rewinding the time if necessary.
*
* @param wthr The current weather data.
* @param hoursFromNow The number of hours from now to calculate the next epoch time.
*
* @returns The next epoch time as a number.
*/
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;
};
/**
* Retrieves the weather icon query for a specific time in the future.
*
* This function calculates the next epoch time and retrieves the corresponding weather data.
* It then generates a weather icon query based on the weather condition and time of day.
*
* @param weather The current weather data.
* @param hoursFromNow The number of hours from now to calculate the weather icon query.
*
* @returns The weather icon query as a string.
*/
export const getIconQuery = (weather: Weather, hoursFromNow: number): WeatherIconTitle => {
const nextEpoch = getNextEpoch(weather, hoursFromNow);
const weatherAtEpoch = weather.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';
}
};

View File

@@ -0,0 +1,25 @@
import { bind } from 'astal';
import { globalWeatherVar } from 'src/globals/weather';
import { Gtk } from 'astal/gtk3';
import { weatherIcons } from 'src/lib/icons/weather.js';
import { getIconQuery } from '../helpers';
export const HourlyIcon = ({ hoursFromNow }: HourlyIconProps): JSX.Element => {
return (
<box halign={Gtk.Align.CENTER}>
<label
className={'hourly-weather-icon txt-icon'}
label={bind(globalWeatherVar).as((weather) => {
const iconQuery = getIconQuery(weather, hoursFromNow);
const weatherIcn = weatherIcons[iconQuery] || weatherIcons['warning'];
return weatherIcn;
})}
halign={Gtk.Align.CENTER}
/>
</box>
);
};
interface HourlyIconProps {
hoursFromNow: number;
}

View File

@@ -0,0 +1,18 @@
import { HourlyIcon } from './icon/index.js';
import { HourlyTemp } from './temperature/index.js';
import { HourlyTime } from './time/index.js';
import { Gtk } from 'astal/gtk3';
export const HourlyTemperature = (): JSX.Element => {
return (
<box className={'hourly-weather-container'} halign={Gtk.Align.FILL} vertical={false} hexpand>
{[1, 2, 3, 4].map((hoursFromNow) => (
<box className={'hourly-weather-item'} hexpand vertical>
<HourlyTime hoursFromNow={hoursFromNow} />
<HourlyIcon hoursFromNow={hoursFromNow} />
<HourlyTemp hoursFromNow={hoursFromNow} />
</box>
))}
</box>
);
};

View File

@@ -0,0 +1,36 @@
import options from 'src/options';
import { globalWeatherVar } from 'src/globals/weather';
import { getNextEpoch } from '../helpers';
import { bind, Variable } from 'astal';
const { unit } = options.menus.clock.weather;
export const HourlyTemp = ({ hoursFromNow }: HourlyTempProps): JSX.Element => {
const weatherBinding = Variable.derive([bind(globalWeatherVar), bind(unit)], (weather, unitType) => {
if (!Object.keys(weather).length) {
return '-';
}
const nextEpoch = getNextEpoch(weather, hoursFromNow);
const weatherAtEpoch = weather.forecast.forecastday[0].hour.find((h) => h.time_epoch === nextEpoch);
if (unitType === 'imperial') {
return `${weatherAtEpoch ? Math.ceil(weatherAtEpoch.temp_f) : '-'}° F`;
}
return `${weatherAtEpoch ? Math.ceil(weatherAtEpoch.temp_c) : '-'}° C`;
});
return (
<label
className={'hourly-weather-temp'}
label={weatherBinding()}
onDestroy={() => {
weatherBinding.drop();
}}
/>
);
};
interface HourlyTempProps {
hoursFromNow: number;
}

View File

@@ -0,0 +1,30 @@
import { globalWeatherVar } from 'src/globals/weather';
import { getNextEpoch } from '../helpers';
import { bind } from 'astal';
export const HourlyTime = ({ hoursFromNow }: HourlyTimeProps): JSX.Element => {
return (
<label
className={'hourly-weather-time'}
label={bind(globalWeatherVar).as((weather) => {
if (!Object.keys(weather).length) {
return '-';
}
const nextEpoch = getNextEpoch(weather, hoursFromNow);
const dateAtEpoch = new Date(nextEpoch * 1000);
let hours = dateAtEpoch.getHours();
const ampm = hours >= 12 ? 'PM' : 'AM';
hours = hours % 12 || 12;
return `${hours}${ampm}`;
})}
/>
);
};
interface HourlyTimeProps {
hoursFromNow: number;
}

View File

@@ -0,0 +1,18 @@
import { bind } from 'astal';
import { Gtk } from 'astal/gtk3';
import { getWeatherStatusTextIcon } from 'src/globals/weather.js';
export const TodayIcon = (): JSX.Element => {
return (
<box
className={'calendar-menu-weather today icon container'}
halign={Gtk.Align.START}
valign={Gtk.Align.CENTER}
>
<label
className={'calendar-menu-weather today icon txt-icon'}
label={bind(globalWeatherVar).as(getWeatherStatusTextIcon)}
/>
</box>
);
};

View File

@@ -0,0 +1,23 @@
import { TodayIcon } from './icon/index.js';
import { TodayStats } from './stats/index.js';
import { TodayTemperature } from './temperature/index.js';
import { HourlyTemperature } from './hourly/index.js';
import Separator from 'src/components/shared/Separator.js';
export const WeatherWidget = (): JSX.Element => {
return (
<box className={'calendar-menu-item-container weather'}>
<box className={'weather-container-box'}>
<box vertical hexpand>
<box className={'calendar-menu-weather today'} hexpand>
<TodayIcon />
<TodayTemperature />
<TodayStats />
</box>
<Separator className={'menu-separator weather'} />
<HourlyTemperature />
</box>
</box>
</box>
);
};

View File

@@ -0,0 +1,32 @@
import { getTemperature, globalWeatherVar } from 'src/globals/weather';
import options from 'src/options';
import { getRainChance } from 'src/globals/weather';
import { Gtk } from 'astal/gtk3';
import { bind, Variable } from 'astal';
const { unit } = options.menus.clock.weather;
export const TodayStats = (): JSX.Element => {
const temperatureBinding = Variable.derive([bind(globalWeatherVar), bind(unit)], getTemperature);
return (
<box
className={'calendar-menu-weather today stats container'}
halign={Gtk.Align.END}
valign={Gtk.Align.CENTER}
vertical
onDestroy={() => {
temperatureBinding.drop();
}}
>
<box className={'weather wind'}>
<label className={'weather wind icon txt-icon'} label={''} />
<label className={'weather wind label'} label={temperatureBinding()} />
</box>
<box className={'weather precip'}>
<label className={'weather precip icon txt-icon'} label={''} />
<label className={'weather precip label'} label={bind(globalWeatherVar).as(getRainChance)} />
</box>
</box>
);
};

View File

@@ -0,0 +1,49 @@
import options from 'src/options';
import { globalWeatherVar } from 'src/globals/weather';
import { getTemperature, getWeatherIcon } from 'src/globals/weather';
import { Gtk } from 'astal/gtk3';
import { bind, Variable } from 'astal';
const { unit } = options.menus.clock.weather;
export const TodayTemperature = (): JSX.Element => {
const labelBinding = Variable.derive([bind(globalWeatherVar), bind(unit)], getTemperature);
return (
<box
halign={Gtk.Align.CENTER}
valign={Gtk.Align.CENTER}
vertical
onDestroy={() => {
labelBinding.drop();
}}
>
<box
className={'calendar-menu-weather today temp container'}
valign={Gtk.Align.CENTER}
vertical={false}
hexpand
>
<box halign={Gtk.Align.CENTER} hexpand>
<label className={'calendar-menu-weather today temp label'} label={labelBinding()} />
<label
className={bind(globalWeatherVar).as(
(weather) =>
`calendar-menu-weather today temp label icon txt-icon ${getWeatherIcon(Math.ceil(weather.current.temp_f)).color}`,
)}
label={bind(globalWeatherVar).as(
(weather) => getWeatherIcon(Math.ceil(weather.current.temp_f)).icon,
)}
/>
</box>
</box>
<box halign={Gtk.Align.CENTER}>
<label
className={bind(globalWeatherVar).as(
(weather) =>
`calendar-menu-weather today condition label ${getWeatherIcon(Math.ceil(weather.current.temp_f)).color}`,
)}
label={bind(globalWeatherVar).as((weather) => weather.current.condition.text)}
/>
</box>
</box>
);
};

View File

@@ -0,0 +1,109 @@
import { bind } from 'astal';
import { networkService } from 'src/lib/constants/services';
import { bluetoothService } from 'src/lib/constants/services';
import { notifdService } from 'src/lib/constants/services';
import { audioService } from 'src/lib/constants/services';
import { isPrimaryClick } from 'src/lib/utils';
import { isWifiEnabled } from './helpers';
export const WifiButton = (): JSX.Element => {
return (
<button
className={bind(isWifiEnabled).as((isEnabled) => `dashboard-button wifi ${!isEnabled ? 'disabled' : ''}`)}
onClick={(_, event) => {
if (isPrimaryClick(event)) {
networkService.wifi?.set_enabled(!networkService.wifi.enabled);
}
}}
tooltipText={'Toggle Wifi'}
expand
>
<label className={'txt-icon'} label={bind(isWifiEnabled).as((isEnabled) => (isEnabled ? '󰤨' : '󰤭'))} />
</button>
);
};
export const BluetoothButton = (): JSX.Element => {
return (
<button
className={bind(bluetoothService, 'isPowered').as(
(isEnabled) => `dashboard-button bluetooth ${!isEnabled ? 'disabled' : ''}`,
)}
onClick={(_, event) => {
if (isPrimaryClick(event)) {
bluetoothService.toggle();
}
}}
tooltipText={'Toggle Bluetooth'}
expand
>
<label
className={'txt-icon'}
label={bind(bluetoothService, 'isPowered').as((isEnabled) => (isEnabled ? '󰂯' : '󰂲'))}
/>
</button>
);
};
export const NotificationsButton = (): JSX.Element => {
return (
<button
className={bind(notifdService, 'dontDisturb').as(
(dnd) => `dashboard-button notifications ${dnd ? 'disabled' : ''}`,
)}
onClick={(_, event) => {
if (isPrimaryClick(event)) {
notifdService.set_dont_disturb(!notifdService.dontDisturb);
}
}}
tooltipText={'Toggle Notifications'}
expand
>
<label className={'txt-icon'} label={bind(notifdService, 'dontDisturb').as((dnd) => (dnd ? '󰂛' : '󰂚'))} />
</button>
);
};
export const PlaybackButton = (): JSX.Element => {
return (
<button
className={bind(audioService.defaultSpeaker, 'mute').as(
(isMuted) => `dashboard-button playback ${isMuted ? 'disabled' : ''}`,
)}
onClick={(_, event) => {
if (isPrimaryClick(event)) {
audioService.defaultSpeaker.set_mute(!audioService.defaultSpeaker.mute);
}
}}
tooltipText={'Toggle Mute (Playback)'}
expand
>
<label
className={'txt-icon'}
label={bind(audioService.defaultSpeaker, 'mute').as((isMuted) => (isMuted ? '󰖁' : '󰕾'))}
/>
</button>
);
};
export const MicrophoneButton = (): JSX.Element => {
return (
<button
className={bind(audioService.defaultMicrophone, 'mute').as(
(isMuted) => `dashboard-button input ${isMuted ? 'disabled' : ''}`,
)}
onClick={(_, event) => {
if (isPrimaryClick(event)) {
audioService.defaultMicrophone.set_mute(!audioService.defaultMicrophone.mute);
}
}}
tooltipText={'Toggle Mute (Microphone)'}
expand
>
<label
className={'txt-icon'}
label={bind(audioService.defaultMicrophone, 'mute').as((isMuted) => (isMuted ? '󰍭' : '󰍬'))}
/>
</button>
);
};

View File

@@ -0,0 +1,20 @@
import { bind, Variable } from 'astal';
import { networkService } from 'src/lib/constants/services';
export const isWifiEnabled: Variable<boolean> = Variable(false);
let wifiEnabledBinding: Variable<void>;
Variable.derive([bind(networkService, 'wifi')], () => {
if (wifiEnabledBinding) {
wifiEnabledBinding();
wifiEnabledBinding.drop();
}
if (!networkService.wifi) {
return;
}
wifiEnabledBinding = Variable.derive([bind(networkService.wifi, 'enabled')], (isEnabled) => {
isWifiEnabled.set(isEnabled);
});
});

View File

@@ -0,0 +1,22 @@
import { Gtk } from 'astal/gtk3';
import { BluetoothButton, MicrophoneButton, NotificationsButton, PlaybackButton, WifiButton } from './ControlButtons';
export const Controls = ({ isEnabled }: ControlsProps): JSX.Element => {
if (!isEnabled) {
return <box />;
}
return (
<box className={'dashboard-card controls-container'} halign={Gtk.Align.FILL} valign={Gtk.Align.FILL} expand>
<WifiButton />
<BluetoothButton />
<NotificationsButton />
<PlaybackButton />
<MicrophoneButton />
</box>
);
};
interface ControlsProps {
isEnabled: boolean;
}

View File

@@ -0,0 +1,48 @@
import { bind, execAsync, Variable } from 'astal';
import { App, Gtk, Widget } from 'astal/gtk3';
import { isPrimaryClick } from 'src/lib/utils';
import options from 'src/options';
const { left, right } = options.menus.dashboard.directories;
const DirectoryLink = ({ directoryItem, ...props }: DirectoryLinkProps): JSX.Element => {
return (
<button
{...props}
onClick={(_, event) => {
if (isPrimaryClick(event)) {
App.get_window('dashboardmenu')?.set_visible(false);
execAsync(directoryItem.command.get());
}
}}
>
<label label={bind(directoryItem.label)} halign={Gtk.Align.START} />
</button>
);
};
export const LeftLink1 = (): JSX.Element => {
return <DirectoryLink className={'directory-link left top'} directoryItem={left.directory1} />;
};
export const LeftLink2 = (): JSX.Element => {
return <DirectoryLink className={'directory-link left middle'} directoryItem={left.directory2} />;
};
export const LeftLink3 = (): JSX.Element => {
return <DirectoryLink className={'directory-link left bottom'} directoryItem={left.directory3} />;
};
export const RightLink1 = (): JSX.Element => {
return <DirectoryLink className={'directory-link right top'} directoryItem={right.directory1} />;
};
export const RightLink2 = (): JSX.Element => {
return <DirectoryLink className={'directory-link right middle'} directoryItem={right.directory2} />;
};
export const RightLink3 = (): JSX.Element => {
return <DirectoryLink className={'directory-link right bottom'} directoryItem={right.directory3} />;
};
interface DirectoryLinkProps extends Widget.ButtonProps {
directoryItem: {
label: Variable<string>;
command: Variable<string>;
};
}

View File

@@ -0,0 +1,21 @@
import { BindableChild } from 'astal/gtk3/astalify';
export const LeftSection = ({ children }: SectionProps): JSX.Element => {
return (
<box className={'section left'} vertical expand>
{children}
</box>
);
};
export const RightSection = ({ children }: SectionProps): JSX.Element => {
return (
<box className={'section right'} vertical expand>
{children}
</box>
);
};
interface SectionProps {
children?: BindableChild | BindableChild[];
}

View File

@@ -0,0 +1,28 @@
import { Gtk } from 'astal/gtk3';
import { LeftSection, RightSection } from './Sections';
import { LeftLink1, LeftLink2, LeftLink3, RightLink1, RightLink2, RightLink3 } from './DirectoryLinks';
export const Directories = ({ isEnabled }: DirectoriesProps): JSX.Element => {
if (!isEnabled) {
return <box />;
}
return (
<box className={'dashboard-card directories-container'} valign={Gtk.Align.FILL} halign={Gtk.Align.FILL} expand>
<LeftSection>
<LeftLink1 />
<LeftLink2 />
<LeftLink3 />
</LeftSection>
<RightSection>
<RightLink1 />
<RightLink2 />
<RightLink3 />
</RightSection>
</box>
);
};
interface DirectoriesProps {
isEnabled: boolean;
}

View File

@@ -0,0 +1,46 @@
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 options from 'src/options.js';
import { bind } from 'astal/binding.js';
import Variable from 'astal/variable.js';
import { RevealerTransitionMap } from 'src/lib/constants/options.js';
const { controls, shortcuts, stats, directories } = options.menus.dashboard;
const { transition } = options.menus;
export default (): JSX.Element => {
const dashboardBinding = Variable.derive(
[bind(controls.enabled), bind(shortcuts.enabled), bind(stats.enabled), bind(directories.enabled)],
(isControlsEnabled, isShortcutsEnabled, isStatsEnabled, isDirectoriesEnabled) => {
return [
<box className={'dashboard-content-container'} vertical>
<box className={'dashboard-content-items'} vertical>
<Profile />
<Shortcuts isEnabled={isShortcutsEnabled} />
<Controls isEnabled={isControlsEnabled} />
<Directories isEnabled={isDirectoriesEnabled} />
<Stats isEnabled={isStatsEnabled} />
</box>
</box>,
];
},
);
return (
<DropdownMenu
name={'dashboardmenu'}
transition={bind(transition).as((transition) => RevealerTransitionMap[transition])}
onDestroy={() => {
dashboardBinding.drop();
}}
>
<box className={'dashboard-menu-content'} css={'padding: 1px; margin: -1px;'} vexpand={false}>
{dashboardBinding()}
</box>
</DropdownMenu>
);
};

View File

@@ -0,0 +1,36 @@
import { PowerOptions } from 'src/lib/types/options';
import { isPrimaryClick } from 'src/lib/utils';
import { handleClick } from './helpers';
const PowerActionButton = (icon: string, tooltip: string, action: PowerOptions): JSX.Element => {
return (
<button
className={`dashboard-button ${action}`}
onClick={(_, event) => {
if (isPrimaryClick(event)) {
handleClick(action);
}
}}
tooltip_text={tooltip}
vexpand
>
<label className={'txt-icon'} label={icon} />
</button>
);
};
export const ShutDown = (): JSX.Element => {
return PowerActionButton('󰐥', 'Shut Down', 'shutdown');
};
export const Reboot = (): JSX.Element => {
return PowerActionButton('󰜉', 'Reboot', 'reboot');
};
export const LogOut = (): JSX.Element => {
return PowerActionButton('󰿅', 'Log Out', 'logout');
};
export const Sleep = (): JSX.Element => {
return PowerActionButton('󰤄', 'Sleep', 'sleep');
};

View File

@@ -0,0 +1,12 @@
import { LogOut, Reboot, ShutDown, Sleep } from './PowerButtons';
export const PowerMenu = (): JSX.Element => {
return (
<box className={'power-menu-container dashboard-card'} vertical vexpand>
<ShutDown />
<Reboot />
<LogOut />
<Sleep />
</box>
);
};

View File

@@ -0,0 +1,47 @@
import { bind, exec } from 'astal';
import GdkPixbuf from 'gi://GdkPixbuf';
import { Gtk } from 'astal/gtk3';
import options from 'src/options.js';
const { image, name } = options.menus.dashboard.powermenu.avatar;
const ProfilePicture = (): JSX.Element => {
return (
<box
className={'profile-picture'}
halign={Gtk.Align.CENTER}
css={bind(image).as((img) => {
try {
GdkPixbuf.Pixbuf.new_from_file(img);
return `background-image: url("${img}")`;
} catch {
return `background-image: url("${SRC_DIR}/assets/hyprpanel.png")`;
}
})}
/>
);
};
const ProfileName = (): JSX.Element => {
return (
<label
className={'profile-name'}
halign={Gtk.Align.CENTER}
label={bind(name).as((profileName) => {
if (profileName === 'system') {
return exec('bash -c whoami');
}
return profileName;
})}
/>
);
};
export const UserProfile = (): JSX.Element => {
return (
<box className={'profile-picture-container dashboard-card'} hexpand vertical>
<ProfilePicture />
<ProfileName />
</box>
);
};

View File

@@ -0,0 +1,29 @@
import { App } from 'astal/gtk3';
import powermenu from '../../power/helpers/actions.js';
import { PowerOptions } from 'src/lib/types/options.js';
import { execAsync } from 'astal/process.js';
const { confirmation, shutdown, logout, sleep, reboot } = options.menus.dashboard.powermenu;
/**
* Handles the click event for power options.
*
* This function executes the appropriate action based on the provided power option.
* It hides the dashboard menu and either executes the action directly or shows a confirmation dialog.
*
* @param action The power option to handle (shutdown, reboot, logout, sleep).
*/
export const handleClick = (action: PowerOptions): void => {
const actions = {
shutdown: shutdown.get(),
reboot: reboot.get(),
logout: logout.get(),
sleep: sleep.get(),
};
App.get_window('dashboardmenu')?.set_visible(false);
if (!confirmation.get()) {
execAsync(actions[action]).catch((err) => console.error(`Failed to execute ${action} command. Error: ${err}`));
} else {
powermenu.action(action);
}
};

View File

@@ -0,0 +1,14 @@
import { Gtk } from 'astal/gtk3';
import { UserProfile } from './Profile';
import { PowerMenu } from './PowerMenu';
const Profile = (): JSX.Element => {
return (
<box className={'profiles-container'} halign={Gtk.Align.FILL} hexpand>
<UserProfile />
<PowerMenu />
</box>
);
};
export { Profile };

View File

@@ -0,0 +1,65 @@
import { bind, execAsync, Variable } from 'astal';
import { App, Gdk, Gtk } from 'astal/gtk3';
import Menu from 'src/components/shared/Menu';
import MenuItem from 'src/components/shared/MenuItem';
import { hyprlandService } from 'src/lib/constants/services';
import { isRecording } from '../helpers';
const monitorList = Variable(hyprlandService.monitors);
hyprlandService.connect('monitor-added', () => monitorList.set(hyprlandService.monitors));
hyprlandService.connect('monitor-removed', () => monitorList.set(hyprlandService.monitors));
const MonitorListDropdown = (): JSX.Element => {
return (
<Menu className={'dropdown recording'} halign={Gtk.Align.FILL} hexpand>
{bind(monitorList).as((monitors) => {
return monitors.map((monitor) => (
<MenuItem
label={`Display ${monitor.name}`}
onButtonPressEvent={(_, event) => {
const buttonClicked = event.get_button()[1];
if (buttonClicked !== Gdk.BUTTON_PRIMARY) {
return;
}
App.get_window('dashboardmenu')?.set_visible(false);
execAsync(`${SRC_DIR}/scripts/screen_record.sh start ${monitor.name}`).catch((err) =>
console.error(err),
);
}}
/>
));
})}
</Menu>
);
};
export const RecordingButton = (): JSX.Element => {
return (
<button
className={`dashboard-button record ${isRecording.get() ? 'active' : ''}`}
tooltipText={'Record Screen'}
vexpand
onButtonPressEvent={(_, event) => {
const buttonClicked = event.get_button()[1];
if (buttonClicked !== Gdk.BUTTON_PRIMARY) {
return;
}
if (isRecording.get() === true) {
App.get_window('dashboardmenu')?.set_visible(false);
return execAsync(`${SRC_DIR}/scripts/screen_record.sh stop`).catch((err) => console.error(err));
} else {
const monitorDropdownList = MonitorListDropdown() as Gtk.Menu;
monitorDropdownList.popup_at_pointer(event);
}
}}
>
<label className={'button-label txt-icon'} label={'󰑊'} />
</button>
);
};

View File

@@ -0,0 +1,23 @@
import { App, Gdk } from 'astal/gtk3';
export const SettingsButton = (): JSX.Element => {
return (
<button
className={'dashboard-button'}
tooltipText={'HyprPanel Configuration'}
vexpand
onButtonPressEvent={(_, event) => {
const buttonClicked = event.get_button()[1];
if (buttonClicked !== Gdk.BUTTON_PRIMARY) {
return;
}
App.get_window('dashboardmenu')?.set_visible(false);
App.toggle_window('settings-dialog');
}}
>
<label className={'button-label txt-icon'} label={'󰒓'} />
</button>
);
};

View File

@@ -0,0 +1,62 @@
import { Widget } from 'astal/gtk3';
import { ShortcutVariable } from 'src/lib/types/dashboard';
import { isPrimaryClick } from 'src/lib/utils';
import { handleClick, hasCommand } from '../helpers';
import options from 'src/options';
const { left, right } = options.menus.dashboard.shortcuts;
const ShortcutButton = ({ shortcut, ...props }: ShortcutButtonProps): JSX.Element => {
return (
<button
vexpand
tooltipText={shortcut.tooltip.get()}
onClick={(_, event) => {
if (isPrimaryClick(event)) {
handleClick(shortcut.command.get());
}
}}
{...props}
>
<label className={'button-label txt-icon'} label={shortcut.icon.get()} />
</button>
);
};
export const LeftShortcut1 = (): JSX.Element => {
return (
<ShortcutButton
shortcut={left.shortcut1}
className={`dashboard-button top-button ${hasCommand(left.shortcut1) ? 'paired' : ''}`}
/>
);
};
export const LeftShortcut2 = (): JSX.Element => {
return <ShortcutButton shortcut={left.shortcut2} className={`dashboard-button`} />;
};
export const LeftShortcut3 = (): JSX.Element => {
return (
<ShortcutButton
shortcut={left.shortcut3}
className={`dashboard-button top-button ${hasCommand(left.shortcut3) ? 'paired' : ''}`}
/>
);
};
export const LeftShortcut4 = (): JSX.Element => {
return <ShortcutButton shortcut={left.shortcut4} className={`dashboard-button `} />;
};
export const RightShortcut1 = (): JSX.Element => {
return <ShortcutButton shortcut={right.shortcut1} className={`dashboard-button top-button paired`} />;
};
export const RightShortcut3 = (): JSX.Element => {
return <ShortcutButton shortcut={right.shortcut3} className={`dashboard-button top-button paired`} />;
};
interface ShortcutButtonProps extends Widget.ButtonProps {
shortcut: ShortcutVariable;
}

View File

@@ -0,0 +1,93 @@
import { bind, execAsync, timeout, Variable } from 'astal';
import { App } from 'astal/gtk3';
import { BashPoller } from 'src/lib/poller/BashPoller';
import { ShortcutVariable } from 'src/lib/types/dashboard';
import options from 'src/options';
const { left } = options.menus.dashboard.shortcuts;
/**
* Handles the recorder status based on the command output.
*
* This function checks if the command output indicates that recording is in progress.
*
* @param commandOutput The output of the command to check.
*
* @returns True if the command output is 'recording', false otherwise.
*/
export const handleRecorder = (commandOutput: string): boolean => {
if (commandOutput === 'recording') {
return true;
}
return false;
};
/**
* Handles the click action for a shortcut.
*
* This function hides the dashboard menu and executes the specified action after an optional timeout.
*
* @param action The action to execute.
* @param tOut The timeout in milliseconds before executing the action. Defaults to 0.
*/
export const handleClick = (action: string, tOut: number = 0): void => {
App.get_window('dashboardmenu')?.set_visible(false);
timeout(tOut, () => {
execAsync(`bash -c "${action}"`)
.then((res) => {
return res;
})
.catch((err) => console.error(err));
});
};
/**
* Checks if a shortcut has a command.
*
* This function determines if the provided shortcut has a command defined.
*
* @param shortCut The shortcut to check.
*
* @returns True if the shortcut has a command, false otherwise.
*/
export const hasCommand = (shortCut: ShortcutVariable): boolean => {
return shortCut.command.get().length > 0;
};
/**
* A variable indicating whether the left card is hidden.
*
* This variable is set to true if none of the left shortcuts have commands defined.
*/
export const leftCardHidden = Variable(
!(
hasCommand(left.shortcut1) ||
hasCommand(left.shortcut2) ||
hasCommand(left.shortcut3) ||
hasCommand(left.shortcut4)
),
);
/**
* A variable representing the polling interval in milliseconds.
*/
export const pollingInterval = Variable(1000);
/**
* A variable indicating whether recording is in progress.
*/
export const isRecording = Variable(false);
/**
* A poller for checking the recording status.
*
* This poller periodically checks the recording status by executing a bash command and updates the `isRecording` variable.
*/
export const recordingPoller = new BashPoller<boolean, []>(
isRecording,
[],
bind(pollingInterval),
`${SRC_DIR}/scripts/screen_record.sh status`,
handleRecorder,
);

View File

@@ -0,0 +1,22 @@
import { Gtk } from 'astal/gtk3';
import { LeftShortcuts, RightShortcuts } from './sections/Section';
import { recordingPoller } from './helpers';
export const Shortcuts = ({ isEnabled }: ShortcutsProps): JSX.Element => {
recordingPoller.initialize();
if (!isEnabled) {
return <box />;
}
return (
<box className={'shortcuts-container'} halign={Gtk.Align.FILL} hexpand>
<LeftShortcuts />
<RightShortcuts />
</box>
);
};
interface ShortcutsProps {
isEnabled: boolean;
}

View File

@@ -0,0 +1,34 @@
import { BindableChild } from 'astal/gtk3/astalify';
export const LeftColumn = ({ visibleClass, children }: LeftColumnProps): JSX.Element => {
return (
<box className={`card-button-section-container ${visibleClass ? 'visible' : ''}`}>
{visibleClass ? (
<box vertical hexpand vexpand>
{children}
</box>
) : (
<box />
)}
</box>
);
};
export const RightColumn = ({ children }: RightColumnProps): JSX.Element => {
return (
<box className={`card-button-section-container`}>
<box vertical hexpand vexpand>
{children}
</box>
</box>
);
};
interface LeftColumnProps {
visibleClass?: boolean;
children?: BindableChild | BindableChild[];
}
interface RightColumnProps {
children?: BindableChild | BindableChild[];
}

View File

@@ -0,0 +1,94 @@
import { bind, Variable } from 'astal';
import options from 'src/options';
import { hasCommand, isRecording, leftCardHidden } from '../helpers';
import {
LeftShortcut1,
LeftShortcut2,
LeftShortcut3,
LeftShortcut4,
RightShortcut1,
RightShortcut3,
} from '../buttons/ShortcutButtons';
import { LeftColumn, RightColumn } from './Column';
import { SettingsButton } from '../buttons/SettingsButton';
import { RecordingButton } from '../buttons/RecordingButton';
const { left, right } = options.menus.dashboard.shortcuts;
const leftBindings = [
bind(left.shortcut1.command),
bind(left.shortcut1.tooltip),
bind(left.shortcut1.icon),
bind(left.shortcut2.command),
bind(left.shortcut2.tooltip),
bind(left.shortcut2.icon),
bind(left.shortcut3.command),
bind(left.shortcut3.tooltip),
bind(left.shortcut3.icon),
bind(left.shortcut4.command),
bind(left.shortcut4.tooltip),
bind(left.shortcut4.icon),
];
const rightBindings = [
bind(right.shortcut1.command),
bind(right.shortcut1.tooltip),
bind(right.shortcut1.icon),
bind(right.shortcut3.command),
bind(right.shortcut3.tooltip),
bind(right.shortcut3.icon),
bind(leftCardHidden),
bind(isRecording),
];
export const LeftShortcuts = (): JSX.Element => {
return (
<box>
{Variable.derive(leftBindings, () => {
const isVisibleLeft = hasCommand(left.shortcut1) || hasCommand(left.shortcut2);
const isVisibleRight = hasCommand(left.shortcut3) || hasCommand(left.shortcut4);
if (!isVisibleLeft && !isVisibleRight) {
leftCardHidden.set(true);
return <box />;
}
leftCardHidden.set(false);
return (
<box className={'container most-used dashboard-card'}>
<LeftColumn visibleClass={isVisibleRight && isVisibleLeft}>
<LeftShortcut1 />
<LeftShortcut2 />
</LeftColumn>
<RightColumn>
<LeftShortcut3 />
<LeftShortcut4 />
</RightColumn>
</box>
);
})()}
</box>
);
};
export const RightShortcuts = (): JSX.Element => {
return (
<box>
{Variable.derive(rightBindings, () => {
return (
<box className={`container utilities dashboard-card ${!leftCardHidden.get() ? 'paired' : ''}`}>
<LeftColumn visibleClass={!leftCardHidden.get()}>
<RightShortcut1 />
<SettingsButton />
</LeftColumn>
<RightColumn>
<RightShortcut3 />
<RecordingButton />
</RightColumn>
</box>
);
})()}
</box>
);
};

View File

@@ -0,0 +1,97 @@
import { bind } from 'astal';
import { Gtk } from 'astal/gtk3';
import { isPrimaryClick } from 'src/lib/utils';
import options from 'src/options';
import { handleClick } from './helpers';
import { Binding } from 'astal';
import { cpuService, gpuService, ramService, storageService } from '.';
import { renderResourceLabel } from 'src/components/bar/utils/helpers';
const { enable_gpu } = options.menus.dashboard.stats;
const StatBar = ({ icon, value, label, stat }: StatBarProps): JSX.Element => {
return (
<box vertical>
<box className={`stat ${stat}`} valign={Gtk.Align.CENTER} hexpand>
<button>
<label className={'txt-icon'} label={icon} />
</button>
<button
onClick={(_, self) => {
if (isPrimaryClick(self)) {
handleClick();
}
}}
>
<levelbar className={'stats-bar'} value={value} valign={Gtk.Align.CENTER} hexpand />
</button>
</box>
<box halign={Gtk.Align.END}>
<label className={`stat-value ${stat}`} label={label} />
</box>
</box>
);
};
export const GpuStat = (): JSX.Element => {
return (
<box>
{bind(enable_gpu).as((enabled) => {
if (!enabled) {
return <box />;
}
return (
<StatBar
icon={'󰢮'}
stat={'gpu'}
value={bind(gpuService.gpuUsage)}
label={bind(gpuService.gpuUsage).as((gpuUsage) => `${Math.floor(gpuUsage * 100)}%`)}
/>
);
})}
</box>
);
};
export const CpuStat = (): JSX.Element => {
return (
<StatBar
icon={''}
stat={'cpu'}
value={bind(cpuService.cpu).as((cpuUsage) => Math.round(cpuUsage) / 100)}
label={bind(cpuService.cpu).as((cpuUsage) => `${Math.round(cpuUsage)}%`)}
/>
);
};
export const RamStat = (): JSX.Element => {
return (
<StatBar
icon={''}
stat={'ram'}
value={bind(ramService.ram).as((ramUsage) => ramUsage.percentage / 100)}
label={bind(ramService.ram).as((ramUsage) => `${renderResourceLabel('used/total', ramUsage, true)}`)}
/>
);
};
export const StorageStat = (): JSX.Element => {
return (
<StatBar
icon={'󰋊'}
stat={'storage'}
value={bind(storageService.storage).as((storageUsage) => storageUsage.percentage / 100)}
label={bind(storageService.storage).as((storageUsage) =>
renderResourceLabel('used/total', storageUsage, true),
)}
/>
);
};
interface StatBarProps {
icon: string;
stat: string;
value: Binding<number> | number;
label: Binding<string> | string;
}

View File

@@ -0,0 +1,117 @@
import { execAsync } from 'astal';
import { App } from 'astal/gtk3';
import options from 'src/options';
import Cpu from 'src/services/Cpu';
import Gpu from 'src/services/Gpu';
import Ram from 'src/services/Ram';
import Storage from 'src/services/Storage';
const { terminal } = options;
const { interval, enabled, enable_gpu } = options.menus.dashboard.stats;
/**
* Handles the click event for the dashboard menu.
*
* This function hides the dashboard menu window and attempts to open the `btop` terminal application.
* If the command fails, it logs an error message.
*/
export const handleClick = (): void => {
App.get_window('dashboardmenu')?.set_visible(false);
execAsync(`bash -c "${terminal} -e btop"`).catch((err) => `Failed to open btop: ${err}`);
};
/**
* Monitors the interval for updating CPU, RAM, and storage services.
*
* This function subscribes to the interval setting and updates the timers for the CPU, RAM, and storage services accordingly.
*
* @param cpuService The CPU service instance.
* @param ramService The RAM service instance.
* @param storageService The storage service instance.
*/
const monitorInterval = (cpuService: Cpu, ramService: Ram, storageService: Storage): void => {
interval.subscribe(() => {
ramService.updateTimer(interval.get());
cpuService.updateTimer(interval.get());
storageService.updateTimer(interval.get());
});
};
/**
* Monitors the enabled state for CPU, RAM, GPU, and storage services.
*
* This function subscribes to the enabled setting and starts or stops the pollers for the CPU, RAM, GPU, and storage services based on the enabled state.
*
* @param cpuService The CPU service instance.
* @param ramService The RAM service instance.
* @param gpuService The GPU service instance.
* @param storageService The storage service instance.
*/
const monitorStatsEnabled = (cpuService: Cpu, ramService: Ram, gpuService: Gpu, storageService: Storage): void => {
enabled.subscribe(() => {
if (!enabled.get()) {
ramService.stopPoller();
cpuService.stopPoller();
gpuService.stopPoller();
storageService.stopPoller();
return;
}
if (enable_gpu.get()) {
gpuService.startPoller();
}
ramService.startPoller();
cpuService.startPoller();
storageService.startPoller();
});
};
/**
* Monitors the GPU tracking enabled state.
*
* This function subscribes to the GPU tracking enabled setting and starts or stops the GPU poller based on the enabled state.
*
* @param gpuService The GPU service instance.
*/
const monitorGpuTrackingEnabled = (gpuService: Gpu): void => {
enable_gpu.subscribe((gpuEnabled) => {
if (gpuEnabled) {
return gpuService.startPoller();
}
gpuService.stopPoller();
});
};
/**
* Initializes the pollers for CPU, RAM, GPU, and storage services.
*
* This function sets up the initial state for the CPU, RAM, GPU, and storage services, including starting the pollers if enabled.
* It also sets up monitoring for interval changes, enabled state changes, and GPU tracking enabled state.
*
* @param cpuService The CPU service instance.
* @param ramService The RAM service instance.
* @param gpuService The GPU service instance.
* @param storageService The storage service instance.
*/
export const initializePollers = (cpuService: Cpu, ramService: Ram, gpuService: Gpu, storageService: Storage): void => {
ramService.setShouldRound(true);
storageService.setShouldRound(true);
if (enabled.get()) {
ramService.startPoller();
cpuService.startPoller();
storageService.startPoller();
}
if (enabled.get() && enable_gpu.get()) {
gpuService.startPoller();
} else {
gpuService.stopPoller();
}
monitorInterval(cpuService, ramService, storageService);
monitorStatsEnabled(cpuService, ramService, gpuService, storageService);
monitorGpuTrackingEnabled(gpuService);
};

View File

@@ -0,0 +1,39 @@
import { Gtk } from 'astal/gtk3';
import { CpuStat, GpuStat, RamStat, StorageStat } from './StatBars';
import { initializePollers } from './helpers';
import Gpu from 'src/services/Gpu';
import Ram from 'src/services/Ram';
import Cpu from 'src/services/Cpu';
import Storage from 'src/services/Storage';
export const ramService = new Ram();
export const cpuService = new Cpu();
export const storageService = new Storage();
export const gpuService = new Gpu();
initializePollers(cpuService, ramService, gpuService, storageService);
export const Stats = ({ isEnabled }: StatsProps): JSX.Element => {
if (!isEnabled) {
return <box />;
}
return (
<box
className={'dashboard-card stats-container'}
valign={Gtk.Align.FILL}
halign={Gtk.Align.FILL}
expand
vertical
>
<CpuStat />
<RamStat />
<GpuStat />
<StorageStat />
</box>
);
};
interface StatsProps {
isEnabled: boolean;
}

View File

@@ -0,0 +1,9 @@
import { Gtk } from 'astal/gtk3';
export const BrightnessHeader = (): JSX.Element => {
return (
<box className={'menu-label-container'} halign={Gtk.Align.FILL}>
<label className={'menu-label'} halign={Gtk.Align.START} label={'Brightness'} hexpand />
</box>
);
};

View File

@@ -0,0 +1,8 @@
import { Gtk } from 'astal/gtk3';
import icons from 'src/lib/icons/icons';
export const BrightnessIcon = (): JSX.Element => {
return (
<icon className={'brightness-slider-icon'} valign={Gtk.Align.CENTER} icon={icons.brightness.screen} vexpand />
);
};

View File

@@ -0,0 +1,16 @@
import { bind } from 'astal';
import { Gtk } from 'astal/gtk3';
import { brightnessService } from 'src/lib/constants/services';
export const BrightnessPercentage = (): JSX.Element => {
return (
<label
className={'brightness-slider-label'}
label={bind(brightnessService, 'screen').as((screenBrightness) => {
return `${Math.round(screenBrightness * 100)}%`;
})}
valign={Gtk.Align.CENTER}
vexpand
/>
);
};

View File

@@ -0,0 +1,22 @@
import { bind } from 'astal';
import { Gtk } from 'astal/gtk3';
import { brightnessService } from 'src/lib/constants/services';
export const BrightnessSlider = (): JSX.Element => {
return (
<slider
className={'menu-active-slider menu-slider brightness'}
value={bind(brightnessService, 'screen')}
onDragged={({ value, dragging }) => {
if (dragging) {
brightnessService.screen = value;
}
}}
valign={Gtk.Align.CENTER}
drawValue={false}
expand
min={0}
max={1}
/>
);
};

View File

@@ -0,0 +1,22 @@
import { Gtk } from 'astal/gtk3';
import { BrightnessHeader } from './Header';
import { BrightnessIcon } from './Icon';
import { BrightnessSlider } from './Slider';
import { BrightnessPercentage } from './Percentage';
const Brightness = (): JSX.Element => {
return (
<box className={'menu-section-container brightness'} vertical>
<BrightnessHeader />
<box className={'menu-items-section'} valign={Gtk.Align.FILL} vexpand vertical>
<box className={'brightness-container'}>
<BrightnessIcon />
<BrightnessSlider />
<BrightnessPercentage />
</box>
</box>
</box>
);
};
export { Brightness };

View File

@@ -0,0 +1,25 @@
import DropdownMenu from '../shared/dropdown/index.js';
import { EnergyProfiles } from './profiles/index.js';
import { Brightness } from './brightness/index.js';
import options from 'src/options.js';
import { bind } from 'astal/binding.js';
import { Gtk } from 'astal/gtk3';
import { RevealerTransitionMap } from 'src/lib/constants/options.js';
const { transition } = options.menus;
export default (): JSX.Element => {
return (
<DropdownMenu
name={'energymenu'}
transition={bind(transition).as((transition) => RevealerTransitionMap[transition])}
>
<box className={'menu-items energy'} halign={Gtk.Align.FILL} hexpand>
<box className={'menu-items-container energy'} halign={Gtk.Align.FILL} hexpand vertical>
<Brightness />
<EnergyProfiles />
</box>
</box>
</DropdownMenu>
);
};

View File

@@ -0,0 +1,13 @@
import { bind } from 'astal';
import { Gtk } from 'astal/gtk3';
import { uptime } from 'src/lib/variables.js';
import { renderUptime } from './helpers';
export const PowerProfileHeader = (): JSX.Element => {
return (
<box className="menu-label-container" halign={Gtk.Align.FILL}>
<label className="menu-label" label="Power Profile" halign={Gtk.Align.START} hexpand />
<label className="menu-label uptime" label={bind(uptime).as(renderUptime)} tooltipText="Uptime" />
</box>
);
};

View File

@@ -0,0 +1,40 @@
import { bind } from 'astal';
import { Gtk } from 'astal/gtk3';
import AstalPowerProfiles from 'gi://AstalPowerProfiles?version=0.1';
import { powerProfilesService } from 'src/lib/constants/services';
import icons from 'src/lib/icons/icons';
import { ProfileType } from 'src/lib/types/powerprofiles';
import { isPrimaryClick } from 'src/lib/utils';
export const PowerProfiles = (): JSX.Element => {
const powerProfiles = powerProfilesService.get_profiles();
return (
<box className="menu-items-section" valign={Gtk.Align.FILL} vexpand vertical>
{powerProfiles.map((powerProfile: AstalPowerProfiles.Profile) => {
const profileType = powerProfile.profile as ProfileType;
return (
<button
className={bind(powerProfilesService, 'activeProfile').as(
(active) => `power-profile-item ${active === powerProfile.profile ? 'active' : ''}`,
)}
onClick={(_, event) => {
if (isPrimaryClick(event)) {
powerProfilesService.activeProfile = powerProfile.profile;
}
}}
>
<box>
<icon
className="power-profile-icon"
icon={icons.powerprofile[profileType] || icons.powerprofile.balanced}
/>
<label className="power-profile-label" label={profileType} />
</box>
</button>
);
})}
</box>
);
};

View File

@@ -0,0 +1,15 @@
/**
* Renders the uptime in a human-readable format.
*
* This function takes the current uptime in minutes and converts it to a string format showing days, hours, and minutes.
*
* @param curUptime The current uptime in minutes.
*
* @returns A string representing the uptime in days, hours, and minutes.
*/
export const 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`;
};

View File

@@ -0,0 +1,11 @@
import { PowerProfileHeader } from './Header';
import { PowerProfiles } from './Profile';
export const EnergyProfiles = (): JSX.Element => {
return (
<box className="menu-section-container energy" vertical>
<PowerProfileHeader />
<PowerProfiles />
</box>
);
};

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 const DropdownMenus = [
AudioMenu,
NetworkMenu,
BluetoothMenu,
MediaMenu,
NotificationsMenu,
CalendarMenu,
EnergyMenu,
DashboardMenu,
PowerDropdown,
];
export const StandardWindows = [PowerMenu, Verification];

View File

@@ -0,0 +1,23 @@
import { getBackground } from './helpers.js';
import { Gtk } from 'astal/gtk3';
import { BindableChild } from 'astal/gtk3/astalify.js';
export const MediaContainer = ({ children }: MediaContainerProps): JSX.Element => {
return (
<box className="menu-items media" halign={Gtk.Align.FILL} hexpand>
<box className="menu-items-container media" halign={Gtk.Align.FILL} hexpand>
<box className={'menu-section-container'}>
<box className={'menu-items-section'} vertical={false}>
<box className={'menu-content'} css={getBackground()} halign={Gtk.Align.FILL} hexpand vertical>
{children}
</box>
</box>
</box>
</box>
</box>
);
};
interface MediaContainerProps {
children?: BindableChild | BindableChild[];
}

View File

@@ -0,0 +1,107 @@
import icons from 'src/lib/icons/icons';
import { Astal, Gtk, Widget } from 'astal/gtk3';
import { isPrimaryClick } from 'src/lib/utils';
import { bind } from 'astal';
import { isLoopActive, isShuffleActive, loopIconMap, loopTooltipMap } from './helpers';
import AstalMpris from 'gi://AstalMpris?version=0.1';
import { activePlayer, loopStatus, shuffleStatus } from 'src/globals/media';
export type LoopStatus = 'none' | 'track' | 'playlist';
export const Loop = (): JSX.Element => {
const className = bind(loopStatus).as((status) => {
const isActive = isLoopActive(status);
const loopingAllowed = status !== null && status !== AstalMpris.Loop.UNSUPPORTED ? 'enabled' : 'disabled';
return `media-indicator-control-button loop ${isActive} ${loopingAllowed}`;
});
const tooltipText = bind(loopStatus).as((status) => {
if (status === null) {
return 'Unavailable';
}
return loopTooltipMap[status];
});
const iconBinding = bind(loopStatus).as((status) => {
if (status === null || status === AstalMpris.Loop.UNSUPPORTED) {
return icons.mpris.loop.none;
}
return icons.mpris.loop[loopIconMap[status]];
});
const onClick = (_: Widget.Button, event: Astal.ClickEvent): void => {
if (!isPrimaryClick(event)) {
return;
}
const currentPlayer = activePlayer.get();
if (currentPlayer && currentPlayer.loopStatus !== AstalMpris.Loop.UNSUPPORTED) {
currentPlayer.loop();
}
};
return (
<box className="media-indicator-control loop">
<button
className={className}
halign={Gtk.Align.CENTER}
hasTooltip
tooltipText={tooltipText}
onClick={onClick}
>
<icon icon={iconBinding} />
</button>
</box>
);
};
export const Shuffle = (): JSX.Element => {
const className = bind(shuffleStatus).as((status) => {
const isActive = isShuffleActive(status);
const shuffleAllowed = status !== null && status !== AstalMpris.Shuffle.UNSUPPORTED ? 'enabled' : 'disabled';
return `media-indicator-control-button shuffle ${isActive} ${shuffleAllowed}`;
});
const tooltipText = bind(shuffleStatus).as((status) => {
if (status === null || status === AstalMpris.Shuffle.UNSUPPORTED) {
return 'Unavailable';
}
const shuffleTooltipMap = {
[AstalMpris.Shuffle.ON]: 'Shuffling',
[AstalMpris.Shuffle.OFF]: 'Not Shuffling',
[AstalMpris.Shuffle.UNSUPPORTED]: 'Unsupported',
};
return shuffleTooltipMap[status];
});
const onClick = (_: Widget.Button, self: Astal.ClickEvent): void => {
if (!isPrimaryClick(self)) {
return;
}
const currentPlayer = activePlayer.get();
if (currentPlayer && currentPlayer.shuffleStatus !== AstalMpris.Shuffle.UNSUPPORTED) {
currentPlayer.shuffle();
}
};
return (
<box className={'media-indicator-control shuffle'}>
<button
className={className}
halign={Gtk.Align.CENTER}
hasTooltip
tooltipText={tooltipText}
onClick={onClick}
>
<icon icon={icons.mpris.shuffle.enabled} />
</button>
</box>
);
};

View File

@@ -0,0 +1,38 @@
import { Astal, Gtk, Widget } from 'astal/gtk3';
import { isPrimaryClick } from 'src/lib/utils';
import { bind } from 'astal';
import { getPlaybackIcon } from './helpers';
import AstalMpris from 'gi://AstalMpris?version=0.1';
import { activePlayer, canPlay, playbackStatus } from 'src/globals/media';
export const PlayPause = (): JSX.Element => {
const className = bind(canPlay).as((canPlay) => {
return `media-indicator-control-button play ${canPlay ? 'enabled' : 'disabled'}`;
});
const icon = bind(playbackStatus).as((status) => {
return getPlaybackIcon(status);
});
const tooltipText = bind(playbackStatus).as((playbackStatus) => {
return playbackStatus === AstalMpris.PlaybackStatus.PLAYING ? 'Pause' : 'Play';
});
const onClick = (_: Widget.Button, event: Astal.ClickEvent): void => {
if (!isPrimaryClick(event)) {
return;
}
const currentPlayer = activePlayer.get();
if (currentPlayer && currentPlayer.can_play) {
currentPlayer.play_pause();
}
};
return (
<button className={className} halign={Gtk.Align.CENTER} hasTooltip tooltipText={tooltipText} onClick={onClick}>
<icon icon={icon} />
</button>
);
};

View File

@@ -0,0 +1,66 @@
import { bind } from 'astal';
import { Astal, Gtk, Widget } from 'astal/gtk3';
import { mprisService } from 'src/lib/constants/services';
import { isPrimaryClick } from 'src/lib/utils';
import { getNextPlayer, getPreviousPlayer } from './helpers';
export const PreviousPlayer = (): JSX.Element => {
const className = bind(mprisService, 'players').as(() => {
const isDisabled = mprisService.players.length <= 1 ? 'disabled' : 'enabled';
return `media-indicator-control-button ${isDisabled}`;
});
const onClick = (_: Widget.Button, event: Astal.ClickEvent): void => {
if (!isPrimaryClick(event)) {
return;
}
const isDisabled = mprisService.players.length <= 1;
if (!isDisabled) {
getPreviousPlayer();
}
};
return (
<button
className={className}
halign={Gtk.Align.CENTER}
hasTooltip
tooltipText={'Previous Player'}
onClick={onClick}
>
<label label={'󰅁'} />
</button>
);
};
export const NextPlayer = (): JSX.Element => {
const className = bind(mprisService, 'players').as(() => {
const isDisabled = mprisService.players.length <= 1 ? 'disabled' : 'enabled';
return `media-indicator-control-button ${isDisabled}`;
});
const onClick = (_: Widget.Button, event: Astal.ClickEvent): void => {
if (!isPrimaryClick(event)) {
return;
}
const isDisabled = mprisService.players.length <= 1;
if (!isDisabled) {
getNextPlayer();
}
};
return (
<button
className={className}
halign={Gtk.Align.CENTER}
hasTooltip
tooltipText={'Next Player'}
onClick={onClick}
>
<label label={'󰅂'} />
</button>
);
};

View File

@@ -0,0 +1,71 @@
import icons from 'src/lib/icons/icons';
import { Astal, Gtk, Widget } from 'astal/gtk3';
import { isPrimaryClick } from 'src/lib/utils';
import { bind } from 'astal';
import { activePlayer, canGoNext, canGoPrevious } from 'src/globals/media';
export const NextTrack = (): JSX.Element => {
const className = bind(canGoNext).as((skippable) => {
const nextStatus = skippable ? 'enabled' : 'disabled';
return `media-indicator-control-button next ${nextStatus}`;
});
const onClick = (_: Widget.Button, event: Astal.ClickEvent): void => {
if (!isPrimaryClick(event)) {
return;
}
const currentPlayer = activePlayer.get();
if (currentPlayer && currentPlayer.can_go_next) {
currentPlayer.next();
}
};
return (
<box className={'media-indicator-control next'}>
<button
className={className}
halign={Gtk.Align.CENTER}
hasTooltip
tooltipText={'Next Track'}
onClick={onClick}
>
<icon icon={icons.mpris.next} />
</button>
</box>
);
};
export const PreviousTrack = (): JSX.Element => {
const className = bind(canGoPrevious).as((rewindable) => {
const prevStatus = rewindable ? 'enabled' : 'disabled';
return `media-indicator-control-button prev ${prevStatus}`;
});
const onClick = (_: Widget.Button, event: Astal.ClickEvent): void => {
if (!isPrimaryClick(event)) {
return;
}
const currentPlayer = activePlayer.get();
if (currentPlayer && currentPlayer.can_go_previous) {
currentPlayer.previous();
}
};
return (
<button
className={className}
halign={Gtk.Align.CENTER}
hasTooltip
tooltipText={'Previous Track'}
onClick={onClick}
>
<icon icon={icons.mpris.prev} />
</button>
);
};

View File

@@ -0,0 +1,122 @@
import AstalMpris from 'gi://AstalMpris?version=0.1';
import { activePlayer } from 'src/globals/media';
import { mprisService } from 'src/lib/constants/services';
import icons2 from 'src/lib/icons/icons2';
import { PlaybackIconMap } from 'src/lib/types/mpris';
/**
* Determines if the loop status is active.
*
* This function checks if the provided loop status is either PLAYLIST or TRACK.
* If the status matches, it returns 'active'; otherwise, it returns an empty string.
*
* @param status The loop status to check.
*
* @returns 'active' if the loop status is PLAYLIST or TRACK, otherwise an empty string.
*/
export const isLoopActive = (status: AstalMpris.Loop): string => {
return [AstalMpris.Loop.PLAYLIST, AstalMpris.Loop.TRACK].includes(status) ? 'active' : '';
};
export const loopIconMap: Record<AstalMpris.Loop, keyof typeof icons2.mpris.loop> = {
[AstalMpris.Loop.NONE]: 'none',
[AstalMpris.Loop.UNSUPPORTED]: 'none',
[AstalMpris.Loop.TRACK]: 'track',
[AstalMpris.Loop.PLAYLIST]: 'playlist',
};
const playbackIconMap: PlaybackIconMap = {
[AstalMpris.PlaybackStatus.PLAYING]: 'playing',
[AstalMpris.PlaybackStatus.PAUSED]: 'paused',
[AstalMpris.PlaybackStatus.STOPPED]: 'stopped',
};
export const loopTooltipMap: Record<AstalMpris.Loop, string> = {
[AstalMpris.Loop.NONE]: 'Not Looping',
[AstalMpris.Loop.UNSUPPORTED]: 'Unsupported',
[AstalMpris.Loop.TRACK]: 'Looping Track',
[AstalMpris.Loop.PLAYLIST]: 'Looping Playlist',
};
/**
* Retrieves the playback icon for the given playback status.
*
* This function returns the corresponding icon name for the provided playback status from the `icons2.mpris` object.
*
* @param playbackStatus The playback status to get the icon for.
*
* @returns The icon name for the given playback status.
*/
export const getPlaybackIcon = (playbackStatus: AstalMpris.PlaybackStatus): string => {
const playbackIcon = playbackIconMap[playbackStatus];
const mprisIcons = icons2.mpris;
return mprisIcons[playbackIcon as keyof typeof mprisIcons] as string;
};
/**
* Determines if the shuffle status is active.
*
* This function checks if the provided shuffle status is ON.
* If the status matches, it returns 'active'; otherwise, it returns an empty string.
*
* @param status The shuffle status to check.
*
* @returns 'active' if the shuffle status is ON, otherwise an empty string.
*/
export const isShuffleActive = (status: AstalMpris.Shuffle): string => {
if (status === AstalMpris.Shuffle.ON) {
return 'active';
}
return '';
};
/**
* Sets the next active player.
*
* This function sets the next player in the `mprisService.players` array as the active player.
* If there is only one player, it sets that player as the active player.
*
* @returns void
*/
export const getNextPlayer = (): void => {
const currentPlayer = activePlayer.get();
if (currentPlayer === undefined) {
return;
}
const currentPlayerIndex = mprisService.players.findIndex((player) => player.busName === currentPlayer.busName);
const totalPlayers = mprisService.players.length;
if (totalPlayers === 1) {
return activePlayer.set(mprisService.players[0]);
}
return activePlayer.set(mprisService.players[(currentPlayerIndex + 1) % totalPlayers]);
};
/**
* Sets the previous active player.
*
* This function sets the previous player in the `mprisService.players` array as the active player.
* If there is only one player, it sets that player as the active player.
*
* @returns void
*/
export const getPreviousPlayer = (): void => {
const currentPlayer = activePlayer.get();
if (currentPlayer === undefined) {
return;
}
const currentPlayerIndex = mprisService.players.findIndex((player) => player.busName === currentPlayer.busName);
const totalPlayers = mprisService.players.length;
if (totalPlayers === 1) {
return activePlayer.set(mprisService.players[0]);
}
return activePlayer.set(mprisService.players[(currentPlayerIndex - 1 + totalPlayers) % totalPlayers]);
};

View File

@@ -0,0 +1,22 @@
import { BoxWidget } from 'src/lib/types/widget.js';
import { NextTrack, PreviousTrack } from './Tracks.js';
import { PlayPause } from './PlayPause.js';
import { Loop, Shuffle } from './Modes.js';
import { Gtk } from 'astal/gtk3';
import { NextPlayer, PreviousPlayer } from './Players.js';
export const MediaControls = (): BoxWidget => {
return (
<box className={'media-indicator-current-player-controls'} vertical>
<box className={'media-indicator-current-controls'} halign={Gtk.Align.CENTER}>
<PreviousPlayer />
<Shuffle />
<PreviousTrack />
<PlayPause />
<NextTrack />
<Loop />
<NextPlayer />
</box>
</box>
);
};

View File

@@ -0,0 +1,81 @@
import { Binding } from 'astal';
import { bind, Variable } from 'astal';
import AstalMpris from 'gi://AstalMpris?version=0.1';
import { mediaArtUrl } from 'src/globals/media';
import { mprisService } from 'src/lib/constants/services';
import options from 'src/options';
const { tint, color } = options.theme.bar.menus.menu.media.card;
const curPlayer = Variable('');
/**
* Generates CSS for album art with a tinted background.
*
* This function creates a CSS string for the album art background using the provided image URL.
* It applies a linear gradient tint based on the user's theme settings for tint and color.
*
* @param imageUrl The URL of the album art image.
*
* @returns A CSS string for the album art background.
*/
export const generateAlbumArt = (imageUrl: string): string => {
const userTint = tint.get();
const userHexColor = color.get();
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;
};
/**
* Initializes the active player hook.
*
* This function sets up a listener for changes in the MPRIS service.
* It updates the current player based on the playback status and the order of players.
*/
export const initializeActivePlayerHook = (): void => {
mprisService.connect('changed', () => {
const statusOrder = {
[AstalMpris.PlaybackStatus.PLAYING]: 1,
[AstalMpris.PlaybackStatus.PAUSED]: 2,
[AstalMpris.PlaybackStatus.STOPPED]: 3,
};
const isPlaying = mprisService.players.find((p) => p['playbackStatus'] === AstalMpris.PlaybackStatus.PLAYING);
const playerStillExists = mprisService.players.some((player) => curPlayer.set(player.busName));
const nextPlayerUp = mprisService.players.sort(
(a, b) => statusOrder[a.playbackStatus] - statusOrder[b.playbackStatus],
)[0].bus_name;
if (isPlaying || !playerStillExists) {
curPlayer.set(nextPlayerUp);
}
});
};
/**
* Retrieves the background binding for the media card.
*
* This function sets up a derived variable that updates the background CSS for the media card
* based on the current theme settings for color, tint, and media art URL.
*
* @returns A Binding<string> representing the background CSS for the media card.
*/
export const getBackground = (): Binding<string> => {
return Variable.derive([bind(color), bind(tint), bind(mediaArtUrl)], (_, __, artUrl) => {
return generateAlbumArt(artUrl);
})();
};

View File

@@ -0,0 +1,44 @@
/**
* Updates the tooltip text of the slider based on the player's current position.
*
* This function generates a formatted timestamp string that shows the current position and total length of the media.
* If the position is invalid, it returns a default timestamp of "00:00".
*
* @param position The current position of the player in seconds.
* @param totalLength The total length of the media in seconds.
*
* @returns A formatted timestamp string showing the current position and total length.
*/
export const getTimeStamp = (position: number, totalLength: number): string => {
if (typeof position === 'number' && position >= 0) {
return `${getFormattedTime(position)} / ${getFormattedTime(totalLength)}`;
} else {
return `00:00`;
}
};
/**
* Formats a given time in seconds into a human-readable string.
*
* This function converts a time value in seconds into a formatted string in the format "HH:MM:SS" or "MM:SS".
* It handles hours, minutes, and seconds, and ensures that each component is zero-padded to two digits.
*
* @param time The time value in seconds to format.
*
* @returns A formatted time string in the format "HH:MM:SS" or "MM:SS".
*/
export const getFormattedTime = (time: number): string => {
const curHour = Math.floor(time / 3600);
const curMin = Math.floor((time % 3600) / 60);
const curSec = Math.floor(time % 60);
const formatTime = (time: number): string => {
return time.toString().padStart(2, '0');
};
const formatHour = (hour: number): string => {
return hour > 0 ? formatTime(hour) + ':' : '';
};
return `${formatHour(curHour)}${formatTime(curMin)}:${formatTime(curSec)}`;
};

View File

@@ -0,0 +1,47 @@
import options from 'src/options';
import { bind, Variable } from 'astal';
import { Widget } from 'astal/gtk3';
import { activePlayer, currentPosition, timeStamp } from 'src/globals/media';
const { displayTimeTooltip } = options.menus.media;
export const MediaSlider = (): JSX.Element => {
const sliderValue = Variable.derive([bind(activePlayer), bind(currentPosition)], (player, position) => {
if (player === undefined) {
return 0;
}
if (player.length > 0) {
return position / player.length;
}
return 0;
});
const dragHandler = ({ value }: Widget.Slider): void => {
const currentPlayer = activePlayer.get();
if (currentPlayer !== undefined) {
currentPlayer.set_position(value * currentPlayer.length);
}
};
return (
<box
className={'media-indicator-current-progress-bar'}
hexpand
onDestroy={() => {
sliderValue.drop();
}}
>
<slider
className={'menu-slider media progress'}
hasTooltip={bind(displayTimeTooltip)}
tooltipText={bind(timeStamp)}
value={sliderValue()}
onDragged={dragHandler}
drawValue={false}
hexpand
/>
</box>
);
};

View File

@@ -0,0 +1,17 @@
import options from 'src/options';
import { bind } from 'astal';
import { timeStamp } from 'src/globals/media';
const { displayTime } = options.menus.media;
export const MediaTimeStamp = (): JSX.Element => {
if (!displayTime.get()) {
return <box />;
}
return (
<box className="media-indicator-current-time-label" hexpand>
<label className="time-label" label={bind(timeStamp)} hexpand />
</box>
);
};

View File

@@ -0,0 +1,24 @@
import options from 'src/options';
import { Gtk } from 'astal/gtk3';
import { bind } from 'astal';
import { mediaAlbum } from 'src/globals/media';
const { hideAlbum } = options.menus.media;
export const SongAlbum = (): JSX.Element => {
if (hideAlbum.get()) {
return <box />;
}
return (
<box className={'media-indicator-current-song-album'} halign={Gtk.Align.CENTER}>
<label
className={'media-indicator-current-song-album-label'}
label={bind(mediaAlbum)}
maxWidthChars={40}
truncate
wrap
/>
</box>
);
};

View File

@@ -0,0 +1,24 @@
import options from 'src/options';
import { Gtk } from 'astal/gtk3';
import { bind } from 'astal';
import { mediaArtist } from 'src/globals/media';
const { hideAuthor } = options.menus.media;
export const SongAuthor = (): JSX.Element => {
if (hideAuthor.get()) {
return <box />;
}
return (
<box className={'media-indicator-current-song-author'} halign={Gtk.Align.CENTER}>
<label
className={'media-indicator-current-song-author-label'}
label={bind(mediaArtist)}
maxWidthChars={35}
truncate
wrap
/>
</box>
);
};

View File

@@ -0,0 +1,17 @@
import { Gtk } from 'astal/gtk3';
import { bind } from 'astal';
import { mediaTitle } from 'src/globals/media';
export const SongName = (): JSX.Element => {
return (
<box className={'media-indicator-current-song-name'} halign={Gtk.Align.CENTER}>
<label
className={'media-indicator-current-song-name-label'}
label={bind(mediaTitle)}
maxWidthChars={31}
truncate
wrap
/>
</box>
);
};

View File

@@ -0,0 +1,14 @@
import { SongName } from './SongName';
import { SongAuthor } from './SongAuthor';
import { SongAlbum } from './SongAlbum';
import { Gtk } from 'astal/gtk3';
export const MediaInfo = (): JSX.Element => {
return (
<box className={'media-indicator-current-media-info'} halign={Gtk.Align.CENTER} hexpand vertical>
<SongName />
<SongAuthor />
<SongAlbum />
</box>
);
};

View File

@@ -0,0 +1,27 @@
import { bind } from 'astal/binding.js';
import DropdownMenu from '../shared/dropdown/index.js';
import options from 'src/options.js';
import { MediaContainer } from './components/MediaContainer.js';
import { MediaInfo } from './components/title/index.js';
import { MediaControls } from './components/controls/index.js';
import { MediaSlider } from './components/timebar/index.js';
import { MediaTimeStamp } from './components/timelabel/index.js';
import { RevealerTransitionMap } from 'src/lib/constants/options.js';
const { transition } = options.menus;
export default (): JSX.Element => {
return (
<DropdownMenu
name="mediamenu"
transition={bind(transition).as((transition) => RevealerTransitionMap[transition])}
>
<MediaContainer>
<MediaInfo />
<MediaControls />
<MediaSlider />
<MediaTimeStamp />
</MediaContainer>
</DropdownMenu>
);
};

View File

@@ -0,0 +1,119 @@
import { bind, Variable } from 'astal';
import AstalNetwork from 'gi://AstalNetwork?version=0.1';
import { networkService } from 'src/lib/constants/services';
/*******************************************
* Values *
*******************************************/
export const wiredState: Variable<AstalNetwork.DeviceState> = Variable(AstalNetwork.DeviceState.UNKNOWN);
export const wiredInternet: Variable<AstalNetwork.Internet> = Variable(AstalNetwork.Internet.DISCONNECTED);
export const wiredIcon: Variable<string> = Variable('');
export const wiredSpeed: Variable<number> = Variable(0);
/*******************************************
* Bindings *
*******************************************/
let wiredStateBinding: Variable<void>;
let wiredInternetBinding: Variable<void>;
let wiredIconBinding: Variable<void>;
let wiredSpeedBinding: Variable<void>;
/**
* Retrieves the current state of the wired network.
*
* This function sets up a binding to the `state` property of the wired network service.
* If the wired network service is available, it updates the `wiredState` variable with the current state.
*/
const getWiredState = (): void => {
if (wiredStateBinding) {
wiredStateBinding();
wiredStateBinding.drop();
}
if (!networkService.wired) {
wiredState.set(AstalNetwork.DeviceState.UNAVAILABLE);
return;
}
wiredStateBinding = Variable.derive([bind(networkService.wired, 'state')], (state) => {
wiredState.set(state);
});
};
/**
* Retrieves the current internet status of the wired network.
*
* This function sets up a binding to the `internet` property of the wired network service.
* If the wired network service is available, it updates the `wiredInternet` variable with the current internet status.
*/
const getWiredInternet = (): void => {
if (wiredInternetBinding) {
wiredInternetBinding();
wiredInternetBinding.drop();
}
if (!networkService.wired) {
return;
}
wiredInternetBinding = Variable.derive([bind(networkService.wired, 'internet')], (internet) => {
wiredInternet.set(internet);
});
};
/**
* Retrieves the current icon for the wired network.
*
* This function sets up a binding to the `iconName` property of the wired network service.
* If the wired network service is available, it updates the `wiredIcon` variable with the current icon name.
*/
const getWiredIcon = (): void => {
if (wiredIconBinding) {
wiredIconBinding();
wiredIconBinding.drop();
}
if (!networkService.wired) {
wiredIcon.set('network-wired-symbolic');
return;
}
wiredIconBinding = Variable.derive([bind(networkService.wired, 'iconName')], (icon) => {
wiredIcon.set(icon);
});
};
/**
* Retrieves the current speed of the wired network.
*
* This function sets up a binding to the `speed` property of the wired network service.
* If the wired network service is available, it updates the `wiredSpeed` variable with the current speed.
*/
const getWiredSpeed = (): void => {
if (wiredSpeedBinding) {
wiredSpeedBinding();
wiredSpeedBinding.drop();
}
if (!networkService.wired) {
return;
}
wiredSpeedBinding = Variable.derive([bind(networkService.wired, 'speed')], (speed) => {
wiredSpeed.set(speed);
});
};
Variable.derive([bind(networkService, 'wired')], () => {
getWiredState();
getWiredInternet();
getWiredIcon();
getWiredSpeed();
});
Variable.derive([bind(networkService, 'wired')], () => {
getWiredState();
getWiredInternet();
getWiredIcon();
getWiredSpeed();
});

View File

@@ -0,0 +1,52 @@
import { Gtk } from 'astal/gtk3';
import { bind } from 'astal/binding';
import AstalNetwork from 'gi://AstalNetwork?version=0.1';
import { DEVICE_STATES } from 'src/lib/constants/network';
import { wiredIcon, wiredInternet, wiredSpeed, wiredState } from './helpers';
export const Ethernet = (): JSX.Element => {
return (
<box className={'menu-section-container ethernet'} vertical>
<box className={'menu-label-container'} halign={Gtk.Align.FILL}>
<label className={'menu-label'} halign={Gtk.Align.START} hexpand label={'Ethernet'} />
</box>
<box className={'menu-items-section'} vertical>
<box className={'menu-content'} vertical>
<box className={'network-element-item'}>
<box halign={Gtk.Align.START}>
<icon
className={bind(wiredState).as((state) => {
return `network-icon ethernet ${state === AstalNetwork.DeviceState.ACTIVATED ? 'active' : ''}`;
})}
tooltipText={bind(wiredInternet).as((internet) => {
return internet.toString();
})}
icon={bind(wiredIcon)}
/>
<box className={'connection-container'} vertical>
<label
className={'active-connection'}
halign={Gtk.Align.START}
truncate
wrap
label={bind(wiredSpeed).as((speed) => {
return `Ethernet Connection (${speed} Mbps)`;
})}
/>
<label
className={'connection-status dim'}
halign={Gtk.Align.START}
truncate
wrap
label={bind(wiredState).as((state) => {
return DEVICE_STATES[state];
})}
/>
</box>
</box>
</box>
</box>
</box>
</box>
);
};

Some files were not shown because too many files have changed in this diff Show More