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:
@@ -56,7 +56,7 @@ export class BarAutoHideService {
|
||||
if (hideMode === 'never') {
|
||||
this._showAllBars();
|
||||
} else if (hideMode === 'single-window') {
|
||||
this._updateBarVisibilityByWindowCount();
|
||||
this._handleSingleWindowAutoHide();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -64,7 +64,7 @@ export class BarAutoHideService {
|
||||
this._subscriptions.client = Variable.derive(
|
||||
[bind(this._hyprlandService, 'focusedClient')],
|
||||
(currentClient) => {
|
||||
this._handleFullscreenClientVisibility(currentClient);
|
||||
this._handleFullscreenAutoHide(currentClient);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -91,8 +91,17 @@ export class BarAutoHideService {
|
||||
private _setBarVisibility(monitorId: number, isVisible: boolean): void {
|
||||
const barName = `bar-${monitorId}`;
|
||||
|
||||
if (BarVisibility.get(barName)) {
|
||||
App.get_window(barName)?.set_visible(isVisible);
|
||||
if (!BarVisibility.get(barName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const window = App.get_window(barName);
|
||||
if (window && !window.get_window()?.is_destroyed()) {
|
||||
try {
|
||||
window.set_visible(isVisible);
|
||||
} catch (error) {
|
||||
console.warn(`[BarAutoHide] Failed to set visibility for ${barName}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +110,7 @@ export class BarAutoHideService {
|
||||
*
|
||||
* @param client - The Hyprland client whose fullscreen state to monitor
|
||||
*/
|
||||
private _handleFullscreenClientVisibility(client: AstalHyprland.Client): void {
|
||||
private _handleFullscreenAutoHide(client: AstalHyprland.Client): void {
|
||||
if (client === null) {
|
||||
return;
|
||||
}
|
||||
@@ -109,12 +118,25 @@ export class BarAutoHideService {
|
||||
const fullscreenBinding = bind(client, 'fullscreen');
|
||||
|
||||
Variable.derive([bind(fullscreenBinding)], (isFullScreen) => {
|
||||
if (this._autoHide.get() === 'fullscreen') {
|
||||
this._setBarVisibility(client.monitor.id, !Boolean(isFullScreen));
|
||||
if (this._autoHide.get() === 'fullscreen' && client.monitor?.id !== undefined) {
|
||||
this._setBarVisibility(client.monitor?.id, !Boolean(isFullScreen));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates bar visibility based on workspace window count
|
||||
*/
|
||||
private _handleSingleWindowAutoHide(): void {
|
||||
const monitors = this._hyprlandService.get_monitors();
|
||||
const activeWorkspaces = monitors.map((monitor) => monitor.active_workspace);
|
||||
|
||||
activeWorkspaces.forEach((workspace) => {
|
||||
const hasOneClient = workspace.get_clients().length !== 1;
|
||||
this._setBarVisibility(workspace.monitor.id, hasOneClient);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows bars on all monitors
|
||||
*/
|
||||
@@ -128,19 +150,6 @@ export class BarAutoHideService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates bar visibility based on workspace window count
|
||||
*/
|
||||
private _updateBarVisibilityByWindowCount(): void {
|
||||
const monitors = this._hyprlandService.get_monitors();
|
||||
const activeWorkspaces = monitors.map((monitor) => monitor.active_workspace);
|
||||
|
||||
activeWorkspaces.forEach((workspace) => {
|
||||
const hasOneClient = workspace.get_clients().length !== 1;
|
||||
this._setBarVisibility(workspace.monitor.id, hasOneClient);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates bar visibility based on workspace fullscreen state
|
||||
*/
|
||||
|
||||
117
src/services/display/bar/refreshManager.ts
Normal file
117
src/services/display/bar/refreshManager.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { App } from 'astal/gtk3';
|
||||
import { Bar } from 'src/components/bar';
|
||||
import { forMonitors } from 'src/components/bar/utils/monitors';
|
||||
import { GdkMonitorService } from 'src/services/display/monitor';
|
||||
import Notifications from 'src/components/notifications';
|
||||
import OSD from 'src/components/osd/index';
|
||||
|
||||
/**
|
||||
* Manages dynamic refresh of monitor-dependent components when monitor configuration changes.
|
||||
* Handles bars, notifications, OSD, and other monitor-aware components.
|
||||
* Includes debouncing, error recovery, and prevents concurrent refresh operations.
|
||||
*/
|
||||
export class BarRefreshManager {
|
||||
private static _instance: BarRefreshManager | null = null;
|
||||
private _refreshInProgress = false;
|
||||
private _pendingRefresh = false;
|
||||
private _monitorChangeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
/**
|
||||
* Retrieves the singleton instance of the refresh manager
|
||||
* Creates the instance on first access to ensure single point of control
|
||||
*/
|
||||
public static getInstance(): BarRefreshManager {
|
||||
if (!BarRefreshManager._instance) {
|
||||
BarRefreshManager._instance = new BarRefreshManager();
|
||||
}
|
||||
return BarRefreshManager._instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes monitor configuration change events with built-in debouncing
|
||||
* Ensures smooth transitions during rapid monitor connect/disconnect scenarios
|
||||
*
|
||||
* @param event - The type of monitor change event that occurred
|
||||
*/
|
||||
public handleMonitorChange(event: string): void {
|
||||
if (this._monitorChangeTimeout !== null) {
|
||||
clearTimeout(this._monitorChangeTimeout);
|
||||
}
|
||||
|
||||
this._monitorChangeTimeout = setTimeout(() => {
|
||||
this._refreshMonitors().catch((error) => {
|
||||
console.error(`[MonitorChange] Failed to refresh bars for ${event}:`, error);
|
||||
});
|
||||
this._monitorChangeTimeout = null;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrates the complete refresh of monitor-dependent components
|
||||
* Prevents concurrent refreshes and queues pending requests to avoid race conditions
|
||||
*/
|
||||
private async _refreshMonitors(): Promise<void> {
|
||||
if (this._refreshInProgress) {
|
||||
this._pendingRefresh = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this._refreshInProgress = true;
|
||||
|
||||
try {
|
||||
this._destroyBars();
|
||||
this._destroyNotificationWindow();
|
||||
this._destroyOsdWindow();
|
||||
|
||||
const gdkMonitorService = GdkMonitorService.getInstance();
|
||||
gdkMonitorService.reset();
|
||||
|
||||
await forMonitors(Bar);
|
||||
|
||||
Notifications();
|
||||
OSD();
|
||||
} catch (error) {
|
||||
console.error('[MonitorRefresh] Error during component refresh:', error);
|
||||
} finally {
|
||||
this._refreshInProgress = false;
|
||||
|
||||
if (this._pendingRefresh) {
|
||||
this._pendingRefresh = false;
|
||||
setTimeout(() => this._refreshMonitors(), 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys all existing bar windows across monitors
|
||||
* Identifies bars by their naming convention to ensure complete cleanup
|
||||
*/
|
||||
private _destroyBars(): void {
|
||||
const barWindows = App.get_windows().filter((window) => window.name.startsWith('bar-'));
|
||||
barWindows.forEach((window) => window?.destroy());
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the notifications window from the display
|
||||
* Ensures proper cleanup before recreating notifications on new monitor configuration
|
||||
*/
|
||||
private _destroyNotificationWindow(): void {
|
||||
const notificationsWindow = App.get_window('notifications-window');
|
||||
if (notificationsWindow !== null) {
|
||||
notificationsWindow.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the OSD indicator window from the display
|
||||
* Prepares for recreation on the appropriate monitor after configuration changes
|
||||
*/
|
||||
private _destroyOsdWindow(): void {
|
||||
const osdWindow = App.get_window('indicator');
|
||||
if (osdWindow !== null) {
|
||||
osdWindow.destroy();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,24 +4,37 @@ import AstalHyprland from 'gi://AstalHyprland?version=0.1';
|
||||
const hyprlandService = AstalHyprland.get_default();
|
||||
|
||||
/**
|
||||
* The MonitorMapper class encapsulates the conversion logic between GDK and Hyprland monitor IDs.
|
||||
* It maintains internal state for monitors that have already been used so that duplicate assignments are avoided.
|
||||
* Singleton service that manages the conversion between GDK and Hyprland monitor IDs.
|
||||
* Maintains persistent state to ensure consistent monitor mappings across the application lifecycle.
|
||||
*/
|
||||
export class GdkMonitorService {
|
||||
private _usedGdkMonitors: Set<number>;
|
||||
private _usedHyprlandMonitors: Set<number>;
|
||||
private static _instance: GdkMonitorService;
|
||||
private _usedHyprlandIds: Set<number>;
|
||||
|
||||
constructor() {
|
||||
this._usedGdkMonitors = new Set();
|
||||
this._usedHyprlandMonitors = new Set();
|
||||
private constructor() {
|
||||
this._usedHyprlandIds = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the internal state for both GDK and Hyprland monitor mappings.
|
||||
* Gets the singleton instance of GdkMonitorService.
|
||||
* Creates the instance on first access and reuses it for all subsequent calls.
|
||||
*
|
||||
* @returns The singleton GdkMonitorService instance
|
||||
*/
|
||||
public static getInstance(): GdkMonitorService {
|
||||
if (!GdkMonitorService._instance) {
|
||||
GdkMonitorService._instance = new GdkMonitorService();
|
||||
}
|
||||
return GdkMonitorService._instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the internal state for monitor mappings.
|
||||
* Note: With singleton pattern, this should only be called when monitor
|
||||
* configuration actually changes.
|
||||
*/
|
||||
public reset(): void {
|
||||
this._usedGdkMonitors.clear();
|
||||
this._usedHyprlandMonitors.clear();
|
||||
this._usedHyprlandIds.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,16 +51,25 @@ export class GdkMonitorService {
|
||||
}
|
||||
|
||||
const gdkMonitor = gdkMonitors[monitor];
|
||||
const hyprlandMonitors = hyprlandService.get_monitors();
|
||||
if (!gdkMonitor) {
|
||||
return monitor;
|
||||
}
|
||||
|
||||
return this._matchMonitor(
|
||||
hyprlandMonitors,
|
||||
const hyprlandMonitors = hyprlandService.get_monitors();
|
||||
const validMonitors = hyprlandMonitors.filter((m) => m.model && m.model !== 'null');
|
||||
const tempUsedIds = new Set<number>();
|
||||
const monitorsToUse = validMonitors.length > 0 ? validMonitors : hyprlandMonitors;
|
||||
|
||||
const result = this._matchMonitor(
|
||||
monitorsToUse,
|
||||
gdkMonitor,
|
||||
monitor,
|
||||
this._usedHyprlandMonitors,
|
||||
(mon) => mon.id,
|
||||
(mon, gdkMon) => this._matchMonitorKey(mon, gdkMon),
|
||||
tempUsedIds,
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,79 +93,66 @@ export class GdkMonitorService {
|
||||
const foundHyprlandMonitor =
|
||||
hyprlandMonitors.find((mon) => mon.id === monitor) || hyprlandMonitors[0];
|
||||
|
||||
const tempUsedIds = new Set<number>();
|
||||
|
||||
return this._matchMonitor(
|
||||
gdkCandidates,
|
||||
foundHyprlandMonitor,
|
||||
monitor,
|
||||
this._usedGdkMonitors,
|
||||
(candidate) => candidate.id,
|
||||
(candidate, hyprlandMonitor) => this._matchMonitorKey(hyprlandMonitor, candidate.monitor),
|
||||
tempUsedIds,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic helper that finds the best matching candidate monitor based on:
|
||||
* 1. A direct match (candidate matches the source and has the same id as the target).
|
||||
* 2. A relaxed match (candidate matches the source, regardless of id).
|
||||
* 3. A fallback match (first candidate that hasn’t been used).
|
||||
* 1. A direct match (candidate matches the source and has the same id as the target, and hasn't been used).
|
||||
* 2. A relaxed match (candidate matches the source, regardless of id, and hasn't been used).
|
||||
* 3. No fallback - return target to preserve intended mapping.
|
||||
*
|
||||
* @param candidates - Array of candidate monitors.
|
||||
* @param source - The source monitor object to match against.
|
||||
* @param target - The desired monitor id.
|
||||
* @param usedMonitors - A Set of already used candidate ids.
|
||||
* @param getId - Function to extract the id from a candidate.
|
||||
* @param compare - Function that determines if a candidate matches the source.
|
||||
* @param usedIds - Set of already used IDs for this mapping batch.
|
||||
* @returns The chosen monitor id.
|
||||
*/
|
||||
private _matchMonitor<T, U>(
|
||||
candidates: T[],
|
||||
source: U,
|
||||
target: number,
|
||||
usedMonitors: Set<number>,
|
||||
getId: (candidate: T) => number,
|
||||
compare: (candidate: T, source: U) => boolean,
|
||||
usedIds: Set<number>,
|
||||
): number {
|
||||
// Direct match: candidate matches the source and has the same id as the target.
|
||||
const directMatch = candidates.find(
|
||||
(candidate) =>
|
||||
compare(candidate, source) &&
|
||||
!usedMonitors.has(getId(candidate)) &&
|
||||
getId(candidate) === target,
|
||||
);
|
||||
const directMatch = candidates.find((candidate) => {
|
||||
const matches = compare(candidate, source);
|
||||
const id = getId(candidate);
|
||||
const isUsed = usedIds.has(id);
|
||||
return matches && id === target && !isUsed;
|
||||
});
|
||||
|
||||
if (directMatch !== undefined) {
|
||||
usedMonitors.add(getId(directMatch));
|
||||
return getId(directMatch);
|
||||
const result = getId(directMatch);
|
||||
usedIds.add(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Relaxed match: candidate matches the source regardless of id.
|
||||
const relaxedMatch = candidates.find(
|
||||
(candidate) => compare(candidate, source) && !usedMonitors.has(getId(candidate)),
|
||||
);
|
||||
const relaxedMatch = candidates.find((candidate) => {
|
||||
const matches = compare(candidate, source);
|
||||
const id = getId(candidate);
|
||||
const isUsed = usedIds.has(id);
|
||||
return matches && !isUsed;
|
||||
});
|
||||
|
||||
if (relaxedMatch !== undefined) {
|
||||
usedMonitors.add(getId(relaxedMatch));
|
||||
return getId(relaxedMatch);
|
||||
const result = getId(relaxedMatch);
|
||||
usedIds.add(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Fallback: use the first candidate that hasn't been used.
|
||||
const fallback = candidates.find((candidate) => !usedMonitors.has(getId(candidate)));
|
||||
|
||||
if (fallback !== undefined) {
|
||||
usedMonitors.add(getId(fallback));
|
||||
return getId(fallback);
|
||||
}
|
||||
|
||||
// As a last resort, iterate over candidates.
|
||||
for (const candidate of candidates) {
|
||||
const candidateId = getId(candidate);
|
||||
if (!usedMonitors.has(candidateId)) {
|
||||
usedMonitors.add(candidateId);
|
||||
return candidateId;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(`Returning original monitor index as a last resort: ${target}`);
|
||||
return target;
|
||||
}
|
||||
|
||||
@@ -155,6 +164,10 @@ export class GdkMonitorService {
|
||||
* @returns boolean indicating if the monitors match
|
||||
*/
|
||||
private _matchMonitorKey(hyprlandMonitor: AstalHyprland.Monitor, gdkMonitor: GdkMonitor): boolean {
|
||||
if (!hyprlandMonitor.model || hyprlandMonitor.model === 'null') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isRotated90 = hyprlandMonitor.transform % 2 !== 0;
|
||||
const gdkScaleFactor = Math.ceil(hyprlandMonitor.scale);
|
||||
|
||||
@@ -170,16 +183,6 @@ export class GdkMonitorService {
|
||||
|
||||
const keyMatch = gdkMonitor.key === gdkScaleFactorKey || gdkMonitor.key === hyprlandScaleFactorKey;
|
||||
|
||||
this._logMonitorInfo(
|
||||
gdkMonitor,
|
||||
hyprlandMonitor,
|
||||
isRotated90,
|
||||
gdkScaleFactor,
|
||||
gdkScaleFactorKey,
|
||||
hyprlandScaleFactorKey,
|
||||
keyMatch,
|
||||
);
|
||||
|
||||
return keyMatch;
|
||||
}
|
||||
|
||||
@@ -205,56 +208,21 @@ export class GdkMonitorService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const model = curMonitor.get_model() ?? '';
|
||||
const geometry = curMonitor.get_geometry();
|
||||
const scaleFactor = curMonitor.get_scale_factor();
|
||||
try {
|
||||
const model = curMonitor.get_model() ?? '';
|
||||
const geometry = curMonitor.get_geometry();
|
||||
const scaleFactor = curMonitor.get_scale_factor();
|
||||
|
||||
// GDK3 only supports integer scale factors
|
||||
const key = `${model}_${geometry.width}x${geometry.height}_${scaleFactor}`;
|
||||
gdkMonitors[i] = { key, model, used: false };
|
||||
const key = `${model}_${geometry.width}x${geometry.height}_${scaleFactor}`;
|
||||
gdkMonitors[i] = { key, model, used: false };
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get properties for monitor ${i}:`, error);
|
||||
gdkMonitors[i] = { key: `monitor_${i}`, model: 'Unknown', used: false };
|
||||
}
|
||||
}
|
||||
|
||||
return gdkMonitors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs detailed monitor information for debugging purposes
|
||||
* @param gdkMonitor - GDK monitor object
|
||||
* @param hyprlandMonitor - Hyprland monitor information
|
||||
* @param isRotated90 - Whether the monitor is rotated 90 degrees
|
||||
* @param gdkScaleFactor - The GDK monitor's scale factor
|
||||
* @param gdkScaleFactorKey - Key used for scale factor matching
|
||||
* @param hyprlandScaleFactorKey - Key used for general scale matching
|
||||
* @param keyMatch - Whether the monitor keys match
|
||||
*/
|
||||
private _logMonitorInfo(
|
||||
gdkMonitor: GdkMonitor,
|
||||
hyprlandMonitor: AstalHyprland.Monitor,
|
||||
isRotated90: boolean,
|
||||
gdkScaleFactor: number,
|
||||
gdkScaleFactorKey: string,
|
||||
hyprlandScaleFactorKey: string,
|
||||
keyMatch: boolean,
|
||||
): void {
|
||||
console.debug('=== Monitor Matching Debug Info ===');
|
||||
console.debug('GDK Monitor');
|
||||
console.debug(` Key: ${gdkMonitor.key}`);
|
||||
console.debug('Hyprland Monitor');
|
||||
console.debug(` ID: ${hyprlandMonitor.id}`);
|
||||
console.debug(` Model: ${hyprlandMonitor.model}`);
|
||||
console.debug(` Resolution: ${hyprlandMonitor.width}x${hyprlandMonitor.height}`);
|
||||
console.debug(` Scale: ${hyprlandMonitor.scale}`);
|
||||
console.debug(` Transform: ${hyprlandMonitor.transform}`);
|
||||
console.debug('Calculated Values');
|
||||
console.debug(` Rotation: ${isRotated90 ? '90°' : '0°'}`);
|
||||
console.debug(` GDK Scale Factor: ${gdkScaleFactor}`);
|
||||
console.debug('Calculated Keys');
|
||||
console.debug(` GDK Scale Factor Key: ${gdkScaleFactorKey}`);
|
||||
console.debug(` Hyprland Scale Factor Key: ${hyprlandScaleFactorKey}`);
|
||||
console.debug('Match Result');
|
||||
console.debug(` ${keyMatch ? '✅ Monitors Match' : '❌ No Match'}`);
|
||||
console.debug('===============================\n');
|
||||
}
|
||||
}
|
||||
|
||||
type GdkMonitor = {
|
||||
|
||||
@@ -35,19 +35,6 @@ export class NetworkService {
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up bindings to monitor network service changes
|
||||
*/
|
||||
private _setupBindings(): void {
|
||||
Variable.derive([bind(this._astalNetwork, 'wifi')], () => {
|
||||
this.wifi.onWifiServiceChanged();
|
||||
});
|
||||
|
||||
Variable.derive([bind(this._astalNetwork, 'wired')], () => {
|
||||
this.ethernet.onWiredServiceChanged();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the appropriate WiFi icon based on the provided icon name.
|
||||
*
|
||||
@@ -67,4 +54,17 @@ export class NetworkService {
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up bindings to monitor network service changes
|
||||
*/
|
||||
private _setupBindings(): void {
|
||||
Variable.derive([bind(this._astalNetwork, 'wifi')], () => {
|
||||
this.wifi.onWifiServiceChanged();
|
||||
});
|
||||
|
||||
Variable.derive([bind(this._astalNetwork, 'wired')], () => {
|
||||
this.ethernet.onWiredServiceChanged();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user