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:
403
src/components/bar/utils/helpers.ts
Normal file
403
src/components/bar/utils/helpers.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import { ResourceLabelType } from 'src/lib/types/bar';
|
||||
import { GenericResourceData, Postfix, UpdateHandlers } from 'src/lib/types/customModules/generic';
|
||||
import { InputHandlerEvents, RunAsyncCommand } from 'src/lib/types/customModules/utils';
|
||||
import { ThrottleFn } from 'src/lib/types/utils';
|
||||
import { bind, Binding, execAsync, Variable } from 'astal';
|
||||
import { openMenu } from 'src/components/bar/utils/menu';
|
||||
import options from 'src/options';
|
||||
import { Gdk } from 'astal/gtk3';
|
||||
import { GtkWidget } from 'src/lib/types/widget';
|
||||
import { onMiddleClick, onPrimaryClick, onSecondaryClick } from 'src/lib/shared/eventHandlers';
|
||||
|
||||
const { scrollSpeed } = options.bar.customModules;
|
||||
|
||||
const dummyVar = Variable('');
|
||||
|
||||
/**
|
||||
* Handles the post input updater by toggling its value.
|
||||
*
|
||||
* This function checks if the `postInputUpdater` variable is defined. If it is, it toggles its value.
|
||||
*
|
||||
* @param postInputUpdater An optional Variable<boolean> that tracks the post input update state.
|
||||
*/
|
||||
const handlePostInputUpdater = (postInputUpdater?: Variable<boolean>): void => {
|
||||
if (postInputUpdater !== undefined) {
|
||||
postInputUpdater.set(!postInputUpdater.get());
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Executes an asynchronous command and handles the result.
|
||||
*
|
||||
* This function runs a given command asynchronously using `execAsync`. If the command starts with 'menu:', it opens the specified menu.
|
||||
* Otherwise, it executes the command in a bash shell. After execution, it handles the post input updater and calls the provided callback function with the command output.
|
||||
*
|
||||
* @param cmd The command to execute.
|
||||
* @param events An object containing the clicked widget and event information.
|
||||
* @param fn An optional callback function to handle the command output.
|
||||
* @param postInputUpdater An optional Variable<boolean> that tracks the post input update state.
|
||||
*/
|
||||
export const runAsyncCommand: RunAsyncCommand = (cmd, events, fn, postInputUpdater?: Variable<boolean>): void => {
|
||||
if (cmd.startsWith('menu:')) {
|
||||
const menuName = cmd.split(':')[1].trim().toLowerCase();
|
||||
openMenu(events.clicked, events.event, `${menuName}menu`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
execAsync(`bash -c "${cmd}"`)
|
||||
.then((output) => {
|
||||
handlePostInputUpdater(postInputUpdater);
|
||||
if (fn !== undefined) {
|
||||
fn(output);
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error(`Error running command "${cmd}": ${err})`));
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic throttle function to limit the rate at which a function can be called.
|
||||
*
|
||||
* This function creates a throttled version of the provided function that can only be called once within the specified limit.
|
||||
*
|
||||
* @param func The function to throttle.
|
||||
* @param limit The time limit in milliseconds.
|
||||
*
|
||||
* @returns The throttled function.
|
||||
*/
|
||||
export function throttleInput<T extends ThrottleFn>(func: T, limit: number): T {
|
||||
let inThrottle = false;
|
||||
return function (this: ThisParameterType<T>, ...args: Parameters<T>) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => {
|
||||
inThrottle = false;
|
||||
}, limit);
|
||||
}
|
||||
} as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a throttled scroll handler with the given interval.
|
||||
*
|
||||
* This function returns a throttled version of the `runAsyncCommand` function that can be called with the specified interval.
|
||||
*
|
||||
* @param interval The interval in milliseconds.
|
||||
*
|
||||
* @returns The throttled scroll handler function.
|
||||
*/
|
||||
export const throttledScrollHandler = (interval: number): ThrottleFn =>
|
||||
throttleInput((cmd: string, args, fn, postInputUpdater) => {
|
||||
runAsyncCommand(cmd, args, fn, postInputUpdater);
|
||||
}, 200 / interval);
|
||||
|
||||
/**
|
||||
* Handles input events for a GtkWidget.
|
||||
*
|
||||
* This function sets up event handlers for primary, secondary, and middle clicks, as well as scroll events.
|
||||
* It uses the provided input handler events and post input updater to manage the input state.
|
||||
*
|
||||
* @param self The GtkWidget instance to handle input events for.
|
||||
* @param inputHandlerEvents An object containing the input handler events for primary, secondary, and middle clicks, as well as scroll up and down.
|
||||
* @param postInputUpdater An optional Variable<boolean> that tracks the post input update state.
|
||||
*/
|
||||
export const inputHandler = (
|
||||
self: GtkWidget,
|
||||
{
|
||||
onPrimaryClick: onPrimaryClickInput,
|
||||
onSecondaryClick: onSecondaryClickInput,
|
||||
onMiddleClick: onMiddleClickInput,
|
||||
onScrollUp: onScrollUpInput,
|
||||
onScrollDown: onScrollDownInput,
|
||||
}: InputHandlerEvents,
|
||||
postInputUpdater?: Variable<boolean>,
|
||||
): void => {
|
||||
const sanitizeInput = (input: Variable<string>): string => {
|
||||
if (input === undefined) {
|
||||
return '';
|
||||
}
|
||||
return input.get();
|
||||
};
|
||||
|
||||
const updateHandlers = (): UpdateHandlers => {
|
||||
const interval = scrollSpeed.get();
|
||||
const throttledHandler = throttledScrollHandler(interval);
|
||||
|
||||
const disconnectPrimaryClick = onPrimaryClick(self, (clicked: GtkWidget, event: Gdk.Event) => {
|
||||
runAsyncCommand(
|
||||
sanitizeInput(onPrimaryClickInput?.cmd || dummyVar),
|
||||
{ clicked, event },
|
||||
onPrimaryClickInput.fn,
|
||||
postInputUpdater,
|
||||
);
|
||||
});
|
||||
|
||||
const disconnectSecondaryClick = onSecondaryClick(self, (clicked: GtkWidget, event: Gdk.Event) => {
|
||||
runAsyncCommand(
|
||||
sanitizeInput(onSecondaryClickInput?.cmd || dummyVar),
|
||||
{ clicked, event },
|
||||
onSecondaryClickInput.fn,
|
||||
postInputUpdater,
|
||||
);
|
||||
});
|
||||
|
||||
const disconnectMiddleClick = onMiddleClick(self, (clicked: GtkWidget, event: Gdk.Event) => {
|
||||
runAsyncCommand(
|
||||
sanitizeInput(onMiddleClickInput?.cmd || dummyVar),
|
||||
{ clicked, event },
|
||||
onMiddleClickInput.fn,
|
||||
postInputUpdater,
|
||||
);
|
||||
});
|
||||
|
||||
const id = self.connect('scroll-event', (self: GtkWidget, event: Gdk.Event) => {
|
||||
const [directionSuccess, direction] = event.get_scroll_direction();
|
||||
const [deltaSuccess, , yScroll] = event.get_scroll_deltas();
|
||||
|
||||
const handleScroll = (input?: { cmd: Variable<string>; fn: (output: string) => void }): void => {
|
||||
if (input) {
|
||||
throttledHandler(sanitizeInput(input.cmd), { clicked: self, event }, input.fn, postInputUpdater);
|
||||
}
|
||||
};
|
||||
|
||||
if (directionSuccess) {
|
||||
if (direction === Gdk.ScrollDirection.UP) {
|
||||
handleScroll(onScrollUpInput);
|
||||
} else if (direction === Gdk.ScrollDirection.DOWN) {
|
||||
handleScroll(onScrollDownInput);
|
||||
}
|
||||
}
|
||||
|
||||
if (deltaSuccess) {
|
||||
if (yScroll > 0) {
|
||||
handleScroll(onScrollUpInput);
|
||||
} else if (yScroll < 0) {
|
||||
handleScroll(onScrollDownInput);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
disconnectPrimary: disconnectPrimaryClick,
|
||||
disconnectSecondary: disconnectSecondaryClick,
|
||||
disconnectMiddle: disconnectMiddleClick,
|
||||
disconnectScroll: () => self.disconnect(id),
|
||||
};
|
||||
};
|
||||
|
||||
updateHandlers();
|
||||
|
||||
const sanitizeVariable = (someVar: Variable<string> | undefined): Binding<string> => {
|
||||
if (someVar === undefined || typeof someVar.bind !== 'function') {
|
||||
return bind(dummyVar);
|
||||
}
|
||||
return bind(someVar);
|
||||
};
|
||||
|
||||
Variable.derive(
|
||||
[
|
||||
bind(scrollSpeed),
|
||||
sanitizeVariable(onPrimaryClickInput),
|
||||
sanitizeVariable(onSecondaryClickInput),
|
||||
sanitizeVariable(onMiddleClickInput),
|
||||
sanitizeVariable(onScrollUpInput),
|
||||
sanitizeVariable(onScrollDownInput),
|
||||
],
|
||||
() => {
|
||||
const handlers = updateHandlers();
|
||||
|
||||
handlers.disconnectPrimary();
|
||||
handlers.disconnectSecondary();
|
||||
handlers.disconnectMiddle();
|
||||
handlers.disconnectScroll();
|
||||
},
|
||||
)();
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the percentage of used resources.
|
||||
*
|
||||
* This function calculates the percentage of used resources based on the total and used values.
|
||||
* It can optionally round the result to the nearest integer.
|
||||
*
|
||||
* @param totalUsed An array containing the total and used values.
|
||||
* @param round A boolean indicating whether to round the result.
|
||||
*
|
||||
* @returns The percentage of used resources as a number.
|
||||
*/
|
||||
export const divide = ([total, used]: number[], round: boolean): number => {
|
||||
const percentageTotal = (used / total) * 100;
|
||||
if (round) {
|
||||
return total > 0 ? Math.round(percentageTotal) : 0;
|
||||
}
|
||||
return total > 0 ? parseFloat(percentageTotal.toFixed(2)) : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a size in bytes to KiB.
|
||||
*
|
||||
* This function converts a size in bytes to kibibytes (KiB) and optionally rounds the result.
|
||||
*
|
||||
* @param sizeInBytes The size in bytes to format.
|
||||
* @param round A boolean indicating whether to round the result.
|
||||
*
|
||||
* @returns The size in KiB as a number.
|
||||
*/
|
||||
export const formatSizeInKiB = (sizeInBytes: number, round: boolean): number => {
|
||||
const sizeInGiB = sizeInBytes / 1024 ** 1;
|
||||
return round ? Math.round(sizeInGiB) : parseFloat(sizeInGiB.toFixed(2));
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a size in bytes to MiB.
|
||||
*
|
||||
* This function converts a size in bytes to mebibytes (MiB) and optionally rounds the result.
|
||||
*
|
||||
* @param sizeInBytes The size in bytes to format.
|
||||
* @param round A boolean indicating whether to round the result.
|
||||
*
|
||||
* @returns The size in MiB as a number.
|
||||
*/
|
||||
export const formatSizeInMiB = (sizeInBytes: number, round: boolean): number => {
|
||||
const sizeInGiB = sizeInBytes / 1024 ** 2;
|
||||
return round ? Math.round(sizeInGiB) : parseFloat(sizeInGiB.toFixed(2));
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a size in bytes to GiB.
|
||||
*
|
||||
* This function converts a size in bytes to gibibytes (GiB) and optionally rounds the result.
|
||||
*
|
||||
* @param sizeInBytes The size in bytes to format.
|
||||
* @param round A boolean indicating whether to round the result.
|
||||
*
|
||||
* @returns The size in GiB as a number.
|
||||
*/
|
||||
export const formatSizeInGiB = (sizeInBytes: number, round: boolean): number => {
|
||||
const sizeInGiB = sizeInBytes / 1024 ** 3;
|
||||
return round ? Math.round(sizeInGiB) : parseFloat(sizeInGiB.toFixed(2));
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a size in bytes to TiB.
|
||||
*
|
||||
* This function converts a size in bytes to tebibytes (TiB) and optionally rounds the result.
|
||||
*
|
||||
* @param sizeInBytes The size in bytes to format.
|
||||
* @param round A boolean indicating whether to round the result.
|
||||
*
|
||||
* @returns The size in TiB as a number.
|
||||
*/
|
||||
export const formatSizeInTiB = (sizeInBytes: number, round: boolean): number => {
|
||||
const sizeInGiB = sizeInBytes / 1024 ** 4;
|
||||
return round ? Math.round(sizeInGiB) : parseFloat(sizeInGiB.toFixed(2));
|
||||
};
|
||||
|
||||
/**
|
||||
* Automatically formats a size in bytes to the appropriate unit.
|
||||
*
|
||||
* This function converts a size in bytes to the most appropriate unit (TiB, GiB, MiB, KiB, or bytes) and optionally rounds the result.
|
||||
*
|
||||
* @param sizeInBytes The size in bytes to format.
|
||||
* @param round A boolean indicating whether to round the result.
|
||||
*
|
||||
* @returns The formatted size as a number.
|
||||
*/
|
||||
export const autoFormatSize = (sizeInBytes: number, round: boolean): number => {
|
||||
// auto convert to GiB, MiB, KiB, TiB, or bytes
|
||||
if (sizeInBytes >= 1024 ** 4) return formatSizeInTiB(sizeInBytes, round);
|
||||
if (sizeInBytes >= 1024 ** 3) return formatSizeInGiB(sizeInBytes, round);
|
||||
if (sizeInBytes >= 1024 ** 2) return formatSizeInMiB(sizeInBytes, round);
|
||||
if (sizeInBytes >= 1024 ** 1) return formatSizeInKiB(sizeInBytes, round);
|
||||
|
||||
return sizeInBytes;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the appropriate postfix for a size in bytes.
|
||||
*
|
||||
* This function returns the appropriate postfix (TiB, GiB, MiB, KiB, or B) for a given size in bytes.
|
||||
*
|
||||
* @param sizeInBytes The size in bytes to determine the postfix for.
|
||||
*
|
||||
* @returns The postfix as a string.
|
||||
*/
|
||||
export const getPostfix = (sizeInBytes: number): Postfix => {
|
||||
if (sizeInBytes >= 1024 ** 4) return 'TiB';
|
||||
if (sizeInBytes >= 1024 ** 3) return 'GiB';
|
||||
if (sizeInBytes >= 1024 ** 2) return 'MiB';
|
||||
if (sizeInBytes >= 1024 ** 1) return 'KiB';
|
||||
|
||||
return 'B';
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a resource label based on the label type and resource data.
|
||||
*
|
||||
* This function generates a resource label string based on the provided label type, resource data, and rounding option.
|
||||
* It formats the used, total, and free resource values and calculates the percentage if needed.
|
||||
*
|
||||
* @param lblType The type of label to render (used/total, used, free, or percentage).
|
||||
* @param rmUsg An object containing the resource usage data (used, total, percentage, and free).
|
||||
* @param round A boolean indicating whether to round the values.
|
||||
*
|
||||
* @returns The rendered resource label as a string.
|
||||
*/
|
||||
export const renderResourceLabel = (lblType: ResourceLabelType, rmUsg: GenericResourceData, round: boolean): string => {
|
||||
const { used, total, percentage, free } = rmUsg;
|
||||
|
||||
const formatFunctions = {
|
||||
TiB: formatSizeInTiB,
|
||||
GiB: formatSizeInGiB,
|
||||
MiB: formatSizeInMiB,
|
||||
KiB: formatSizeInKiB,
|
||||
B: (size: number): number => size,
|
||||
};
|
||||
|
||||
// Get the data in proper GiB, MiB, KiB, TiB, or bytes
|
||||
const totalSizeFormatted = autoFormatSize(total, round);
|
||||
// get the postfix: one of [TiB, GiB, MiB, KiB, B]
|
||||
const postfix = getPostfix(total);
|
||||
|
||||
// Determine which format function to use
|
||||
const formatUsed = formatFunctions[postfix] || formatFunctions['B'];
|
||||
const usedSizeFormatted = formatUsed(used, round);
|
||||
|
||||
if (lblType === 'used/total') {
|
||||
return `${usedSizeFormatted}/${totalSizeFormatted} ${postfix}`;
|
||||
}
|
||||
if (lblType === 'used') {
|
||||
return `${autoFormatSize(used, round)} ${getPostfix(used)}`;
|
||||
}
|
||||
if (lblType === 'free') {
|
||||
return `${autoFormatSize(free, round)} ${getPostfix(free)}`;
|
||||
}
|
||||
|
||||
return `${percentage}%`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a tooltip based on the data type and label type.
|
||||
*
|
||||
* This function generates a tooltip string based on the provided data type and label type.
|
||||
*
|
||||
* @param dataType The type of data to include in the tooltip.
|
||||
* @param lblTyp The type of label to format the tooltip for (used, free, used/total, or percentage).
|
||||
*
|
||||
* @returns The formatted tooltip as a string.
|
||||
*/
|
||||
export const formatTooltip = (dataType: string, lblTyp: ResourceLabelType): string => {
|
||||
switch (lblTyp) {
|
||||
case 'used':
|
||||
return `Used ${dataType}`;
|
||||
case 'free':
|
||||
return `Free ${dataType}`;
|
||||
case 'used/total':
|
||||
return `Used/Total ${dataType}`;
|
||||
case 'percentage':
|
||||
return `Percentage ${dataType} Usage`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
59
src/components/bar/utils/menu.ts
Normal file
59
src/components/bar/utils/menu.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { App, Gdk } from 'astal/gtk3';
|
||||
import { GtkWidget } from 'src/lib/types/widget';
|
||||
import { calculateMenuPosition } from 'src/components/menus/shared/dropdown/locationHandler';
|
||||
|
||||
export const closeAllMenus = (): void => {
|
||||
const menuWindows = App.get_windows()
|
||||
.filter((w) => {
|
||||
if (w.name) {
|
||||
return /.*menu/.test(w.name);
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
.map((window) => window.name);
|
||||
|
||||
menuWindows.forEach((window) => {
|
||||
if (window) {
|
||||
App.get_window(window)?.set_visible(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const openMenu = async (clicked: GtkWidget, event: Gdk.Event, window: string): Promise<void> => {
|
||||
/*
|
||||
* NOTE: We have to make some adjustments so the menu pops up relatively
|
||||
* to the center of the button clicked. We don't want the menu to spawn
|
||||
* offcenter depending on which edge of the button you click on.
|
||||
* -------------
|
||||
* To fix this, we take the x coordinate of the click within the button's bounds.
|
||||
* If you click the left edge of a 100 width button, then the x axis will be 0
|
||||
* and if you click the right edge then the x axis will be 100.
|
||||
* -------------
|
||||
* Then we divide the width of the button by 2 to get the center of the button and then get
|
||||
* the offset by subtracting the clicked x coordinate. Then we can apply that offset
|
||||
* to the x coordinate of the click relative to the screen to get the center of the
|
||||
* icon click.
|
||||
*/
|
||||
|
||||
try {
|
||||
const middleOfButton = Math.floor(clicked.get_allocated_width() / 2);
|
||||
const xAxisOfButtonClick = clicked.get_pointer()[0];
|
||||
const middleOffset = middleOfButton - xAxisOfButtonClick;
|
||||
|
||||
const clickPos = event.get_root_coords();
|
||||
const adjustedXCoord = clickPos[1] + middleOffset;
|
||||
const coords = [adjustedXCoord, clickPos[2]];
|
||||
|
||||
await calculateMenuPosition(coords, window);
|
||||
|
||||
closeAllMenus();
|
||||
App.toggle_window(window);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
console.error(`Error calculating menu position: ${error.stack}`);
|
||||
} else {
|
||||
console.error(`Unknown error occurred: ${error}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
169
src/components/bar/utils/monitors.ts
Normal file
169
src/components/bar/utils/monitors.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { hyprlandService } from 'src/lib/constants/services';
|
||||
import { Gdk } from 'astal/gtk3';
|
||||
import { BarLayout, BarLayouts } from 'src/lib/types/options';
|
||||
|
||||
type GdkMonitors = {
|
||||
[key: string]: {
|
||||
key: string;
|
||||
model: string;
|
||||
used: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const getLayoutForMonitor = (monitor: number, layouts: BarLayouts): BarLayout => {
|
||||
const matchingKey = Object.keys(layouts).find((key) => key === monitor.toString());
|
||||
const wildcard = Object.keys(layouts).find((key) => key === '*');
|
||||
|
||||
if (matchingKey) {
|
||||
return layouts[matchingKey];
|
||||
}
|
||||
|
||||
if (wildcard) {
|
||||
return layouts[wildcard];
|
||||
}
|
||||
|
||||
return {
|
||||
left: ['dashboard', 'workspaces', 'windowtitle'],
|
||||
middle: ['media'],
|
||||
right: ['volume', 'network', 'bluetooth', 'battery', 'systray', 'clock', 'notifications'],
|
||||
};
|
||||
};
|
||||
|
||||
export const isLayoutEmpty = (layout: BarLayout): boolean => {
|
||||
const isLeftSectionEmpty = !Array.isArray(layout.left) || layout.left.length === 0;
|
||||
const isRightSectionEmpty = !Array.isArray(layout.right) || layout.right.length === 0;
|
||||
const isMiddleSectionEmpty = !Array.isArray(layout.middle) || layout.middle.length === 0;
|
||||
|
||||
return isLeftSectionEmpty && isRightSectionEmpty && isMiddleSectionEmpty;
|
||||
};
|
||||
|
||||
export function getGdkMonitors(): GdkMonitors {
|
||||
const display = Gdk.Display.get_default();
|
||||
|
||||
if (display === null) {
|
||||
console.error('Failed to get Gdk display.');
|
||||
return {};
|
||||
}
|
||||
|
||||
const numGdkMonitors = display.get_n_monitors();
|
||||
const gdkMonitors: GdkMonitors = {};
|
||||
|
||||
for (let i = 0; i < numGdkMonitors; i++) {
|
||||
const curMonitor = display.get_monitor(i);
|
||||
|
||||
if (curMonitor === null) {
|
||||
console.warn(`Monitor at index ${i} is null.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const model = curMonitor.get_model() || '';
|
||||
const geometry = curMonitor.get_geometry();
|
||||
const scaleFactor = curMonitor.get_scale_factor();
|
||||
|
||||
const key = `${model}_${geometry.width}x${geometry.height}_${scaleFactor}`;
|
||||
gdkMonitors[i] = { key, model, used: false };
|
||||
}
|
||||
|
||||
return gdkMonitors;
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: Some more funky stuff being done by GDK.
|
||||
* We render windows/bar based on the monitor ID. So if you have 3 monitors, then your
|
||||
* monitor IDs will be [0, 1, 2]. Hyprland will NEVER change what ID belongs to what monitor.
|
||||
*
|
||||
* So if hyprland determines id 0 = DP-1, even after you unplug, shut off or restart your monitor,
|
||||
* the id 0 will ALWAYS be DP-1.
|
||||
*
|
||||
* However, GDK (the righteous genius that it is) will change the order of ID anytime your monitor
|
||||
* setup is changed. So if you unplug your monitor and plug it back it, it now becomes the last id.
|
||||
* So if DP-1 was id 0 and you unplugged it, it will reconfigure to id 2. This sucks because now
|
||||
* there's a mismtach between what GDK determines the monitor is at id 2 and what Hyprland determines
|
||||
* is at id 2.
|
||||
*
|
||||
* So for that reason, we need to redirect the input `monitor` that the Bar module takes in, to the
|
||||
* proper Hyprland monitor. So when monitor id 0 comes in, we need to find what the id of that monitor
|
||||
* is being determined as by Hyprland so the bars show up on the right monitors.
|
||||
*
|
||||
* Since GTK3 doesn't contain connection names and only monitor models, we have to make the best guess
|
||||
* in the case that there are multiple models in the same resolution with the same scale. We find the
|
||||
* 'right' monitor by checking if the model matches along with the resolution and scale. If monitor at
|
||||
* ID 0 for GDK is being reported as 'MSI MAG271CQR' we find the same model in the Hyprland monitor list
|
||||
* and check if the resolution and scaling is the same... if it is then we determine it's a match.
|
||||
*
|
||||
* The edge-case that we just can't handle is if you have the same monitors in the same resolution at the same
|
||||
* scale. So if you've got 2 'MSI MAG271CQR' monitors at 2560x1440 at scale 1, then we just match the first
|
||||
* monitor in the list as the first match and then the second 'MSI MAG271CQR' as a match in the 2nd iteration.
|
||||
* You may have the bar showing up on the wrong one in this case because we don't know what the connector id
|
||||
* is of either of these monitors (DP-1, DP-2) which are unique values - as these are only in GTK4.
|
||||
*
|
||||
* Keep in mind though, this is ONLY an issue if you change your monitor setup by plugging in a new one, restarting
|
||||
* an existing one or shutting it off.
|
||||
*
|
||||
* If your monitors aren't changed in the current session you're in then none of this safeguarding is relevant.
|
||||
*
|
||||
* Fun stuff really... :facepalm:
|
||||
*/
|
||||
|
||||
export const gdkMonitorIdToHyprlandId = (monitor: number, usedHyprlandMonitors: Set<number>): number => {
|
||||
const gdkMonitors = getGdkMonitors();
|
||||
|
||||
if (Object.keys(gdkMonitors).length === 0) {
|
||||
return monitor;
|
||||
}
|
||||
|
||||
// Get the GDK monitor for the given monitor index
|
||||
const gdkMonitor = gdkMonitors[monitor];
|
||||
|
||||
// First pass: Strict matching including the monitor index (i.e., hypMon.id === monitor + resolution+scale criteria)
|
||||
const directMatch = hyprlandService.get_monitors().find((hypMon) => {
|
||||
const isVertical = hypMon?.transform !== undefined ? hypMon.transform % 2 !== 0 : false;
|
||||
|
||||
const width = isVertical ? hypMon.height : hypMon.width;
|
||||
const height = isVertical ? hypMon.width : hypMon.height;
|
||||
|
||||
const hyprlandKey = `${hypMon.model}_${width}x${height}_${hypMon.scale}`;
|
||||
return gdkMonitor.key.startsWith(hyprlandKey) && !usedHyprlandMonitors.has(hypMon.id) && hypMon.id === monitor;
|
||||
});
|
||||
|
||||
if (directMatch) {
|
||||
usedHyprlandMonitors.add(directMatch.id);
|
||||
return directMatch.id;
|
||||
}
|
||||
|
||||
// Second pass: Relaxed matching without considering the monitor index
|
||||
const hyprlandMonitor = hyprlandService.get_monitors().find((hypMon) => {
|
||||
const isVertical = hypMon?.transform !== undefined ? hypMon.transform % 2 !== 0 : false;
|
||||
|
||||
const width = isVertical ? hypMon.height : hypMon.width;
|
||||
const height = isVertical ? hypMon.width : hypMon.height;
|
||||
|
||||
const hyprlandKey = `${hypMon.model}_${width}x${height}_${hypMon.scale}`;
|
||||
return gdkMonitor.key.startsWith(hyprlandKey) && !usedHyprlandMonitors.has(hypMon.id);
|
||||
});
|
||||
|
||||
if (hyprlandMonitor) {
|
||||
usedHyprlandMonitors.add(hyprlandMonitor.id);
|
||||
return hyprlandMonitor.id;
|
||||
}
|
||||
|
||||
// Fallback: Find the first available monitor ID that hasn't been used
|
||||
const fallbackMonitor = hyprlandService.get_monitors().find((hypMon) => !usedHyprlandMonitors.has(hypMon.id));
|
||||
|
||||
if (fallbackMonitor) {
|
||||
usedHyprlandMonitors.add(fallbackMonitor.id);
|
||||
return fallbackMonitor.id;
|
||||
}
|
||||
|
||||
// Ensure we return a valid monitor ID that actually exists
|
||||
for (let i = 0; i < hyprlandService.get_monitors().length; i++) {
|
||||
if (!usedHyprlandMonitors.has(i)) {
|
||||
usedHyprlandMonitors.add(i);
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
// As a last resort, return the original monitor index if no unique monitor can be found
|
||||
console.warn(`Returning original monitor index as a last resort: ${monitor}`);
|
||||
return monitor;
|
||||
};
|
||||
29
src/components/bar/utils/sideEffects.ts
Normal file
29
src/components/bar/utils/sideEffects.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import options from '../../../options';
|
||||
|
||||
const { showIcon, showTime } = options.bar.clock;
|
||||
|
||||
showIcon.subscribe(() => {
|
||||
if (!showTime.get() && !showIcon.get()) {
|
||||
showTime.set(true);
|
||||
}
|
||||
});
|
||||
|
||||
showTime.subscribe(() => {
|
||||
if (!showTime.get() && !showIcon.get()) {
|
||||
showIcon.set(true);
|
||||
}
|
||||
});
|
||||
|
||||
const { label, icon } = options.bar.windowtitle;
|
||||
|
||||
label.subscribe(() => {
|
||||
if (!label.get() && !icon.get()) {
|
||||
icon.set(true);
|
||||
}
|
||||
});
|
||||
|
||||
icon.subscribe(() => {
|
||||
if (!label.get() && !icon.get()) {
|
||||
label.set(true);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user