Improve monitor reconnect/disconnect logic. (#974)

* WIP: Improve monitor reconnect logic

* Organize revealer into a controller and clean up bindings of destroyed bars.

* Improve monitor disconnect/reconnect logic.

* Add JSDoc
This commit is contained in:
Jas Singh
2025-06-01 18:23:36 -07:00
committed by GitHub
parent 49dbce1730
commit 9698f9be7c
22 changed files with 706 additions and 333 deletions

View File

@@ -1,19 +1,24 @@
import { GdkMonitorService } from 'src/services/display/monitor';
import { JSXElement } from 'src/core/types';
import { BarLayout } from './layout/BarLayout';
import { getCoreWidgets } from './layout/coreWidgets';
import { WidgetRegistry } from './layout/WidgetRegistry';
const gdkMonitorService = new GdkMonitorService();
const widgetRegistry = new WidgetRegistry(getCoreWidgets());
/**
* Factory function to create a Bar for a specific monitor
* Creates a bar widget for a specific monitor with proper error handling
* to prevent crashes when monitors become invalid.
*
* @param gdkMonitor - The GDK monitor index where the bar will be displayed
* @param hyprlandMonitor - The corresponding Hyprland monitor ID for workspace
* filtering and layout assignment
* @returns A JSX element representing the bar widget for the specified monitor
*/
export const Bar = async (monitor: number): Promise<JSX.Element> => {
export const Bar = async (gdkMonitor: number, hyprlandMonitor?: number): Promise<JSXElement> => {
await widgetRegistry.initialize();
const hyprlandMonitor = gdkMonitorService.mapGdkToHyprland(monitor);
const barLayout = new BarLayout(monitor, hyprlandMonitor, widgetRegistry);
const hyprlandId = hyprlandMonitor ?? gdkMonitor;
const barLayout = new BarLayout(gdkMonitor, hyprlandId, widgetRegistry);
return barLayout.render();
};

View File

@@ -1,10 +1,11 @@
import { App, Gtk } from 'astal/gtk3';
import { App, Gdk, Gtk } from 'astal/gtk3';
import Astal from 'gi://Astal?version=3.0';
import { bind, Binding, Variable } from 'astal';
import { idleInhibit } from 'src/lib/window/visibility';
import { WidgetRegistry } from './WidgetRegistry';
import { getLayoutForMonitor, isLayoutEmpty } from '../utils/monitors';
import options from 'src/configuration';
import { JSXElement } from 'src/core/types';
/**
* Responsible for the bar UI layout and positioning
@@ -46,7 +47,27 @@ export class BarLayout {
this._initializeReactiveVariables();
}
public render(): JSX.Element {
public render(): JSXElement {
const display = Gdk.Display.get_default();
if (!display) {
console.error('[BarLayout] No display available for bar creation');
return null;
}
const monitorCount = display.get_n_monitors();
if (this._gdkMonitor < 0 || this._gdkMonitor >= monitorCount) {
console.error(
`[BarLayout] Invalid monitor index: ${this._gdkMonitor} (total monitors: ${monitorCount})`,
);
return null;
}
const monitor = display.get_monitor(this._gdkMonitor);
if (monitor === null) {
console.error(`[BarLayout] Monitor at index ${this._gdkMonitor} no longer exists`);
return null;
}
return (
<window
inhibit={bind(idleInhibit)}

View File

@@ -1,6 +1,8 @@
import { Gdk } from 'astal/gtk3';
import { range } from 'src/lib/array/helpers';
import { BarLayout, BarLayouts } from 'src/lib/options/types';
import { GdkMonitorService } from 'src/services/display/monitor';
import { MonitorMapping } from './types';
import { JSXElement } from 'src/core/types';
/**
* Returns the bar layout configuration for a specific monitor
@@ -43,17 +45,48 @@ export const isLayoutEmpty = (layout: BarLayout): boolean => {
};
/**
* Generates an array of JSX elements for each monitor.
* Creates widgets for all available monitors with proper GDK to Hyprland monitor mapping.
*
* This function creates an array of JSX elements by calling the provided widget function for each monitor.
* It uses the number of monitors available in the default Gdk display.
*
* @param widget A function that takes a monitor index and returns a JSX element.
*
* @returns An array of JSX elements, one for each monitor.
* @param widget - Function that creates a widget for a given monitor index
* @returns Array of created widgets for all available monitors
*/
export async function forMonitors(widget: (monitor: number) => Promise<JSX.Element>): Promise<JSX.Element[]> {
const n = Gdk.Display.get_default()?.get_n_monitors() ?? 1;
export async function forMonitors(
widget: (monitor: number, hyprlandMonitor?: number) => Promise<JSXElement>,
): Promise<JSXElement[]> {
const display = Gdk.Display.get_default();
if (display === null) {
console.error('[forMonitors] No display available');
return [];
}
return Promise.all(range(n, 0).map(widget));
const monitorCount = display.get_n_monitors();
const gdkMonitorService = GdkMonitorService.getInstance();
const monitorMappings: MonitorMapping[] = [];
for (let gdkMonitorIndex = 0; gdkMonitorIndex < monitorCount; gdkMonitorIndex++) {
const monitor = display.get_monitor(gdkMonitorIndex);
if (monitor === null) {
console.warn(`[forMonitors] Skipping invalid monitor at index ${gdkMonitorIndex}`);
continue;
}
const hyprlandId = gdkMonitorService.mapGdkToHyprland(gdkMonitorIndex);
monitorMappings.push({
gdkIndex: gdkMonitorIndex,
hyprlandId,
});
}
const monitorPromises = monitorMappings.map(async ({ gdkIndex, hyprlandId }) => {
try {
return await widget(gdkIndex, hyprlandId);
} catch (error) {
console.error(`[forMonitors] Failed to create widget for monitor ${gdkIndex}:`, error);
return null;
}
});
const widgets = await Promise.all(monitorPromises);
return widgets.filter((w): w is JSXElement => w !== null);
}

View File

@@ -0,0 +1,4 @@
export interface MonitorMapping {
gdkIndex: number;
hyprlandId: number;
}

View File

@@ -27,9 +27,13 @@ export const NotificationCard = ({
showActions,
...props
}: NotificationCardProps): JSX.Element => {
const actionBox: IActionBox | null = notification.get_actions().length ? (
<Actions notification={notification} showActions={showActions} />
) : null;
let actionBox: ActionBox | null;
if (notification.get_actions().length) {
actionBox = <Actions notification={notification} showActions={showActions} />;
} else {
actionBox = null;
}
return (
<eventbox
@@ -63,11 +67,11 @@ interface NotificationCardProps extends Widget.BoxProps {
showActions: boolean;
}
interface IActionBox extends Gtk.Widget {
interface ActionBox extends Gtk.Widget {
revealChild?: boolean;
}
interface NotificationContentProps {
actionBox: IActionBox | null;
actionBox: ActionBox | null;
notification: AstalNotifd.Notification;
}

View File

@@ -23,14 +23,18 @@ export const notifHasImg = (notification: AstalNotifd.Notification): boolean =>
};
/**
* Tracks the active monitor and updates the provided variable.
* Tracks the currently focused monitor and updates the provided variable with its ID.
* Includes null safety to prevent crashes when monitors are disconnected or during DPMS events.
*
* This function sets up a derived variable that updates the `curMonitor` variable with the ID of the focused monitor.
*
* @param curMonitor The variable to update with the active monitor ID.
* @param curMonitor - Variable that will be updated with the current monitor ID (defaults to 0 if no monitor is focused)
*/
export const trackActiveMonitor = (curMonitor: Variable<number>): void => {
Variable.derive([bind(hyprlandService, 'focusedMonitor')], (monitor) => {
if (monitor?.id === undefined) {
console.warn('No focused monitor available, defaulting to monitor 0');
curMonitor.set(0);
return;
}
curMonitor.set(monitor.id);
});
};

View File

@@ -7,6 +7,7 @@ import AstalHyprland from 'gi://AstalHyprland?version=0.1';
import { GdkMonitorService } from 'src/services/display/monitor/index.js';
import { getPosition } from 'src/lib/window/positioning.js';
import { NotificationCard } from './Notification';
import { App } from 'astal/gtk3';
const hyprlandService = AstalHyprland.get_default();
const { position, monitor, active_monitor, showActionsOnHover, displayedTotal } = options.notifications;
@@ -20,15 +21,13 @@ trackPopupNotifications(popupNotifications);
trackAutoTimeout();
export default (): JSX.Element => {
const gdkMonitorMapper = new GdkMonitorService();
const gdkMonitorMapper = GdkMonitorService.getInstance();
const windowLayer = bind(tear).as((tear) => (tear ? Astal.Layer.TOP : Astal.Layer.OVERLAY));
const windowAnchor = bind(position).as(getPosition);
const windowMonitor = Variable.derive(
[bind(hyprlandService, 'focusedMonitor'), bind(monitor), bind(active_monitor)],
(focusedMonitor, monitor, activeMonitor) => {
gdkMonitorMapper.reset();
if (activeMonitor === true) {
const gdkMonitor = gdkMonitorMapper.mapHyprlandToGdk(focusedMonitor.id);
return gdkMonitor;
@@ -55,6 +54,7 @@ export default (): JSX.Element => {
name={'notifications-window'}
namespace={'notifications-window'}
className={'notifications-window'}
application={App}
layer={windowLayer}
anchor={windowAnchor}
exclusivity={Astal.Exclusivity.NORMAL}

View File

@@ -29,7 +29,7 @@ export const setupOsdBar = (self: LevelBar): void => {
self.value = brightnessService.kbd;
});
Variable.derive([bind(audioService.defaultMicrophone, 'volume')], () => {
const micVolumeBinding = Variable.derive([bind(audioService.defaultMicrophone, 'volume')], () => {
self.toggleClassName('overflow', audioService.defaultMicrophone.volume > 1);
self.value =
audioService.defaultMicrophone.volume <= 1
@@ -37,7 +37,7 @@ export const setupOsdBar = (self: LevelBar): void => {
: audioService.defaultMicrophone.volume - 1;
});
Variable.derive([bind(audioService.defaultMicrophone, 'mute')], () => {
const micMuteBinding = Variable.derive([bind(audioService.defaultMicrophone, 'mute')], () => {
self.toggleClassName(
'overflow',
audioService.defaultMicrophone.volume > 1 &&
@@ -51,7 +51,7 @@ export const setupOsdBar = (self: LevelBar): void => {
: audioService.defaultMicrophone.volume - 1;
});
Variable.derive([bind(audioService.defaultSpeaker, 'volume')], () => {
const speakerVolumeBinding = Variable.derive([bind(audioService.defaultSpeaker, 'volume')], () => {
self.toggleClassName('overflow', audioService.defaultSpeaker.volume > 1);
self.value =
audioService.defaultSpeaker.volume <= 1
@@ -59,7 +59,7 @@ export const setupOsdBar = (self: LevelBar): void => {
: audioService.defaultSpeaker.volume - 1;
});
Variable.derive([bind(audioService.defaultSpeaker, 'mute')], () => {
const speakerMuteBinding = Variable.derive([bind(audioService.defaultSpeaker, 'mute')], () => {
self.toggleClassName(
'overflow',
audioService.defaultSpeaker.volume > 1 &&
@@ -72,4 +72,11 @@ export const setupOsdBar = (self: LevelBar): void => {
? audioService.defaultSpeaker.volume
: audioService.defaultSpeaker.volume - 1;
});
self.connect('destroy', () => {
micVolumeBinding.drop();
micMuteBinding.drop();
speakerVolumeBinding.drop();
speakerMuteBinding.drop();
});
};

View File

@@ -1,79 +1,50 @@
import { bind, timeout, Variable } from 'astal';
import { bind, Variable } from 'astal';
import { Widget } from 'astal/gtk3';
import AstalHyprland from 'gi://AstalHyprland?version=0.1';
import AstalWp from 'gi://AstalWp?version=0.1';
import options from 'src/configuration';
import { GdkMonitorService } from 'src/services/display/monitor';
import BrightnessService from 'src/services/system/brightness';
import { OsdRevealerController } from './revealer/revealerController';
const wireplumber = AstalWp.get_default() as AstalWp.Wp;
const audioService = wireplumber.audio;
const brightnessService = BrightnessService.getInstance();
const hyprlandService = AstalHyprland.get_default();
const { enable, duration, active_monitor, monitor } = options.theme.osd;
const { enable, active_monitor, monitor } = options.theme.osd;
let count = 0;
/*
* So the OSD doesn't show on startup for no reason
*/
let isStartingUp = true;
timeout(3000, () => {
isStartingUp = false;
});
const osdController = OsdRevealerController.getInstance();
/**
* Handles the reveal state of a Widget.Revealer or Widget.Window.
* Determines which monitor the OSD should appear on based on user configuration.
* Safely handles null monitors and DPMS events to prevent crashes.
*
* This function delegates the reveal handling to either `handleRevealRevealer` or `handleRevealWindow` based on the type of the widget.
*
* @param self The Widget.Revealer or Widget.Window instance.
* @param property The property to check, either 'revealChild' or 'visible'.
*/
const handleReveal = (self: Widget.Revealer): void => {
if (isStartingUp) {
return;
}
if (!enable.get()) {
return;
}
self.reveal_child = true;
count++;
timeout(duration.get(), () => {
count--;
if (count === 0) {
self.reveal_child = false;
}
});
};
/**
* Retrieves the monitor index for the OSD.
*
* This function derives the monitor index for the OSD based on the focused monitor, default monitor, and active monitor settings.
*
* @returns A Variable<number> representing the monitor index for the OSD.
* @returns Variable containing the GDK monitor index where OSD should be displayed (defaults to 0 if no valid monitor)
*/
export const getOsdMonitor = (): Variable<number> => {
const gdkMonitorMapper = new GdkMonitorService();
const gdkMonitorMapper = GdkMonitorService.getInstance();
return Variable.derive(
[bind(hyprlandService, 'focusedMonitor'), bind(monitor), bind(active_monitor)],
(currentMonitor, defaultMonitor, followMonitor) => {
gdkMonitorMapper.reset();
try {
if (followMonitor === false) {
const gdkMonitor = gdkMonitorMapper.mapHyprlandToGdk(defaultMonitor);
return gdkMonitor;
}
if (!currentMonitor || currentMonitor.id === undefined || currentMonitor.id === null) {
console.warn('OSD: No focused monitor available, defaulting to monitor 0');
return 0;
}
if (followMonitor === true) {
const gdkMonitor = gdkMonitorMapper.mapHyprlandToGdk(currentMonitor.id);
return gdkMonitor;
} catch (error) {
console.error('OSD: Failed to map monitor, defaulting to 0:', error);
return 0;
}
const gdkMonitor = gdkMonitorMapper.mapHyprlandToGdk(defaultMonitor);
return gdkMonitor;
},
);
};
@@ -86,29 +57,29 @@ export const getOsdMonitor = (): Variable<number> => {
* @param self The Widget.Revealer instance to set up.
*/
export const revealerSetup = (self: Widget.Revealer): void => {
self.hook(enable, () => {
handleReveal(self);
});
osdController.setRevealer(self);
self.hook(brightnessService, 'notify::screen', () => {
handleReveal(self);
});
const handleReveal = (): void => {
osdController.show();
};
self.hook(brightnessService, 'notify::kbd', () => {
handleReveal(self);
});
self.hook(enable, handleReveal);
self.hook(brightnessService, 'notify::screen', handleReveal);
self.hook(brightnessService, 'notify::kbd', handleReveal);
Variable.derive(
const microphoneBinding = Variable.derive(
[bind(audioService.defaultMicrophone, 'volume'), bind(audioService.defaultMicrophone, 'mute')],
() => {
handleReveal(self);
},
handleReveal,
);
Variable.derive(
const speakerBinding = Variable.derive(
[bind(audioService.defaultSpeaker, 'volume'), bind(audioService.defaultSpeaker, 'mute')],
() => {
handleReveal(self);
},
handleReveal,
);
self.connect('destroy', () => {
microphoneBinding.drop();
speakerBinding.drop();
osdController.onRevealerDestroy(self);
});
};

View File

@@ -7,11 +7,6 @@ const wireplumber = AstalWp.get_default() as AstalWp.Wp;
const audioService = wireplumber.audio;
const brightnessService = BrightnessService.getInstance();
type OSDIcon = {
micVariable: Variable<unknown>;
speakerVariable: Variable<unknown>;
};
/**
* Sets up the OSD icon for a given widget.
*
@@ -22,7 +17,7 @@ type OSDIcon = {
*
* @returns An object containing the micVariable and speakerVariable, which are derived variables for microphone and speaker status.
*/
export const setupOsdIcon = (self: Widget.Label): OSDIcon => {
export const setupOsdIcon = (self: Widget.Label): void => {
self.hook(brightnessService, 'notify::screen', () => {
self.label = '󱍖';
});
@@ -45,8 +40,8 @@ export const setupOsdIcon = (self: Widget.Label): OSDIcon => {
},
);
return {
micVariable,
speakerVariable,
};
self.connect('destroy', () => {
micVariable.drop();
speakerVariable.drop();
});
};

View File

@@ -1,6 +1,6 @@
import options from 'src/configuration';
import { bind } from 'astal';
import { Astal } from 'astal/gtk3';
import { App, Astal } from 'astal/gtk3';
import { getOsdMonitor } from './helpers';
import { getPosition } from 'src/lib/window/positioning';
import { OsdRevealer } from './revealer';
@@ -8,20 +8,26 @@ import { OsdRevealer } from './revealer';
const { location } = options.theme.osd;
export default (): JSX.Element => {
const osdMonitorBinding = getOsdMonitor();
return (
<window
monitor={getOsdMonitor()()}
monitor={osdMonitorBinding()}
name={'indicator'}
application={App}
namespace={'indicator'}
className={'indicator'}
visible={true}
layer={bind(options.tear).as((tear) => (tear ? Astal.Layer.TOP : Astal.Layer.OVERLAY))}
anchor={bind(location).as((anchorPoint) => getPosition(anchorPoint))}
setup={(self) => {
getOsdMonitor().subscribe(() => {
osdMonitorBinding().subscribe(() => {
self.set_click_through(true);
});
}}
onDestroy={() => {
osdMonitorBinding.drop();
}}
clickThrough
>
<OsdRevealer />

View File

@@ -27,12 +27,12 @@ export const setupOsdLabel = (self: Widget.Label): void => {
self.label = `${Math.round(brightnessService.kbd * 100)}`;
});
Variable.derive([bind(audioService.defaultMicrophone, 'volume')], () => {
const micVolumeBinding = Variable.derive([bind(audioService.defaultMicrophone, 'volume')], () => {
self.toggleClassName('overflow', audioService.defaultMicrophone.volume > 1);
self.label = `${Math.round(audioService.defaultMicrophone.volume * 100)}`;
});
Variable.derive([bind(audioService.defaultMicrophone, 'mute')], () => {
const micMuteBinding = Variable.derive([bind(audioService.defaultMicrophone, 'mute')], () => {
self.toggleClassName(
'overflow',
audioService.defaultMicrophone.volume > 1 &&
@@ -45,12 +45,12 @@ export const setupOsdLabel = (self: Widget.Label): void => {
self.label = `${inputVolume}`;
});
Variable.derive([bind(audioService.defaultSpeaker, 'volume')], () => {
const speakerVolumeBinding = Variable.derive([bind(audioService.defaultSpeaker, 'volume')], () => {
self.toggleClassName('overflow', audioService.defaultSpeaker.volume > 1);
self.label = `${Math.round(audioService.defaultSpeaker.volume * 100)}`;
});
Variable.derive([bind(audioService.defaultSpeaker, 'mute')], () => {
const speakerMuteBinding = Variable.derive([bind(audioService.defaultSpeaker, 'mute')], () => {
self.toggleClassName(
'overflow',
audioService.defaultSpeaker.volume > 1 &&
@@ -62,4 +62,11 @@ export const setupOsdLabel = (self: Widget.Label): void => {
: Math.round(audioService.defaultSpeaker.volume * 100);
self.label = `${speakerVolume}`;
});
self.connect('destroy', () => {
micVolumeBinding.drop();
micMuteBinding.drop();
speakerVolumeBinding.drop();
speakerMuteBinding.drop();
});
};

View File

@@ -32,7 +32,9 @@ export const OsdRevealer = (): JSX.Element => {
<revealer
transitionType={Gtk.RevealerTransitionType.CROSSFADE}
revealChild={false}
setup={revealerSetup}
setup={(self) => {
revealerSetup(self);
}}
>
<box className={'osd-container'} vertical={osdOrientation}>
{bind(orientation).as((currentOrientation) => {

View File

@@ -0,0 +1,117 @@
import { timeout } from 'astal';
import { Widget } from 'astal/gtk3';
import AstalIO from 'gi://AstalIO?version=0.1';
import options from 'src/configuration';
const { enable, duration } = options.theme.osd;
/**
* Manages OSD revealer instances to prevent stale references and ensure proper cleanup
*/
export class OsdRevealerController {
private static _instance: OsdRevealerController;
private _currentRevealer?: Widget.Revealer;
private _autoHideTimeout?: AstalIO.Time;
private _startupTimeout?: AstalIO.Time;
private _allowReveal = false;
private constructor() {
this._startupTimeout = timeout(3000, () => {
this._allowReveal = true;
this._startupTimeout = undefined;
});
}
/**
* Gets the singleton instance of the OSD revealer controller
*/
public static getInstance(): OsdRevealerController {
if (this._instance === undefined) {
this._instance = new OsdRevealerController();
}
return this._instance;
}
/**
* Registers a revealer widget as the active OSD display component
* Ensures proper cleanup of previous revealers before setting a new one
*
* @param revealer - The revealer widget to manage
*/
public setRevealer(revealer: Widget.Revealer): void {
if (this._currentRevealer && this._currentRevealer !== revealer) {
this._cleanup();
}
this._currentRevealer = revealer;
revealer.set_reveal_child(false);
}
/**
* Reveals the OSD temporarily and sets up auto-hide behavior
* Respects enable state and startup delay before allowing reveal
*/
public show(): void {
const enableRevealer = enable.get();
if (!this._allowReveal || this._currentRevealer === undefined || !enableRevealer) {
return;
}
this._currentRevealer.set_reveal_child(true);
if (this._autoHideTimeout !== undefined) {
this._autoHideTimeout.cancel();
this._autoHideTimeout = undefined;
}
const hideDelay = duration.get();
const revealer = this._currentRevealer;
this._autoHideTimeout = timeout(hideDelay, () => {
if (revealer !== undefined) {
revealer.set_reveal_child(false);
}
this._autoHideTimeout = undefined;
});
}
/**
* Cancels any active auto-hide timeout to prevent stale callbacks
*/
private _cleanup(): void {
if (this._autoHideTimeout) {
this._autoHideTimeout.cancel();
this._autoHideTimeout = undefined;
}
}
/**
* Handles cleanup when a revealer widget is destroyed
* Ensures the controller doesn't hold references to destroyed widgets
*
* @param revealer - The revealer being destroyed
*/
public onRevealerDestroy(revealer: Widget.Revealer): void {
if (this._currentRevealer === revealer) {
this._cleanup();
this._currentRevealer = undefined;
}
}
/**
* Performs complete cleanup of the controller instance
* Cancels all active timeouts and clears widget references
*/
public destroy(): void {
this._cleanup();
if (this._startupTimeout) {
this._startupTimeout.cancel();
this._startupTimeout = undefined;
}
this._currentRevealer = undefined;
}
}

View File

@@ -0,0 +1,3 @@
export interface RevealerSetupBindings {
cleanup: () => void;
}