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:
40
src/components/menus/audio/active/device/Slider.tsx
Normal file
40
src/components/menus/audio/active/device/Slider.tsx
Normal 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';
|
||||
}
|
||||
39
src/components/menus/audio/active/device/SliderIcon.tsx
Normal file
39
src/components/menus/audio/active/device/SliderIcon.tsx
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
21
src/components/menus/audio/active/device/index.tsx
Normal file
21
src/components/menus/audio/active/device/index.tsx
Normal 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;
|
||||
}
|
||||
34
src/components/menus/audio/active/index.tsx
Normal file
34
src/components/menus/audio/active/index.tsx
Normal 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[];
|
||||
}
|
||||
52
src/components/menus/audio/available/Device.tsx
Normal file
52
src/components/menus/audio/available/Device.tsx
Normal 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;
|
||||
}
|
||||
14
src/components/menus/audio/available/Header.tsx
Normal file
14
src/components/menus/audio/available/Header.tsx
Normal 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;
|
||||
}
|
||||
24
src/components/menus/audio/available/InputDevices.tsx
Normal file
24
src/components/menus/audio/available/InputDevices.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
13
src/components/menus/audio/available/NotFoundButton.tsx
Normal file
13
src/components/menus/audio/available/NotFoundButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
24
src/components/menus/audio/available/PlaybackDevices.tsx
Normal file
24
src/components/menus/audio/available/PlaybackDevices.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
15
src/components/menus/audio/available/index.tsx
Normal file
15
src/components/menus/audio/available/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
23
src/components/menus/audio/index.tsx
Normal file
23
src/components/menus/audio/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
40
src/components/menus/audio/utils.ts
Normal file
40
src/components/menus/audio/utils.ts
Normal 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 };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
17
src/components/menus/bluetooth/devices/DeviceListItem.tsx
Normal file
17
src/components/menus/bluetooth/devices/DeviceListItem.tsx
Normal 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[];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
26
src/components/menus/bluetooth/devices/controls/index.tsx
Normal file
26
src/components/menus/bluetooth/devices/controls/index.tsx
Normal 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[];
|
||||
}
|
||||
22
src/components/menus/bluetooth/devices/device/DeviceIcon.tsx
Normal file
22
src/components/menus/bluetooth/devices/device/DeviceIcon.tsx
Normal 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[];
|
||||
}
|
||||
20
src/components/menus/bluetooth/devices/device/DeviceName.tsx
Normal file
20
src/components/menus/bluetooth/devices/device/DeviceName.tsx
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
50
src/components/menus/bluetooth/devices/device/index.tsx
Normal file
50
src/components/menus/bluetooth/devices/device/index.tsx
Normal 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[];
|
||||
}
|
||||
60
src/components/menus/bluetooth/devices/helpers.ts
Normal file
60
src/components/menus/bluetooth/devices/helpers.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
41
src/components/menus/bluetooth/devices/index.tsx
Normal file
41
src/components/menus/bluetooth/devices/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
20
src/components/menus/bluetooth/header/Controls/helper.ts
Normal file
20
src/components/menus/bluetooth/header/Controls/helper.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
14
src/components/menus/bluetooth/header/Controls/index.tsx
Normal file
14
src/components/menus/bluetooth/header/Controls/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
15
src/components/menus/bluetooth/header/index.tsx
Normal file
15
src/components/menus/bluetooth/header/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
25
src/components/menus/bluetooth/index.tsx
Normal file
25
src/components/menus/bluetooth/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
39
src/components/menus/bluetooth/utils.ts
Normal file
39
src/components/menus/bluetooth/utils.ts
Normal 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 };
|
||||
20
src/components/menus/calendar/CalendarWidget.tsx
Normal file
20
src/components/menus/calendar/CalendarWidget.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
35
src/components/menus/calendar/index.tsx
Normal file
35
src/components/menus/calendar/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
35
src/components/menus/calendar/time/MilitaryTime.tsx
Normal file
35
src/components/menus/calendar/time/MilitaryTime.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
58
src/components/menus/calendar/time/StandardTime.tsx
Normal file
58
src/components/menus/calendar/time/StandardTime.tsx
Normal 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;
|
||||
}
|
||||
14
src/components/menus/calendar/time/index.tsx
Normal file
14
src/components/menus/calendar/time/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
64
src/components/menus/calendar/weather/hourly/helpers.ts
Normal file
64
src/components/menus/calendar/weather/hourly/helpers.ts
Normal 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';
|
||||
}
|
||||
};
|
||||
25
src/components/menus/calendar/weather/hourly/icon/index.tsx
Normal file
25
src/components/menus/calendar/weather/hourly/icon/index.tsx
Normal 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;
|
||||
}
|
||||
18
src/components/menus/calendar/weather/hourly/index.tsx
Normal file
18
src/components/menus/calendar/weather/hourly/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
30
src/components/menus/calendar/weather/hourly/time/index.tsx
Normal file
30
src/components/menus/calendar/weather/hourly/time/index.tsx
Normal 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;
|
||||
}
|
||||
18
src/components/menus/calendar/weather/icon/index.tsx
Normal file
18
src/components/menus/calendar/weather/icon/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
23
src/components/menus/calendar/weather/index.tsx
Normal file
23
src/components/menus/calendar/weather/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
32
src/components/menus/calendar/weather/stats/index.tsx
Normal file
32
src/components/menus/calendar/weather/stats/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
49
src/components/menus/calendar/weather/temperature/index.tsx
Normal file
49
src/components/menus/calendar/weather/temperature/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
109
src/components/menus/dashboard/controls/ControlButtons.tsx
Normal file
109
src/components/menus/dashboard/controls/ControlButtons.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
20
src/components/menus/dashboard/controls/helpers.ts
Normal file
20
src/components/menus/dashboard/controls/helpers.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
22
src/components/menus/dashboard/controls/index.tsx
Normal file
22
src/components/menus/dashboard/controls/index.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
}
|
||||
21
src/components/menus/dashboard/directories/Sections.tsx
Normal file
21
src/components/menus/dashboard/directories/Sections.tsx
Normal 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[];
|
||||
}
|
||||
28
src/components/menus/dashboard/directories/index.tsx
Normal file
28
src/components/menus/dashboard/directories/index.tsx
Normal 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;
|
||||
}
|
||||
46
src/components/menus/dashboard/index.tsx
Normal file
46
src/components/menus/dashboard/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
36
src/components/menus/dashboard/profile/PowerButtons.tsx
Normal file
36
src/components/menus/dashboard/profile/PowerButtons.tsx
Normal 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');
|
||||
};
|
||||
12
src/components/menus/dashboard/profile/PowerMenu.tsx
Normal file
12
src/components/menus/dashboard/profile/PowerMenu.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
47
src/components/menus/dashboard/profile/Profile.tsx
Normal file
47
src/components/menus/dashboard/profile/Profile.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
29
src/components/menus/dashboard/profile/helpers.ts
Normal file
29
src/components/menus/dashboard/profile/helpers.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
14
src/components/menus/dashboard/profile/index.tsx
Normal file
14
src/components/menus/dashboard/profile/index.tsx
Normal 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 };
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
93
src/components/menus/dashboard/shortcuts/helpers.ts
Normal file
93
src/components/menus/dashboard/shortcuts/helpers.ts
Normal 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,
|
||||
);
|
||||
22
src/components/menus/dashboard/shortcuts/index.tsx
Normal file
22
src/components/menus/dashboard/shortcuts/index.tsx
Normal 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;
|
||||
}
|
||||
34
src/components/menus/dashboard/shortcuts/sections/Column.tsx
Normal file
34
src/components/menus/dashboard/shortcuts/sections/Column.tsx
Normal 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[];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
97
src/components/menus/dashboard/stats/StatBars.tsx
Normal file
97
src/components/menus/dashboard/stats/StatBars.tsx
Normal 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;
|
||||
}
|
||||
117
src/components/menus/dashboard/stats/helpers.ts
Normal file
117
src/components/menus/dashboard/stats/helpers.ts
Normal 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);
|
||||
};
|
||||
39
src/components/menus/dashboard/stats/index.tsx
Normal file
39
src/components/menus/dashboard/stats/index.tsx
Normal 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;
|
||||
}
|
||||
9
src/components/menus/energy/brightness/Header.tsx
Normal file
9
src/components/menus/energy/brightness/Header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
8
src/components/menus/energy/brightness/Icon.tsx
Normal file
8
src/components/menus/energy/brightness/Icon.tsx
Normal 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 />
|
||||
);
|
||||
};
|
||||
16
src/components/menus/energy/brightness/Percentage.tsx
Normal file
16
src/components/menus/energy/brightness/Percentage.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
};
|
||||
22
src/components/menus/energy/brightness/Slider.tsx
Normal file
22
src/components/menus/energy/brightness/Slider.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
22
src/components/menus/energy/brightness/index.tsx
Normal file
22
src/components/menus/energy/brightness/index.tsx
Normal 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 };
|
||||
25
src/components/menus/energy/index.tsx
Normal file
25
src/components/menus/energy/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
13
src/components/menus/energy/profiles/Header.tsx
Normal file
13
src/components/menus/energy/profiles/Header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
40
src/components/menus/energy/profiles/Profile.tsx
Normal file
40
src/components/menus/energy/profiles/Profile.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
15
src/components/menus/energy/profiles/helpers.ts
Normal file
15
src/components/menus/energy/profiles/helpers.ts
Normal 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`;
|
||||
};
|
||||
11
src/components/menus/energy/profiles/index.tsx
Normal file
11
src/components/menus/energy/profiles/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
25
src/components/menus/exports.ts
Normal file
25
src/components/menus/exports.ts
Normal 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];
|
||||
23
src/components/menus/media/components/MediaContainer.tsx
Normal file
23
src/components/menus/media/components/MediaContainer.tsx
Normal 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[];
|
||||
}
|
||||
107
src/components/menus/media/components/controls/Modes.tsx
Normal file
107
src/components/menus/media/components/controls/Modes.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
38
src/components/menus/media/components/controls/PlayPause.tsx
Normal file
38
src/components/menus/media/components/controls/PlayPause.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
66
src/components/menus/media/components/controls/Players.tsx
Normal file
66
src/components/menus/media/components/controls/Players.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
71
src/components/menus/media/components/controls/Tracks.tsx
Normal file
71
src/components/menus/media/components/controls/Tracks.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
122
src/components/menus/media/components/controls/helpers.ts
Normal file
122
src/components/menus/media/components/controls/helpers.ts
Normal 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]);
|
||||
};
|
||||
22
src/components/menus/media/components/controls/index.tsx
Normal file
22
src/components/menus/media/components/controls/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
81
src/components/menus/media/components/helpers.ts
Normal file
81
src/components/menus/media/components/helpers.ts
Normal 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);
|
||||
})();
|
||||
};
|
||||
44
src/components/menus/media/components/timebar/helpers.ts
Normal file
44
src/components/menus/media/components/timebar/helpers.ts
Normal 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)}`;
|
||||
};
|
||||
47
src/components/menus/media/components/timebar/index.tsx
Normal file
47
src/components/menus/media/components/timebar/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
17
src/components/menus/media/components/timelabel/index.tsx
Normal file
17
src/components/menus/media/components/timelabel/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
24
src/components/menus/media/components/title/SongAlbum.tsx
Normal file
24
src/components/menus/media/components/title/SongAlbum.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
24
src/components/menus/media/components/title/SongAuthor.tsx
Normal file
24
src/components/menus/media/components/title/SongAuthor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
17
src/components/menus/media/components/title/SongName.tsx
Normal file
17
src/components/menus/media/components/title/SongName.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
14
src/components/menus/media/components/title/index.tsx
Normal file
14
src/components/menus/media/components/title/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
27
src/components/menus/media/index.tsx
Normal file
27
src/components/menus/media/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
119
src/components/menus/network/ethernet/helpers.ts
Normal file
119
src/components/menus/network/ethernet/helpers.ts
Normal 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();
|
||||
});
|
||||
52
src/components/menus/network/ethernet/index.tsx
Normal file
52
src/components/menus/network/ethernet/index.tsx
Normal 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
Reference in New Issue
Block a user