Minor: Refactor the code-base for better organization and compartmentalization. (#934)

* Clean up unused code

* Fix media player formatting issue for labels with new line characteres.

* Refactor the media player handlers into a class.

* More code cleanup and organize shared weather utils into distinct classes.

* Flatten some nesting.

* Move weather manager in dedicated class and build HTTP Utility class for Rest API calling.

* Remove logs

* Rebase master merge

* Reorg code (WIP)

* More reorg

* Delete utility scripts

* Reorg options

* Finish moving all options over

* Fix typescript issues

* Update options imports to default

* missed update

* Screw barrel files honestly, work of the devil.

* Only initialize power profiles if power-profiles-daemon is running.

* Fix window positioning and weather service naming

* style dir

* More organization

* Restructure types to be closer to their source

* Remove lib types and constants

* Update basic weather object to be saner with extensibility.

* Service updates

* Fix initialization strategy for services.

* Fix Config Manager to only emit changed objects and added missing temp converters.

* Update storage service to handle unit changes.

* Added cpu temp sensor auto-discovery

* Added missing JSDocs to services

* remove unused

* Migrate to network service.

* Fix network password issue.

* Move out password input into helper

* Rename password mask constant to be less double-negativey.

* Dropdown menu rename

* Added a component to edit JSON in the settings dialog (rough/WIP)

* Align settings

* Add and style JSON Editor.

* Adjust padding

* perf(shortcuts):  avoid unnecessary polling when shortcuts are disabled

Stops the recording poller when shortcuts are disabled, preventing redundant polling and reducing resource usage.

* Fix types and return value if shortcut not enabled.

* Move the swww daemon checking process outside of the wallpaper service into a dedicated deamon lifecyle processor.

* Add more string formatters and use title case for weather status (as it was).

* Fix startup errors.

* Rgba fix

* Remove zod from dependencies

---------

Co-authored-by: KernelDiego <gonzalezdiego.contact@gmail.com>
This commit is contained in:
Jas Singh
2025-05-26 19:45:11 -07:00
committed by GitHub
parent 436dcbfcf2
commit 8cf5806766
532 changed files with 13134 additions and 8669 deletions

18
src/lib/array/helpers.ts Normal file
View File

@@ -0,0 +1,18 @@
/**
* Generates an array of numbers within a specified range
* @param length - The length of the array to generate
* @param start - The starting value of the range. Defaults to 1
* @returns An array of numbers within the specified range
*/
export function range(length: number, start = 1): number[] {
return Array.from({ length }, (_, i) => i + start);
}
/**
* Removes duplicate values from an array
* @param array - The array to deduplicate
* @returns A new array with unique values
*/
export function unique<T>(array: T[]): T[] {
return [...new Set(array)];
}

40
src/lib/bar/helpers.ts Normal file
View File

@@ -0,0 +1,40 @@
import options from 'src/configuration';
import { BarLayouts, BarModule } from '../options/types';
import { unique } from '../array/helpers';
/**
* Retrieves all unique layout items from the bar options.
*
* This function extracts all unique layout items from the bar options defined in the `options` object.
* It iterates through the layouts for each monitor and collects items from the left, middle, and right sections.
*
* @returns An array of unique layout items.
*/
export function getLayoutItems(): BarModule[] {
const { layouts } = options.bar;
const itemsInLayout: BarModule[] = [];
Object.keys(layouts.get()).forEach((monitor) => {
const leftItems = layouts.get()[monitor].left;
const rightItems = layouts.get()[monitor].right;
const middleItems = layouts.get()[monitor].middle;
itemsInLayout.push(...leftItems);
itemsInLayout.push(...middleItems);
itemsInLayout.push(...rightItems);
});
return unique(itemsInLayout);
}
export function setLayout(layout: BarLayouts): string {
try {
const { layouts } = options.bar;
layouts.set(layout);
return 'Successfully updated layout.';
} catch (error) {
return `Failed to set layout: ${error}`;
}
}

View File

@@ -1,109 +0,0 @@
import { bind, Variable } from 'astal';
import { App } from 'astal/gtk3';
import AstalHyprland from 'gi://AstalHyprland?version=0.1';
import { BarVisibility } from 'src/cli/utils/BarVisibility';
import { forceUpdater } from 'src/components/bar/modules/workspaces/helpers';
import options from 'src/options';
const hyprlandService = AstalHyprland.get_default();
const { autoHide } = options.bar;
/**
* Sets bar visibility for a specific monitor
*
* @param monitorId - The ID of the monitor
* @param isVisible - Whether the bar should be visible
*/
function setBarVisibility(monitorId: number, isVisible: boolean): void {
const barName = `bar-${monitorId}`;
if (BarVisibility.get(barName)) {
App.get_window(barName)?.set_visible(isVisible);
}
}
/**
* Handles bar visibility when a client's fullscreen state changes
*
* @param client - The Hyprland client that gained focus
*/
function handleFullscreenClientVisibility(client: AstalHyprland.Client): void {
if (client === null) {
return;
}
const fullscreenBinding = bind(client, 'fullscreen');
Variable.derive([bind(fullscreenBinding)], (isFullScreen) => {
if (autoHide.get() === 'fullscreen') {
setBarVisibility(client.monitor.id, !Boolean(isFullScreen));
}
});
}
/**
* Shows bars on all monitors
*/
function showAllBars(): void {
const monitors = hyprlandService.get_monitors();
monitors.forEach((monitor) => {
if (BarVisibility.get(`bar-${monitor.id}`)) {
setBarVisibility(monitor.id, true);
}
});
}
/**
* Updates bar visibility based on workspace window count
*/
function updateBarVisibilityByWindowCount(): void {
const monitors = hyprlandService.get_monitors();
const activeWorkspaces = monitors.map((monitor) => monitor.active_workspace);
activeWorkspaces.forEach((workspace) => {
const hasOneClient = workspace.get_clients().length !== 1;
setBarVisibility(workspace.monitor.id, hasOneClient);
});
}
/**
* Updates bar visibility based on workspace fullscreen state
*/
function updateBarVisibilityByFullscreen(): void {
hyprlandService.get_workspaces().forEach((workspace) => {
setBarVisibility(workspace.monitor.id, !workspace.hasFullscreen);
});
}
/**
* Initializes the auto-hide behavior for bars
* Manages visibility based on window count, fullscreen state, and user preferences
*/
export function initializeAutoHide(): void {
Variable.derive(
[
bind(autoHide),
bind(hyprlandService, 'workspaces'),
bind(forceUpdater),
bind(hyprlandService, 'focusedWorkspace'),
],
(hideMode) => {
if (hideMode === 'never') {
showAllBars();
} else if (hideMode === 'single-window') {
updateBarVisibilityByWindowCount();
}
},
);
Variable.derive([bind(hyprlandService, 'focusedClient')], (currentClient) => {
handleFullscreenClientVisibility(currentClient);
});
Variable.derive([bind(autoHide)], (hideMode) => {
if (hideMode === 'fullscreen') {
updateBarVisibilityByFullscreen();
}
});
}

View File

@@ -1,59 +0,0 @@
import AstalBattery from 'gi://AstalBattery?version=0.1';
import icons from '../icons/icons';
import { Notify } from '../utils';
import options from 'src/options';
const batteryService = AstalBattery.get_default();
const {
lowBatteryThreshold,
lowBatteryNotification,
lowBatteryNotificationText,
lowBatteryNotificationTitle,
} = options.menus.power;
export function warnOnLowBattery(): void {
let sentLowNotification = false;
let sentHalfLowNotification = false;
batteryService.connect('notify::charging', () => {
if (batteryService.charging) {
sentLowNotification = false;
sentHalfLowNotification = false;
}
});
batteryService.connect('notify::percentage', () => {
if (lowBatteryNotification.get() === undefined || batteryService.charging) {
return;
}
const batteryPercentage = Math.floor(batteryService.percentage * 100);
const lowThreshold = lowBatteryThreshold.get();
// Avoid double notification
let sendNotification = false;
if (!sentLowNotification && batteryPercentage <= lowThreshold) {
sentLowNotification = true;
sendNotification = true;
}
if (!sentHalfLowNotification && batteryPercentage <= lowThreshold / 2) {
sentHalfLowNotification = true;
sendNotification = true;
}
if (sendNotification) {
Notify({
summary: lowBatteryNotificationTitle
.get()
.replaceAll('$POWER_LEVEL', batteryPercentage.toString()),
body: lowBatteryNotificationText
.get()
.replaceAll('$POWER_LEVEL', batteryPercentage.toString()),
iconName: icons.ui.warning,
urgency: 'critical',
});
}
});
}

View File

@@ -1,24 +0,0 @@
import AstalHyprland from 'gi://AstalHyprland?version=0.1';
const hyprlandService = AstalHyprland.get_default();
const floatSettingsDialog = (): void => {
hyprlandService.message('keyword windowrulev2 float, title:^(hyprpanel-settings)$');
hyprlandService.connect('config-reloaded', () => {
hyprlandService.message('keyword windowrulev2 float, title:^(hyprpanel-settings)$');
});
};
const floatFilePicker = (): void => {
hyprlandService.message('keyword windowrulev2 float, title:^((Save|Import) Hyprpanel.*)$');
hyprlandService.connect('config-reloaded', () => {
hyprlandService.message('keyword windowrulev2 float, title:^((Save|Import) Hyprpanel.*)$');
});
};
export const hyprlandSettings = (): void => {
floatSettingsDialog();
floatFilePicker();
};

View File

@@ -1,10 +0,0 @@
import './autoHide';
import { initializeAutoHide } from './autoHide';
import { warnOnLowBattery } from './batteryWarning';
import { hyprlandSettings } from './hyprlandRules';
export const initializeSystemBehaviors = (): void => {
warnOnLowBattery();
initializeAutoHide();
hyprlandSettings();
};

View File

@@ -1,139 +0,0 @@
export const defaultWindowTitleMap = [
// Misc
['kitty', '󰄛', 'Kitty Terminal'],
['firefox', '󰈹', 'Firefox'],
['microsoft-edge', '󰇩', 'Edge'],
['discord', '', 'Discord'],
['vesktop', '', 'Vesktop'],
['org.kde.dolphin', '', 'Dolphin'],
['plex', '󰚺', 'Plex'],
['steam', '', 'Steam'],
['spotify', '󰓇', 'Spotify'],
['ristretto', '󰋩', 'Ristretto'],
['obsidian', '󱓧', 'Obsidian'],
['rofi', '', 'Rofi'],
['qBittorrent$', '', 'QBittorrent'],
// Browsers
['google-chrome', '', 'Google Chrome'],
['brave-browser', '󰖟', 'Brave Browser'],
['chromium', '', 'Chromium'],
['opera', '', 'Opera'],
['vivaldi', '󰖟', 'Vivaldi'],
['waterfox', '󰖟', 'Waterfox'],
['thorium', '󰖟', 'Thorium'],
['tor-browser', '', 'Tor Browser'],
['floorp', '󰈹', 'Floorp'],
['zen', '', 'Zen Browser'],
// Terminals
['gnome-terminal', '', 'GNOME Terminal'],
['konsole', '', 'Konsole'],
['alacritty', '', 'Alacritty'],
['wezterm', '', 'Wezterm'],
['foot', '󰽒', 'Foot Terminal'],
['tilix', '', 'Tilix'],
['xterm', '', 'XTerm'],
['urxvt', '', 'URxvt'],
['com.mitchellh.ghostty', '󰊠', 'Ghostty'],
['^st$', '', 'st Terminal'],
// Development Tools
['code', '󰨞', 'Visual Studio Code'],
['vscode', '󰨞', 'VS Code'],
['sublime-text', '', 'Sublime Text'],
['atom', '', 'Atom'],
['android-studio', '󰀴', 'Android Studio'],
['jetbrains-idea', '', 'IntelliJ IDEA'],
['jetbrains-pycharm', '', 'PyCharm'],
['jetbrains-webstorm', '', 'WebStorm'],
['jetbrains-phpstorm', '', 'PhpStorm'],
['eclipse', '', 'Eclipse'],
['netbeans', '', 'NetBeans'],
['docker', '', 'Docker'],
['vim', '', 'Vim'],
['neovim', '', 'Neovim'],
['neovide', '', 'Neovide'],
['emacs', '', 'Emacs'],
// Communication Tools
['slack', '󰒱', 'Slack'],
['telegram-desktop', '', 'Telegram'],
['org.telegram.desktop', '', 'Telegram'],
['whatsapp', '󰖣', 'WhatsApp'],
['teams', '󰊻', 'Microsoft Teams'],
['skype', '󰒯', 'Skype'],
['thunderbird', '', 'Thunderbird'],
// File Managers
['nautilus', '󰝰', 'Files (Nautilus)'],
['thunar', '󰝰', 'Thunar'],
['pcmanfm', '󰝰', 'PCManFM'],
['nemo', '󰝰', 'Nemo'],
['ranger', '󰝰', 'Ranger'],
['doublecmd', '󰝰', 'Double Commander'],
['krusader', '󰝰', 'Krusader'],
// Media Players
['vlc', '󰕼', 'VLC Media Player'],
['mpv', '', 'MPV'],
['rhythmbox', '󰓃', 'Rhythmbox'],
// Graphics Tools
['gimp', '', 'GIMP'],
['inkscape', '', 'Inkscape'],
['krita', '', 'Krita'],
['blender', '󰂫', 'Blender'],
// Video Editing
['kdenlive', '', 'Kdenlive'],
// Games and Gaming Platforms
['lutris', '󰺵', 'Lutris'],
['heroic', '󰺵', 'Heroic Games Launcher'],
['minecraft', '󰍳', 'Minecraft'],
['csgo', '󰺵', 'CS:GO'],
['dota2', '󰺵', 'Dota 2'],
// Office and Productivity
['evernote', '', 'Evernote'],
['sioyek', '', 'Sioyek'],
// Cloud Services and Sync
['dropbox', '󰇣', 'Dropbox'],
];
const overrides = {
kitty: '',
};
/**
* Generates a mapping of application names to their corresponding icons.
* Uses the defaultWindowTitleMap to create the base mapping and applies any overrides.
*
* @returns An object where keys are application names and values are icon names.
* If an application name exists in the overrides, that value is used instead of the default.
*
* @example
* // Given:
* defaultWindowTitleMap = [['kitty', '󰄛', 'Kitty Terminal'], ['firefox', '󰈹', 'Firefox']]
* overrides = { 'kitty': '' }
*
* // Returns:
* { 'kitty': '', 'firefox': '󰈹' }
*/
export const defaultApplicationIconMap = defaultWindowTitleMap.reduce(
(iconMapAccumulator: Record<string, string>, windowTitles) => {
const currentIconMap = iconMapAccumulator;
const appName: string = windowTitles[0];
const appIcon: string = windowTitles[1];
if (!(appName in currentIconMap)) {
currentIconMap[appName] = appIcon;
}
return currentIconMap;
},
overrides,
);

View File

@@ -1,31 +0,0 @@
export const distroIcons = [
['deepin', ''],
['fedora', ''],
['arch', ''],
['nixos', ''],
['debian', ''],
['opensuse-tumbleweed', ''],
['ubuntu', ''],
['endeavouros', ''],
['manjaro', ''],
['popos', ''],
['garuda', ''],
['zorin', ''],
['mxlinux', ''],
['arcolinux', ''],
['gentoo', ''],
['artix', ''],
['centos', ''],
['hyperbola', ''],
['kubuntu', ''],
['mandriva', ''],
['xerolinux', ''],
['parabola', ''],
['void', ''],
['linuxmint', ''],
['archlabs', ''],
['devuan', ''],
['freebsd', ''],
['openbsd', ''],
['slackware', ''],
];

View File

@@ -1,22 +0,0 @@
import AstalNetwork from 'gi://AstalNetwork?version=0.1';
type DeviceSate = AstalNetwork.DeviceState;
type DevceStates = {
[key in DeviceSate]: string;
};
export const DEVICE_STATES: DevceStates = {
[AstalNetwork.DeviceState.UNKNOWN]: 'Unknown',
[AstalNetwork.DeviceState.UNMANAGED]: 'Unmanaged',
[AstalNetwork.DeviceState.UNAVAILABLE]: 'Unavailable',
[AstalNetwork.DeviceState.DISCONNECTED]: 'Disconnected',
[AstalNetwork.DeviceState.PREPARE]: 'Prepare',
[AstalNetwork.DeviceState.CONFIG]: 'Config',
[AstalNetwork.DeviceState.NEED_AUTH]: 'Need Authentication',
[AstalNetwork.DeviceState.IP_CONFIG]: 'IP Configuration',
[AstalNetwork.DeviceState.IP_CHECK]: 'IP Check',
[AstalNetwork.DeviceState.SECONDARIES]: 'Secondaries',
[AstalNetwork.DeviceState.ACTIVATED]: 'Activated',
[AstalNetwork.DeviceState.DEACTIVATING]: 'Deactivating',
[AstalNetwork.DeviceState.FAILED]: 'Failed',
};

View File

@@ -1,37 +0,0 @@
import { Gtk } from 'astal/gtk3';
import { DropdownMenuList } from '../options/options.types';
export const StackTransitionMap = {
none: Gtk.StackTransitionType.NONE,
crossfade: Gtk.StackTransitionType.CROSSFADE,
slide_right: Gtk.StackTransitionType.SLIDE_RIGHT,
slide_left: Gtk.StackTransitionType.SLIDE_LEFT,
slide_up: Gtk.StackTransitionType.SLIDE_UP,
slide_down: Gtk.StackTransitionType.SLIDE_DOWN,
};
export const RevealerTransitionMap = {
none: Gtk.RevealerTransitionType.NONE,
crossfade: Gtk.RevealerTransitionType.CROSSFADE,
slide_right: Gtk.RevealerTransitionType.SLIDE_RIGHT,
slide_left: Gtk.RevealerTransitionType.SLIDE_LEFT,
slide_up: Gtk.RevealerTransitionType.SLIDE_UP,
slide_down: Gtk.RevealerTransitionType.SLIDE_DOWN,
};
export const dropdownMenuList = [
'dashboardmenu',
'audiomenu',
'mediamenu',
'networkmenu',
'bluetoothmenu',
'notificationsmenu',
'calendarmenu',
'energymenu',
'powerdropdownmenu',
'settings-dialog',
] as const;
export const isDropdownMenu = (name: string): name is DropdownMenuList => {
return dropdownMenuList.includes(name as DropdownMenuList);
};

View File

@@ -0,0 +1,8 @@
import { EventBox } from 'astal/gtk3/widget';
import Variable from 'astal/variable';
type GlobalEventBoxes = {
[key: string]: EventBox;
};
export const globalEventBoxes: Variable<GlobalEventBoxes> = Variable({});

62
src/lib/events/mouse.ts Normal file
View File

@@ -0,0 +1,62 @@
import { Astal, Gdk } from 'astal/gtk3';
/**
* Checks if an event is a primary click
* @param event - The click event to check
* @returns True if the event is a primary click, false otherwise
*/
export const isPrimaryClick = (event: Astal.ClickEvent): boolean => event.button === Gdk.BUTTON_PRIMARY;
/**
* Checks if an event is a secondary click
* @param event - The click event to check
* @returns True if the event is a secondary click, false otherwise
*/
export const isSecondaryClick = (event: Astal.ClickEvent): boolean => event.button === Gdk.BUTTON_SECONDARY;
/**
* Checks if an event is a middle click
* @param event - The click event to check
* @returns True if the event is a middle click, false otherwise
*/
export const isMiddleClick = (event: Astal.ClickEvent): boolean => event.button === Gdk.BUTTON_MIDDLE;
/**
* Checks if an event is a scroll up
* @param event - The scroll event to check
* @returns True if the event is a scroll up, false otherwise
*/
export const isScrollUp = (event: Gdk.Event): boolean => {
const [directionSuccess, direction] = event.get_scroll_direction();
const [deltaSuccess, , yScroll] = event.get_scroll_deltas();
if (directionSuccess && direction === Gdk.ScrollDirection.UP) {
return true;
}
if (deltaSuccess && yScroll < 0) {
return true;
}
return false;
};
/**
* Checks if an event is a scroll down
* @param event - The scroll event to check
* @returns True if the event is a scroll down, false otherwise
*/
export const isScrollDown = (event: Gdk.Event): boolean => {
const [directionSuccess, direction] = event.get_scroll_direction();
const [deltaSuccess, , yScroll] = event.get_scroll_deltas();
if (directionSuccess && direction === Gdk.ScrollDirection.DOWN) {
return true;
}
if (deltaSuccess && yScroll > 0) {
return true;
}
return false;
};

View File

@@ -0,0 +1,27 @@
import { HttpErrorOptions } from './types';
/**
* Custom error class for HTTP request failures
* Provides status code and response data for error handling
*/
export class HttpError extends Error {
public status: number;
public data?: unknown;
public url?: string;
public method?: string;
constructor(options: HttpErrorOptions) {
const { status, message, data, url, method } = options;
const errorMessage = message ? `: ${message}` : '';
const response = `HTTP ${status}${errorMessage}`;
super(response);
this.name = 'HttpError';
this.status = status;
this.data = data;
this.url = url;
this.method = method;
}
}

268
src/lib/httpClient/index.ts Normal file
View File

@@ -0,0 +1,268 @@
import { GLib } from 'astal';
import Soup from 'gi://Soup?version=3.0';
import { HttpError } from './HttpError';
import { RequestOptions, RestResponse } from './types';
import { errorHandler } from 'src/core/errors/handler';
/**
* HTTP client wrapper for Soup.Session providing a Promise-based API
* Handles authentication, timeouts, and JSON parsing automatically
*/
class HttpClient {
private _session: Soup.Session;
constructor(defaultTimeout = 30) {
this._session = new Soup.Session();
this._session.timeout = defaultTimeout;
this._session.user_agent = 'HyprPanel/1.0';
}
/*******************************************
* HTTP Methods *
*******************************************/
/**
* Performs an HTTP GET request
* @param url - Target URL for the request
* @param options - Optional configuration for the request
*/
public async get(url: string, options?: RequestOptions): Promise<RestResponse> {
return this._request('GET', url, options);
}
/**
* Performs an HTTP POST request
* @param url - Target URL for the request
* @param data - Request payload to send
* @param options - Optional configuration for the request
*/
public async post(
url: string,
data?: Record<string, unknown>,
options?: RequestOptions,
): Promise<RestResponse> {
return this._request('POST', url, { ...options, body: data });
}
/**
* Performs an HTTP PUT request
* @param url - Target URL for the request
* @param data - Request payload to send
* @param options - Optional configuration for the request
*/
public async put(
url: string,
data?: Record<string, unknown>,
options?: RequestOptions,
): Promise<RestResponse> {
return this._request('PUT', url, { ...options, body: data });
}
/**
* Performs an HTTP PATCH request
* @param url - Target URL for the request
* @param data - Request payload with partial updates
* @param options - Optional configuration for the request
*/
public async patch(
url: string,
data?: Record<string, unknown>,
options?: RequestOptions,
): Promise<RestResponse> {
return this._request('PATCH', url, { ...options, body: data });
}
/**
* Performs an HTTP DELETE request
* @param url - Target URL for the request
* @param options - Optional configuration for the request
*/
public async delete(url: string, options?: RequestOptions): Promise<RestResponse> {
return this._request('DELETE', url, options);
}
/*******************************************
* SOUP Infrastructure *
*******************************************/
/**
* Internal request handler for all HTTP methods
* @param method - HTTP method to use
* @param url - Target URL for the request
* @param options - Configuration options for the request
* @private
*/
private async _request(method: string, url: string, options: RequestOptions = {}): Promise<RestResponse> {
const requestPromise = new Promise<RestResponse>((resolve, reject) => {
const message = Soup.Message.new(method, url);
if (!message) {
return reject(new Error(`Failed to create request for ${url}`));
}
this._assignHeaders(message, options);
this._constructBodyIfExists(method, options, message);
if (options.timeout) {
this._session.timeout = options.timeout / 1000;
}
this._sendRequest(resolve, reject, message, options);
});
return requestPromise;
}
/**
* Constructs and sets the request body for HTTP methods that support it
* @param method - HTTP method being used
* @param options - Request options containing the body
* @param message - Soup message to attach the body to
*/
private _constructBodyIfExists(method: string, options: RequestOptions, message: Soup.Message): void {
const canContainBody = ['POST', 'PUT', 'PATCH'].includes(method);
if (options.body && canContainBody) {
let body: string;
let contentType = options.headers?.['Content-Type'] || 'application/json';
if (typeof options.body === 'object') {
body = JSON.stringify(options.body);
} else {
body = options.body;
contentType = contentType || 'text/plain';
}
const textEncoder = new TextEncoder();
const bytes = new GLib.Bytes(textEncoder.encode(body));
message.set_request_body_from_bytes(contentType, bytes);
}
}
/**
* Assigns headers to the request message
* @param message - Soup message to add headers to
* @param options - Request options containing headers
*/
private _assignHeaders(message: Soup.Message, options: RequestOptions): Soup.MessageHeaders {
const headers = message.get_request_headers();
if (options.headers) {
Object.entries(options.headers).forEach(([key, value]) => {
headers.append(key, value);
});
}
return headers;
}
/**
* Sends the HTTP request and handles the response
* @param resolve - Promise resolve callback
* @param reject - Promise reject callback
* @param message - Prepared Soup message to send
* @param options - Request configuration options
*/
private _sendRequest(
resolve: (value: RestResponse | PromiseLike<RestResponse>) => void,
reject: (reason?: unknown) => void,
message: Soup.Message,
options: RequestOptions,
): void {
const cancellable = options.signal ?? null;
try {
const bytes = this._session.send_and_read(message, cancellable);
const {
response: responseText,
headers: responseHeaders,
status,
} = this._decodeResponseSync(message, bytes);
const responseData = this._parseReponseData(options, responseText);
const response: RestResponse = {
data: responseData,
status,
headers: responseHeaders,
};
if (status >= 400) {
const httpError = new HttpError({
status,
data: responseData,
url: message.get_uri().to_string(),
method: message.get_method(),
});
return reject(httpError);
}
return resolve(response);
} catch (error) {
reject(error);
}
}
/**
* Decodes the response bytes into text and extracts response metadata
* @param message - Soup message containing the response
* @param bytes - Response bytes from the sync request
*/
private _decodeResponseSync(
message: Soup.Message,
bytes: GLib.Bytes | null,
): {
response: string;
status: Soup.Status;
headers: Record<string, string>;
} {
if (!bytes) {
throw new Error('No response received');
}
const decoder = new TextDecoder();
const byteData = bytes.get_data();
const responseText = byteData ? decoder.decode(byteData) : '';
const status = message.get_status();
const responseHeaders: Record<string, string> = {};
message.get_response_headers().foreach((name, value) => {
responseHeaders[name] = value;
});
return {
response: responseText,
status,
headers: responseHeaders,
};
}
/**
* Parses response text based on the expected response type
* @param options - Request options containing responseType preference
* @param responseText - Raw response text to parse
*/
private _parseReponseData(
options: RequestOptions,
responseText: string,
): string | Record<string, unknown> {
if (!responseText) {
return '';
}
if (options.responseType === 'text') {
return responseText;
}
try {
const parsedResponseText = JSON.parse(responseText);
return parsedResponseText;
} catch (e) {
errorHandler(`Failed to parse JSON response: ${e}`);
}
}
}
export const httpClient = new HttpClient();

View File

@@ -0,0 +1,23 @@
import { Gio } from 'astal';
export interface RequestOptions {
headers?: Record<string, string>;
body?: string | object;
timeout?: number;
responseType?: 'json' | 'text';
signal?: Gio.Cancellable;
}
export interface RestResponse {
data: unknown;
status: number;
headers: Record<string, string>;
}
export interface HttpErrorOptions {
status: number;
message?: string;
data?: unknown;
url?: string;
method?: string;
}

32
src/lib/icons/helpers.ts Normal file
View File

@@ -0,0 +1,32 @@
import { Gtk } from 'astal/gtk3';
/**
* Looks up an icon by name and size
* @param name - The name of the icon to look up
* @param size - The size of the icon to look up. Defaults to 16
* @returns The Gtk.IconInfo object if the icon is found, or null if not found
*/
export function lookUpIcon(name?: string, size = 16): Gtk.IconInfo | null {
if (name === undefined) return null;
return Gtk.IconTheme.get_default().lookup_icon(name, size, Gtk.IconLookupFlags.USE_BUILTIN);
}
/**
* Checks if an icon exists in the theme
* @param name - The name of the icon to check
* @returns True if the icon exists, false otherwise
*/
export function iconExists(name: string): boolean {
return lookUpIcon(name) !== null;
}
/**
* Gets an icon name with fallback
* @param primary - The primary icon name to try
* @param fallback - The fallback icon name if primary doesn't exist
* @returns The primary icon if it exists, otherwise the fallback
*/
export function getIconWithFallback(primary: string, fallback: string): string {
return iconExists(primary) ? primary : fallback;
}

View File

@@ -1,17 +1,3 @@
export const substitutes = {
'transmission-gtk': 'transmission',
'blueberry.py': 'blueberry',
Caprine: 'facebook-messenger',
'com.raggesilver.BlackBox-symbolic': 'terminal-symbolic',
'org.wezfurlong.wezterm-symbolic': 'terminal-symbolic',
'audio-headset-bluetooth': 'audio-headphones-symbolic',
'audio-card-analog-usb': 'audio-speakers-symbolic',
'audio-card-analog-pci': 'audio-card-symbolic',
'preferences-system': 'emblem-system-symbolic',
'com.github.Aylur.ags-symbolic': 'controls-symbolic',
'com.github.Aylur.ags': 'controls-symbolic',
} as const;
export default {
missing: 'image-missing-symbolic',
nix: {
@@ -141,4 +127,59 @@ export default {
dark: 'dark-mode-symbolic',
light: 'light-mode-symbolic',
},
};
weather: {
warning: 'dialog-warning-symbolic',
sunny: 'weather-clear-symbolic',
clear: 'weather-clear-night-symbolic',
partly_cloudy: 'weather-few-clouds-symbolic',
partly_cloudy_night: 'weather-few-clouds-night-symbolic',
cloudy: 'weather-overcast-symbolic',
overcast: 'weather-overcast-symbolic',
mist: 'weather-overcast-symbolic',
patchy_rain_nearby: 'weather-showers-scattered-symbolic',
patchy_rain_possible: 'weather-showers-scattered-symbolic',
patchy_snow_possible: 'weather-snow-symbolic',
patchy_sleet_possible: 'weather-snow-symbolic',
patchy_freezing_drizzle_possible: 'weather-showers-scattered-symbolic',
thundery_outbreaks_possible: 'weather-overcast-symbolic',
blowing_snow: 'weather-snow-symbolic',
blizzard: 'weather-snow-symbolic',
fog: 'weather-fog-symbolic',
freezing_fog: 'weather-fog-symbolic',
patchy_light_drizzle: 'weather-showers-scattered-symbolic',
light_drizzle: 'weather-showers-symbolic',
freezing_drizzle: 'weather-showers-symbolic',
heavy_freezing_drizzle: 'weather-showers-symbolic',
patchy_light_rain: 'weather-showers-scattered-symbolic',
light_rain: 'weather-showers-symbolic',
moderate_rain_at_times: 'weather-showers-symbolic',
moderate_rain: 'weather-showers-symbolic',
heavy_rain_at_times: 'weather-showers-symbolic',
heavy_rain: 'weather-showers-symbolic',
light_freezing_rain: 'weather-showers-symbolic',
moderate_or_heavy_freezing_rain: 'weather-showers-symbolic',
light_sleet: 'weather-snow-symbolic',
moderate_or_heavy_sleet: 'weather-snow-symbolic',
patchy_light_snow: 'weather-snow-symbolic',
light_snow: 'weather-snow-symbolic',
patchy_moderate_snow: 'weather-snow-symbolic',
moderate_snow: 'weather-snow-symbolic',
patchy_heavy_snow: 'weather-snow-symbolic',
heavy_snow: 'weather-snow-symbolic',
ice_pellets: 'weather-showers-symbolic',
light_rain_shower: 'weather-showers-symbolic',
moderate_or_heavy_rain_shower: 'weather-showers-symbolic',
torrential_rain_shower: 'weather-showers-symbolic',
light_sleet_showers: 'weather-showers-symbolic',
moderate_or_heavy_sleet_showers: 'weather-showers-symbolic',
light_snow_showers: 'weather-snow-symbolic',
moderate_or_heavy_snow_showers: 'weather-snow-symbolic',
light_showers_of_ice_pellets: 'weather-showers-symbolic',
moderate_or_heavy_showers_of_ice_pellets: 'weather-showers-symbolic',
patchy_light_rain_with_thunder: 'weather-showers-scattered-symbolic',
moderate_or_heavy_rain_with_thunder: 'weather-showers-symbolic',
moderate_or_heavy_rain_in_area_with_thunder: 'weather-showers-symbolic',
patchy_light_snow_with_thunder: 'weather-snow-symbolic',
moderate_or_heavy_snow_with_thunder: 'weather-snow-symbolic',
},
} as const;

View File

@@ -1,199 +0,0 @@
export const substitutes = {
'transmission-gtk': 'transmission',
'blueberry.py': 'blueberry',
Caprine: 'facebook-messenger',
'com.raggesilver.BlackBox-symbolic': 'terminal-symbolic',
'org.wezfurlong.wezterm-symbolic': 'terminal-symbolic',
'audio-headset-bluetooth': 'audio-headphones-symbolic',
'audio-card-analog-usb': 'audio-speakers-symbolic',
'audio-card-analog-pci': 'audio-card-symbolic',
'preferences-system': 'emblem-system-symbolic',
'com.github.Aylur.ags-symbolic': 'controls-symbolic',
'com.github.Aylur.ags': 'controls-symbolic',
};
export default {
missing: 'image-missing-symbolic',
nix: {
nix: 'nix-snowflake-symbolic',
},
app: {
terminal: 'terminal-symbolic',
},
fallback: {
executable: 'application-x-executable',
notification: 'dialog-information-symbolic',
video: 'video-x-generic-symbolic',
audio: 'audio-x-generic-symbolic',
},
ui: {
close: 'window-close-symbolic',
colorpicker: 'color-select-symbolic',
info: 'info-symbolic',
link: 'external-link-symbolic',
lock: 'system-lock-screen-symbolic',
menu: 'open-menu-symbolic',
refresh: 'view-refresh-symbolic',
search: 'system-search-symbolic',
settings: 'emblem-system-symbolic',
themes: 'preferences-desktop-theme-symbolic',
tick: 'object-select-symbolic',
time: 'hourglass-symbolic',
toolbars: 'toolbars-symbolic',
warning: 'dialog-warning-symbolic',
arrow: {
right: 'pan-end-symbolic',
left: 'pan-start-symbolic',
down: 'pan-down-symbolic',
up: 'pan-up-symbolic',
},
},
audio: {
mic: {
muted: 'microphone-disabled-symbolic',
low: 'microphone-sensitivity-low-symbolic',
medium: 'microphone-sensitivity-medium-symbolic',
high: 'microphone-sensitivity-high-symbolic',
},
volume: {
muted: 'audio-volume-muted-symbolic',
low: 'audio-volume-low-symbolic',
medium: 'audio-volume-medium-symbolic',
high: 'audio-volume-high-symbolic',
overamplified: 'audio-volume-overamplified-symbolic',
},
type: {
headset: 'audio-headphones-symbolic',
speaker: 'audio-speakers-symbolic',
card: 'audio-card-symbolic',
},
mixer: 'mixer-symbolic',
},
powerprofile: {
balanced: 'power-profile-balanced-symbolic',
'power-saver': 'power-profile-power-saver-symbolic',
performance: 'power-profile-performance-symbolic',
},
asusctl: {
profile: {
Balanced: 'power-profile-balanced-symbolic',
Quiet: 'power-profile-power-saver-symbolic',
Performance: 'power-profile-performance-symbolic',
},
mode: {
Integrated: 'processor-symbolic',
Hybrid: 'controller-symbolic',
},
},
battery: {
charging: 'battery-flash-symbolic',
warning: 'battery-empty-symbolic',
},
bluetooth: {
enabled: 'bluetooth-active-symbolic',
disabled: 'bluetooth-disabled-symbolic',
},
brightness: {
indicator: 'display-brightness-symbolic',
keyboard: 'keyboard-brightness-symbolic',
screen: 'display-brightness-symbolic',
},
powermenu: {
sleep: 'weather-clear-night-symbolic',
reboot: 'system-reboot-symbolic',
logout: 'system-log-out-symbolic',
shutdown: 'system-shutdown-symbolic',
},
recorder: {
recording: 'media-record-symbolic',
},
notifications: {
noisy: 'org.gnome.Settings-notifications-symbolic',
silent: 'notifications-disabled-symbolic',
message: 'chat-bubbles-symbolic',
},
trash: {
full: 'user-trash-full-symbolic',
empty: 'user-trash-symbolic',
},
mpris: {
shuffle: {
enabled: 'media-playlist-shuffle-symbolic',
disabled: 'media-playlist-consecutive-symbolic',
},
loop: {
none: 'media-playlist-repeat-symbolic',
track: 'media-playlist-repeat-song-symbolic',
playlist: 'media-playlist-repeat-symbolic',
},
playing: 'media-playback-pause-symbolic',
paused: 'media-playback-start-symbolic',
stopped: 'media-playback-start-symbolic',
prev: 'media-skip-backward-symbolic',
next: 'media-skip-forward-symbolic',
},
system: {
cpu: 'org.gnome.SystemMonitor-symbolic',
ram: 'drive-harddisk-solidstate-symbolic',
temp: 'temperature-symbolic',
},
color: {
dark: 'dark-mode-symbolic',
light: 'light-mode-symbolic',
},
weather: {
warning: 'dialog-warning-symbolic',
sunny: 'weather-clear-symbolic',
clear: 'weather-clear-night-symbolic',
partly_cloudy: 'weather-few-clouds-symbolic',
partly_cloudy_night: 'weather-few-clouds-night-symbolic',
cloudy: 'weather-overcast-symbolic',
overcast: 'weather-overcast-symbolic',
mist: 'weather-overcast-symbolic',
patchy_rain_nearby: 'weather-showers-scattered-symbolic',
patchy_rain_possible: 'weather-showers-scattered-symbolic',
patchy_snow_possible: 'weather-snow-symbolic',
patchy_sleet_possible: 'weather-snow-symbolic',
patchy_freezing_drizzle_possible: 'weather-showers-scattered-symbolic',
thundery_outbreaks_possible: 'weather-overcast-symbolic',
blowing_snow: 'weather-snow-symbolic',
blizzard: 'weather-snow-symbolic',
fog: 'weather-fog-symbolic',
freezing_fog: 'weather-fog-symbolic',
patchy_light_drizzle: 'weather-showers-scattered-symbolic',
light_drizzle: 'weather-showers-symbolic',
freezing_drizzle: 'weather-showers-symbolic',
heavy_freezing_drizzle: 'weather-showers-symbolic',
patchy_light_rain: 'weather-showers-scattered-symbolic',
light_rain: 'weather-showers-symbolic',
moderate_rain_at_times: 'weather-showers-symbolic',
moderate_rain: 'weather-showers-symbolic',
heavy_rain_at_times: 'weather-showers-symbolic',
heavy_rain: 'weather-showers-symbolic',
light_freezing_rain: 'weather-showers-symbolic',
moderate_or_heavy_freezing_rain: 'weather-showers-symbolic',
light_sleet: 'weather-snow-symbolic',
moderate_or_heavy_sleet: 'weather-snow-symbolic',
patchy_light_snow: 'weather-snow-symbolic',
light_snow: 'weather-snow-symbolic',
patchy_moderate_snow: 'weather-snow-symbolic',
moderate_snow: 'weather-snow-symbolic',
patchy_heavy_snow: 'weather-snow-symbolic',
heavy_snow: 'weather-snow-symbolic',
ice_pellets: 'weather-showers-symbolic',
light_rain_shower: 'weather-showers-symbolic',
moderate_or_heavy_rain_shower: 'weather-showers-symbolic',
torrential_rain_shower: 'weather-showers-symbolic',
light_sleet_showers: 'weather-showers-symbolic',
moderate_or_heavy_sleet_showers: 'weather-showers-symbolic',
light_snow_showers: 'weather-snow-symbolic',
moderate_or_heavy_snow_showers: 'weather-snow-symbolic',
light_showers_of_ice_pellets: 'weather-showers-symbolic',
moderate_or_heavy_showers_of_ice_pellets: 'weather-showers-symbolic',
patchy_light_rain_with_thunder: 'weather-showers-scattered-symbolic',
moderate_or_heavy_rain_with_thunder: 'weather-showers-symbolic',
moderate_or_heavy_rain_in_area_with_thunder: 'weather-showers-symbolic',
patchy_light_snow_with_thunder: 'weather-snow-symbolic',
moderate_or_heavy_snow_with_thunder: 'weather-snow-symbolic',
},
} as const;

View File

@@ -1,55 +0,0 @@
export const weatherIcons = {
warning: '󰼯',
sunny: '󰖙',
clear: '󰖔',
partly_cloudy: '󰖕',
partly_cloudy_night: '󰼱',
cloudy: '󰖐',
overcast: '󰖕',
mist: '󰖑',
patchy_rain_nearby: '󰼳',
patchy_rain_possible: '󰼳',
patchy_snow_possible: '󰼴',
patchy_sleet_possible: '󰙿',
patchy_freezing_drizzle_possible: '󰙿',
thundery_outbreaks_possible: '󰙾',
blowing_snow: '󰼶',
blizzard: '󰼶',
fog: '󰖑',
freezing_fog: '󰖑',
patchy_light_drizzle: '󰼳',
light_drizzle: '󰼳',
freezing_drizzle: '󰙿',
heavy_freezing_drizzle: '󰙿',
patchy_light_rain: '󰼳',
light_rain: '󰼳',
moderate_rain_at_times: '󰖗',
moderate_rain: '󰼳',
heavy_rain_at_times: '󰖖',
heavy_rain: '󰖖',
light_freezing_rain: '󰙿',
moderate_or_heavy_freezing_rain: '󰙿',
light_sleet: '󰙿',
moderate_or_heavy_sleet: '󰙿',
patchy_light_snow: '󰼴',
light_snow: '󰼴',
patchy_moderate_snow: '󰼴',
moderate_snow: '󰼶',
patchy_heavy_snow: '󰼶',
heavy_snow: '󰼶',
ice_pellets: '󰖒',
light_rain_shower: '󰖖',
moderate_or_heavy_rain_shower: '󰖖',
torrential_rain_shower: '󰖖',
light_sleet_showers: '󰼵',
moderate_or_heavy_sleet_showers: '󰼵',
light_snow_showers: '󰼵',
moderate_or_heavy_snow_showers: '󰼵',
light_showers_of_ice_pellets: '󰖒',
moderate_or_heavy_showers_of_ice_pellets: '󰖒',
patchy_light_rain_with_thunder: '󰙾',
moderate_or_heavy_rain_with_thunder: '󰙾',
moderate_or_heavy_rain_in_area_with_thunder: '󰙾',
patchy_light_snow_with_thunder: '󰼶',
moderate_or_heavy_snow_with_thunder: '󰼶',
} as const;

View File

@@ -1,169 +0,0 @@
import { readFile, writeFile, monitorFile } from 'astal/file';
import { errorHandler, Notify } from '../utils';
import { ensureDirectory } from '../session';
import icons from '../icons/icons';
/**
* Manages configuration file operations including reading, writing, and change monitoring
*
* The ConfigManager centralizes all configuration persistence operations and provides
* utilities for working with nested configuration structures.
*/
export class ConfigManager {
private _configPath: string;
private _changeCallbacks: Array<() => void> = [];
/**
* Creates a new configuration manager for a specific config file
*
* @param configPath - Path to the configuration file to manage
*/
constructor(configPath: string) {
this._configPath = configPath;
this._ensureConfigDirectory();
this._setupConfigMonitor();
}
/**
* Updates a single option in the configuration file
*
* @param id - Dot-notation path of the option to update
* @param value - New value to store for the option
*/
public updateOption(id: string, value: unknown): void {
const config = this.readConfig();
config[id] = value;
this.writeConfig(config);
}
/**
* Retrieves a value from a nested object using a path
*
* @param dataObject - The object to search within
* @param path - Dot-notation path or array of path segments
* @returns The value at the specified path or undefined if not found
*/
public getNestedValue(dataObject: Record<string, unknown>, path: string | string[]): unknown {
const pathArray = typeof path === 'string' ? path.split('.') : path;
return this._findValueByPath(dataObject, pathArray);
}
/**
* Reads the current configuration from disk
*
* @returns The parsed configuration object or an empty object if the file doesn't exist
*/
public readConfig(): Record<string, unknown> {
const raw = readFile(this._configPath);
if (!raw || raw.trim() === '') {
return {};
}
try {
return JSON.parse(raw);
} catch (error) {
this._handleConfigError(error);
return {};
}
}
/**
* Writes configuration to disk
*
* @param config - The configuration object to serialize and save
*/
public writeConfig(config: Record<string, unknown>): void {
writeFile(this._configPath, JSON.stringify(config, null, 2));
}
/**
* Registers a callback to be called when the config file changes
*
* @param callback - Function to execute when config file changes are detected
*/
public onConfigChanged(callback: () => void): void {
this._changeCallbacks.push(callback);
}
/**
* Recursively navigates an object to find a value at the specified path
*
* @param currentObject - The object currently being traversed
* @param pathKeys - Remaining path segments to navigate
* @returns The value at the path or undefined if not found
*/
private _findValueByPath(currentObject: Record<string, unknown>, pathKeys: string[]): unknown {
const currentKey = pathKeys.shift();
if (currentKey === undefined) {
return currentObject;
}
if (!this._isObject(currentObject)) {
return;
}
const propertyPath = [currentKey, ...pathKeys].join('.');
if (propertyPath in currentObject) {
return currentObject[propertyPath];
}
if (!(currentKey in currentObject)) {
return;
}
const currentKeyValue = currentObject[currentKey];
if (!this._isObject(currentKeyValue)) {
return;
}
return this._findValueByPath(currentKeyValue, pathKeys);
}
/**
* Ensures the directory for the config file exists
*/
private _ensureConfigDirectory(): void {
ensureDirectory(this._configPath.split('/').slice(0, -1).join('/'));
}
/**
* Sets up file monitoring to detect external changes to the config file
*/
private _setupConfigMonitor(): void {
const debounceTimeMs = 200;
let lastEventTime = Date.now();
monitorFile(this._configPath, () => {
if (Date.now() - lastEventTime < debounceTimeMs) {
return;
}
lastEventTime = Date.now();
this._notifyConfigChanged();
});
}
/**
* Notifies all registered callbacks about config file changes
*/
private _notifyConfigChanged(): void {
this._changeCallbacks.forEach((callback) => callback());
}
/**
* Handles configuration parsing errors with appropriate logging and notification
*
* @param error - The error that occurred during config parsing
*/
private _handleConfigError(error: unknown): void {
console.error(`Failed to load config file: ${error}`);
Notify({
summary: 'Failed to load config file',
body: `${error}`,
iconName: icons.ui.warning,
});
errorHandler(error);
}
/**
* Type guard that checks if a value is a non-null object
*
* @param value - The value to check
* @returns True if the value is a non-null object
*/
private _isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
}

View File

@@ -0,0 +1,271 @@
import { readFile, writeFile, monitorFile, Gio } from 'astal/file';
import { ensureDirectory } from '../../session';
import icons from '../../icons/icons';
import { errorHandler } from 'src/core/errors/handler';
import { SystemUtilities } from 'src/core/system/SystemUtilities';
/**
* Manages configuration file operations including reading, writing, and change monitoring
*
* Flow:
* 1. Constructor creates config directory and starts file monitoring
* 2. File monitor watches for external changes and triggers callbacks
* 3. When writing config, monitor is canceled and recreated after delay
* 4. External changes are debounced to prevent rapid callbacks
* 5. Callbacks notify all registered listeners when config changes
* 6. getNestedValue allows accessing deeply nested config values with dot notation
*/
export class ConfigManager {
private static readonly _DEBOUNCE_DELAY_MS = 200;
private static readonly _MONITOR_RESTART_DELAY_MS = 300;
private readonly _configPath: string;
private readonly _changeCallbacks: Array<() => void> = [];
private _fileMonitor: Gio.FileMonitor | null = null;
private _lastChangeTime = 0;
/**
* Creates a new configuration manager for a specific config file
*
* @param configPath - Full path to the configuration JSON file
*/
constructor(configPath: string) {
this._configPath = configPath;
this._createConfigDirectory();
this._startConfigMonitoring();
}
/**
* Updates a single option in the configuration file
*
* @param id - The option key to update
* @param value - The new value to set
*/
public updateOption(id: string, value: unknown): void {
const config = this.readConfig();
config[id] = value;
this.writeConfig(config);
}
/**
* Retrieves a value from a nested object using a path
*
* @param dataObject - The object to traverse
* @param path - Dot-notation path (e.g., 'theme.colors.primary') or array of keys
*/
public getNestedValue(dataObject: Record<string, unknown>, path: string | string[]): unknown {
const pathSegments = Array.isArray(path) ? path : path.split('.');
return this._navigateToValue(dataObject, pathSegments);
}
/**
* Reads the current configuration from disk
*/
public readConfig(): Record<string, unknown> {
const fileContent = readFile(this._configPath);
if (this._isEmptyOrMissing(fileContent)) {
return {};
}
return this._parseConfigSafely(fileContent);
}
/**
* Writes configuration to disk
*
* @param config - The configuration object to save
*/
public writeConfig(config: Record<string, unknown>): void {
writeFile(this._configPath, JSON.stringify(config, null, 2));
}
/**
* Registers a callback to be called when the config file changes
*
* @param callback - Function to call when config changes
*/
public onConfigChanged(callback: () => void): void {
this._changeCallbacks.push(callback);
}
private _createConfigDirectory(): void {
const directoryPath = this._getDirectoryPath();
ensureDirectory(directoryPath);
}
/**
* Extracts the directory path from the full config file path
*/
private _getDirectoryPath(): string {
return this._configPath.split('/').slice(0, -1).join('/');
}
/**
* Sets up file monitoring to detect external changes to the config file
*/
private _startConfigMonitoring(): void {
this._createFileMonitor();
this._overrideWriteConfigForMonitoring();
}
/**
* Creates a new file monitor, canceling any existing one first
*
* We must recreate the monitor after writes because the file system
* monitor can become invalid when the file is replaced during write operations
*/
private _createFileMonitor(): void {
this._cleanupExistingMonitor();
this._fileMonitor = monitorFile(this._configPath, () => {
this._handleFileChange();
});
}
private _cleanupExistingMonitor(): void {
if (!this._fileMonitor) return;
try {
this._fileMonitor.cancel();
} catch (error) {
console.debug('Error canceling file monitor:', error);
}
this._fileMonitor = null;
}
/**
* Processes file change events with debouncing to prevent rapid updates
*/
private _handleFileChange(): void {
const now = Date.now();
if (this._shouldIgnoreChange(now)) {
return;
}
this._lastChangeTime = now;
this._notifyAllCallbacks();
}
private _shouldIgnoreChange(currentTime: number): boolean {
return currentTime - this._lastChangeTime < ConfigManager._DEBOUNCE_DELAY_MS;
}
/**
* Wraps writeConfig to automatically restart the file monitor after writes
*
* This ensures we don't miss external changes that occur immediately after
* our own writes, which would otherwise be lost when the monitor is invalidated
*/
private _overrideWriteConfigForMonitoring(): void {
const originalWriteConfig = this.writeConfig.bind(this);
this.writeConfig = (config: Record<string, unknown>): void => {
originalWriteConfig(config);
this._restartMonitoringAfterWrite();
};
}
/**
* Schedules monitor recreation after a write operation
*
* The delay ensures the file system has finished processing the write
* before we attach a new monitor, preventing race conditions
*/
private _restartMonitoringAfterWrite(): void {
setTimeout(() => {
this._createFileMonitor();
}, ConfigManager._MONITOR_RESTART_DELAY_MS);
}
private _isEmptyOrMissing(content: string): boolean {
return !content || content.trim() === '';
}
private _parseConfigSafely(content: string): Record<string, unknown> {
try {
return JSON.parse(content);
} catch (error) {
this._handleParsingError(error);
return {};
}
}
/**
* Recursively navigates an object to find a value at the specified path
*
* @param currentObject - The object to navigate
* @param pathSegments - Array of keys representing the path
*/
private _navigateToValue(currentObject: Record<string, unknown>, pathSegments: string[]): unknown {
if (pathSegments.length === 0) {
return currentObject;
}
if (!this._isValidObject(currentObject)) {
return undefined;
}
const [currentKey, ...remainingPath] = pathSegments;
const fullPath = [currentKey, ...remainingPath].join('.');
if (fullPath in currentObject) {
return currentObject[fullPath];
}
if (!(currentKey in currentObject)) {
return undefined;
}
const nextValue = currentObject[currentKey];
if (!this._isValidObject(nextValue)) {
return undefined;
}
return this._navigateToValue(nextValue, remainingPath);
}
/**
* Notifies all registered callbacks about config file changes
*/
private _notifyAllCallbacks(): void {
this._changeCallbacks.forEach((callback) => {
try {
callback();
} catch (error) {
console.error('Error in config change callback:', error);
}
});
}
/**
* Handles configuration parsing errors with appropriate logging and notification
*
* @param error - The parsing error that occurred
*/
private _handleParsingError(error: unknown): void {
const errorMessage = `Failed to load config file: ${error}`;
console.error(errorMessage);
SystemUtilities.notify({
summary: 'Configuration Error',
body: errorMessage,
iconName: icons.ui.warning,
});
errorHandler(error);
}
/**
* Type guard that checks if a value is a valid object for navigation
*
* @param value - The value to check
*/
private _isValidObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
}

View File

@@ -1,7 +1,7 @@
import { ConfigManager } from './ConfigManager';
import { Opt, OptProps } from './Opt';
import { OptionRegistry } from './OptionRegistry';
import { MkOptionsResult, OptionsObject } from './options.types';
import { ConfigManager } from './configManager';
import { Opt, OptProps } from './opt';
import { OptionRegistry } from './optionRegistry';
import { MkOptionsResult, OptionsObject } from './types';
const CONFIG_PATH = CONFIG_FILE;
@@ -19,7 +19,8 @@ export function opt<T>(initial: T, props?: OptProps): Opt<T> {
*/
export function mkOptions<T extends OptionsObject>(optionsObj: T): T & MkOptionsResult {
const registry = new OptionRegistry(optionsObj, configManager);
return registry.createEnhancedOptions();
}
export { Opt, OptProps, ConfigManager, OptionRegistry };
export { Opt };

View File

@@ -1,19 +1,5 @@
import Variable from 'astal/variable';
import { ConfigManager } from './ConfigManager';
/**
* Properties that can be passed when creating an option
*/
export interface OptProps {
persistent?: boolean;
}
/**
* Options for set operations
*/
export interface WriteOptions {
writeDisk?: boolean;
}
import { ConfigManager } from '../configManager';
/**
* A managed application option with persistence capabilities
@@ -96,3 +82,17 @@ export class Opt<T = unknown> extends Variable<T> {
return currentValue !== initialValue;
}
}
/**
* Properties that can be passed when creating an option
*/
export interface OptProps {
persistent?: boolean;
}
/**
* Options for set operations
*/
interface WriteOptions {
writeDisk?: boolean;
}

View File

@@ -1,7 +1,7 @@
import { Opt } from './Opt';
import { ConfigManager } from './ConfigManager';
import { MkOptionsResult, OptionsObject } from './options.types';
import { errorHandler } from '../utils';
import { Opt } from '../opt';
import { ConfigManager } from '../configManager';
import { MkOptionsResult, OptionsObject } from '../types';
import { errorHandler } from 'src/core/errors/handler';
/**
* Creates and manages a registry of application options
@@ -74,7 +74,11 @@ export class OptionRegistry<T extends OptionsObject> {
}
const oldVal = opt.get();
if (newVal !== oldVal) {
const newValueStringified = JSON.stringify(newVal, null, 2);
const oldValueStringified = JSON.stringify(oldVal, null, 2);
if (newValueStringified !== oldValueStringified) {
opt.set(newVal, { writeDisk: false });
}
}

View File

@@ -1,10 +1,10 @@
import { Astal } from 'astal/gtk3';
import { dropdownMenuList } from '../constants/options';
import { dropdownMenuList } from '../../components/settings/constants';
import { FontStyle } from 'src/components/settings/shared/inputs/font/utils';
import { Variable } from 'astal';
import { defaultColorMap } from '../types/defaults/options.types';
import { LabelSettingProps } from 'src/components/settings/shared/Label';
import { Opt } from './Opt';
import { Opt } from './opt';
import { defaultColorMap } from 'src/services/matugen/defaults';
export interface MkOptionsResult {
toArray: () => Opt[];
@@ -12,17 +12,6 @@ export interface MkOptionsResult {
handler: (optionsToWatch: string[], callback: () => void) => void;
}
export type RecursiveOptionsObject = {
[key: string]:
| RecursiveOptionsObject
| Opt<string>
| Opt<number>
| Opt<boolean>
| Variable<string>
| Variable<number>
| Variable<boolean>;
};
export type OptionsObject = Record<string, unknown>;
export type BarLocation = 'top' | 'bottom';
@@ -62,7 +51,6 @@ export type BarLayouts = {
[key: string]: BarLayout;
};
export type Unit = 'imperial' | 'metric';
export type PowerOptions = 'sleep' | 'reboot' | 'logout' | 'shutdown';
export type NotificationAnchor =
| 'top'
@@ -88,7 +76,7 @@ export type ThemeExportData = {
filePath: string;
themeOnly: boolean;
};
export type InputType =
type InputType =
| 'number'
| 'color'
| 'float'
@@ -266,12 +254,9 @@ export type MatugenVariations =
| 'vivid_3';
export type ColorMapKey = keyof typeof defaultColorMap;
export type ColorMapValue = (typeof defaultColorMap)[ColorMapKey];
export type ScalingPriority = 'gdk' | 'hyprland' | 'both';
export type BluetoothBatteryState = 'paired' | 'connected' | 'always';
export type BorderLocation =
| 'none'
| 'top'

32
src/lib/path/helpers.ts Normal file
View File

@@ -0,0 +1,32 @@
import { GLib } from 'astal';
/**
* Normalize a path to the absolute representation of the path
* Note: This will only expand '~' if present. Path traversal is not supported
* @param path - The path to normalize
* @returns The normalized path
*/
export function normalizeToAbsolutePath(path: string): string {
if (path.charAt(0) === '~') {
return path.replace('~', GLib.get_home_dir());
}
return path;
}
/**
* Gets the home directory path
* @returns The home directory path
*/
export function getHomeDir(): string {
return GLib.get_home_dir();
}
/**
* Joins path segments
* @param segments - Path segments to join
* @returns The joined path
*/
export function joinPath(...segments: string[]): string {
return segments.join('/').replace(/\/+/g, '/');
}

View File

@@ -1,7 +1,7 @@
import { GenericFunction } from '../types/customModules/generic.types';
import { BarModule } from '../options/options.types';
import { BarModule } from '../options/types';
import { Poller } from './Poller';
import { Binding, execAsync, Variable } from 'astal';
import { GenericFunction } from './types';
/**
* A class that manages polling of a variable by executing a bash command at specified intervals.

View File

@@ -1,7 +1,7 @@
import { GenericFunction } from '../types/customModules/generic.types';
import { BarModule } from '../options/options.types';
import { BarModule } from '../options/types';
import { Poller } from './Poller';
import { Binding, Variable } from 'astal';
import { GenericFunction } from './types';
/**
* A class that manages polling of a variable by executing a generic function at specified intervals.

View File

@@ -1,7 +1,7 @@
import { getLayoutItems } from 'src/lib/utils';
import { AstalIO, Binding, interval, Variable } from 'astal';
import options from 'src/options';
import { BarModule } from '../options/options.types';
import options from 'src/configuration';
import { BarModule } from '../options/types';
import { getLayoutItems } from '../bar/helpers';
const { layouts } = options.bar;

3
src/lib/poller/types.ts Normal file
View File

@@ -0,0 +1,3 @@
export type GenericFunction<Value, Parameters extends unknown[]> = (
...args: Parameters
) => Promise<Value> | Value;

View File

@@ -16,7 +16,7 @@ export function ensureDirectory(path: string): void {
}
}
export function ensureJsonFile(path: string): void {
function ensureJsonFile(path: string): void {
const file = Gio.File.new_for_path(path);
const parent = file.get_parent();
@@ -30,7 +30,7 @@ export function ensureJsonFile(path: string): void {
}
}
export function ensureFile(path: string): void {
function ensureFile(path: string): void {
const file = Gio.File.new_for_path(path);
const parent = file.get_parent();

View File

@@ -1,6 +1,6 @@
import { Gdk } from 'astal/gtk3';
import { ThrottleFn } from '../types/utils.types';
import { GtkWidget } from '../types/widget.types';
import { GtkWidget } from 'src/components/bar/types';
import { ThrottleFn } from './types';
/**
* Connects a primary click handler and returns a disconnect function.

View File

@@ -0,0 +1,9 @@
import { Variable } from 'astal';
import { EventArgs } from 'src/components/bar/utils/input/types';
export type ThrottleFn = (
cmd: string,
args: EventArgs,
fn?: (output: string) => void,
postInputUpdated?: Variable<boolean>,
) => void;

View File

@@ -1,35 +0,0 @@
import AstalMpris from 'gi://AstalMpris?version=0.1';
const mprisService = AstalMpris.get_default();
export const getCurrentPlayer = (
activePlayer: AstalMpris.Player = mprisService.get_players()[0],
): AstalMpris.Player => {
const statusOrder = {
[AstalMpris.PlaybackStatus.PLAYING]: 1,
[AstalMpris.PlaybackStatus.PAUSED]: 2,
[AstalMpris.PlaybackStatus.STOPPED]: 3,
};
const mprisPlayers = mprisService.get_players();
if (mprisPlayers.length === 0) {
return mprisPlayers[0];
}
const isPlaying = mprisPlayers.some(
(p: AstalMpris.Player) => p.playbackStatus === AstalMpris.PlaybackStatus.PLAYING,
);
const playerStillExists = mprisPlayers.some((p) => activePlayer.bus_name === p.bus_name);
const nextPlayerUp = mprisPlayers.sort(
(a: AstalMpris.Player, b: AstalMpris.Player) =>
statusOrder[a.playbackStatus] - statusOrder[b.playbackStatus],
)[0];
if (isPlaying || !playerStillExists) {
return nextPlayerUp;
}
return activePlayer;
};

View File

@@ -1,21 +0,0 @@
import AstalNotifd from 'gi://AstalNotifd?version=0.1';
const normalizeName = (name: string): string => name.toLowerCase().replace(/\s+/g, '_');
export const isNotificationIgnored = (notification: AstalNotifd.Notification, filter: string[]): boolean => {
const notificationFilters = new Set(filter.map(normalizeName));
const normalizedAppName = normalizeName(notification.app_name);
return notificationFilters.has(normalizedAppName);
};
export const filterNotifications = (
notifications: AstalNotifd.Notification[],
filter: string[],
): AstalNotifd.Notification[] => {
const filteredNotifications = notifications.filter((notif: AstalNotifd.Notification) => {
return !isNotificationIgnored(notif, filter);
});
return filteredNotifications;
};

View File

@@ -0,0 +1,65 @@
import AstalNotifd from 'gi://AstalNotifd?version=0.1';
import { Variable } from 'astal';
import { iconExists } from 'src/lib/icons/helpers';
import icons from 'src/lib/icons/icons';
const normalizeName = (name: string): string => name.toLowerCase().replace(/\s+/g, '_');
export const removingNotifications = Variable(false);
export const isNotificationIgnored = (
notification: AstalNotifd.Notification | null,
filter: string[],
): boolean => {
if (!notification) {
return false;
}
const notificationFilters = new Set(filter.map(normalizeName));
const normalizedAppName = normalizeName(notification.app_name);
return notificationFilters.has(normalizedAppName);
};
export const filterNotifications = (
notifications: AstalNotifd.Notification[],
filter: string[],
): AstalNotifd.Notification[] => {
const filteredNotifications = notifications.filter((notif: AstalNotifd.Notification) => {
return !isNotificationIgnored(notif, filter);
});
return filteredNotifications;
};
export const getNotificationIcon = (app_name: string, app_icon: string, app_entry: string): string => {
const icon = icons.fallback.notification;
if (iconExists(app_name)) {
return app_name;
} else if (app_name && iconExists(app_name.toLowerCase())) {
return app_name.toLowerCase();
}
if (app_icon && iconExists(app_icon)) {
return app_icon;
}
if (app_entry && iconExists(app_entry)) {
return app_entry;
}
return icon;
};
export const clearNotifications = async (
notifications: AstalNotifd.Notification[],
delay: number,
): Promise<void> => {
removingNotifications.set(true);
for (const notification of notifications) {
notification.dismiss();
await new Promise((resolve) => setTimeout(resolve, delay));
}
removingNotifications.set(false);
};

View File

@@ -0,0 +1,92 @@
/**
* Capitalizes the first letter of a string
* @param str - The string to capitalize
* @returns The input string with the first letter capitalized
*/
export function capitalizeFirstLetter(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
/**
* Converts a string to camelCase
* @param str - The string to convert
* @returns The camelCase version of the string
*/
export function toCamelCase(str: string): string {
return str
.replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : ''))
.replace(/^(.)/, (char) => char.toLowerCase());
}
/**
* Converts a string to kebab-case
* @param str - The string to convert
* @returns The kebab-case version of the string
*/
export function toKebabCase(str: string): string {
return str
.replace(/([a-z])([A-Z])/g, '$1-$2')
.replace(/[\s_]+/g, '-')
.toLowerCase();
}
/**
* Converts a string to Title Case
* @param str - The string to convert
* @returns The Title Case version of the string
*/
export function toTitleCase(str: string): string {
return str
.toLowerCase()
.replace(/(?:^|\s|-|_)\w/g, (match) => match.toUpperCase())
.replace(/[-_]/g, ' ');
}
/**
* Converts a string to snake_case
* @param str - The string to convert
* @returns The snake_case version of the string
*/
export function toSnakeCase(str: string): string {
return str
.replace(/([a-z])([A-Z])/g, '$1_$2')
.replace(/[-\s]+/g, '_')
.toLowerCase();
}
/**
* Converts a string to PascalCase
* @param str - The string to convert
* @returns The PascalCase version of the string
*/
export function toPascalCase(str: string): string {
return str
.replace(/[-_\s]+(.)?/g, (_, char) => (char ? char.toUpperCase() : ''))
.replace(/^(.)/, (char) => char.toUpperCase());
}
/**
* Converts a string to CONSTANT_CASE
* @param str - The string to convert
* @returns The CONSTANT_CASE version of the string
*/
export function toConstantCase(str: string): string {
return str
.replace(/([a-z])([A-Z])/g, '$1_$2')
.replace(/[-\s]+/g, '_')
.toUpperCase();
}
/**
* Converts a string to sentence case
* @param str - The string to convert
* @returns The sentence case version of the string
*/
export function toSentenceCase(str: string): string {
return str
.toLowerCase()
.replace(/[-_]/g, ' ')
.replace(/^\w/, (char) => char.toUpperCase())
.replace(/\s+/g, ' ')
.trim();
}

40
src/lib/theme/useTheme.ts Normal file
View File

@@ -0,0 +1,40 @@
import Gio from 'gi://Gio';
import {
filterConfigForThemeOnly,
loadJsonFile,
saveConfigToFile,
} from '../../components/settings/shared/FileChooser';
import options from 'src/configuration';
import { errorHandler } from 'src/core/errors/handler';
import { SystemUtilities } from 'src/core/system/SystemUtilities';
const { restartCommand } = options.hyprpanel;
export const hexColorPattern = /^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
export function useTheme(filePath: string): void {
try {
const importedConfig = loadJsonFile(filePath);
if (!importedConfig) {
return;
}
const optionsConfigFile = Gio.File.new_for_path(CONFIG_FILE);
const [optionsSuccess, optionsContent] = optionsConfigFile.load_contents(null);
if (!optionsSuccess) {
throw new Error('Failed to load theme file.');
}
let optionsConfig = JSON.parse(new TextDecoder('utf-8').decode(optionsContent));
const filteredConfig = filterConfigForThemeOnly(importedConfig);
optionsConfig = { ...optionsConfig, ...filteredConfig };
saveConfigToFile(optionsConfig, CONFIG_FILE);
SystemUtilities.bash(restartCommand.get());
} catch (error) {
errorHandler(error);
}
}

View File

@@ -1,8 +0,0 @@
export type MediaTags = {
title: string;
artists: string;
artist: string;
album: string;
name: string;
identity: string;
};

View File

@@ -1,41 +0,0 @@
import { Widget } from 'astal/gtk3';
import { Binding, Variable } from 'astal';
import { Connectable } from 'astal/binding';
import { BoxWidget } from './widget.types';
import { Label } from 'astal/gtk3/widget';
export type BarBoxChild = {
component: JSX.Element;
isVisible?: boolean;
isVis?: Binding<boolean>;
isBox?: boolean;
boxClass: string;
tooltip_text?: string | Binding<string>;
} & ({ isBox: true; props: Widget.EventBoxProps } | { isBox?: false; props: Widget.ButtonProps });
export type BoxHook = (self: BoxWidget) => void;
export type LabelHook = (self: Label) => void;
export type BarModuleProps = {
icon?: string | Binding<string>;
textIcon?: string | Binding<string>;
useTextIcon?: Binding<boolean>;
label?: string | Binding<string>;
truncationSize?: Binding<number>;
labelHook?: LabelHook;
boundLabel?: string;
tooltipText?: string | Binding<string>;
boxClass: string;
isVis?: Binding<boolean>;
props?: Widget.ButtonProps;
showLabel?: boolean;
showLabelBinding?: Binding<boolean>;
showIconBinding?: Binding<boolean>;
hook?: BoxHook;
connection?: Binding<Connectable>;
};
export type ResourceLabelType = 'used/total' | 'used' | 'percentage' | 'free';
export type NetstatLabelType = 'full' | 'in' | 'out';
export type RateUnit = 'GiB' | 'MiB' | 'KiB' | 'auto';

View File

@@ -1,5 +0,0 @@
export type BatteryIconKeys = 0 | 10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100;
export type BatteryIcons = {
[key in BatteryIconKeys]: string;
};

View File

@@ -1 +0,0 @@
export type BarToggleStates = Record<string, boolean | undefined>;

View File

@@ -1,20 +0,0 @@
export type GenericFunction<Value, Parameters extends unknown[]> = (...args: Parameters) => Promise<Value> | Value;
export type GenericResourceMetrics = {
total: number;
used: number;
percentage: number;
};
export type GenericResourceData = GenericResourceMetrics & {
free: number;
};
export type Postfix = 'TiB' | 'GiB' | 'MiB' | 'KiB' | 'B';
export type UpdateHandlers = {
disconnectPrimary: () => void;
disconnectSecondary: () => void;
disconnectMiddle: () => void;
disconnectScroll: () => void;
};

View File

@@ -1,27 +0,0 @@
export type KbLabelType = 'layout' | 'code';
export type HyprctlKeyboard = {
address: string;
name: string;
rules: string;
model: string;
layout: string;
variant: string;
options: string;
active_keymap: string;
main: boolean;
};
export type HyprctlMouse = {
address: string;
name: string;
defaultSpeed: number;
};
export type HyprctlDeviceLayout = {
mice: HyprctlMouse[];
keyboards: HyprctlKeyboard[];
tablets: unknown[];
touch: unknown[];
switches: unknown[];
};

View File

@@ -1,4 +0,0 @@
export type NetworkResourceData = {
in: string;
out: string;
};

View File

@@ -1,22 +0,0 @@
import { Variable } from 'astal';
import { EventArgs } from '../widget.types';
import { Opt } from 'src/lib/options';
export type InputHandlerEventArgs = {
cmd?: Opt<string> | Variable<string>;
fn?: (output: string) => void;
};
export type InputHandlerEvents = {
onPrimaryClick?: InputHandlerEventArgs;
onSecondaryClick?: InputHandlerEventArgs;
onMiddleClick?: InputHandlerEventArgs;
onScrollUp?: InputHandlerEventArgs;
onScrollDown?: InputHandlerEventArgs;
};
export type RunAsyncCommand = (
cmd: string,
args: EventArgs,
fn?: (output: string) => void,
postInputUpdater?: Variable<boolean>,
) => void;

View File

@@ -1,17 +0,0 @@
import { Variable } from 'astal';
export type ShortcutFixed = {
tooltip: string;
command: string;
icon: string;
configurable: false;
};
export type ShortcutVariable = {
tooltip: Variable<string>;
command: Variable<string>;
icon: Variable<string>;
configurable?: true;
};
export type Shortcut = ShortcutFixed | ShortcutVariable;

View File

@@ -1,15 +0,0 @@
import { Astal } from 'astal/gtk3';
import { NetstatLabelType, ResourceLabelType } from '../bar.types';
import { BarLocation } from '../../options/options.types';
export const LABEL_TYPES: ResourceLabelType[] = ['used/total', 'used', 'free', 'percentage'];
export const NETWORK_LABEL_TYPES: NetstatLabelType[] = ['full', 'in', 'out'];
type LocationMap = {
[key in BarLocation]: Astal.WindowAnchor;
};
export const locationMap: LocationMap = {
top: Astal.WindowAnchor.TOP,
bottom: Astal.WindowAnchor.BOTTOM,
};

View File

@@ -1,10 +0,0 @@
import { RateUnit } from '../bar.types';
import { NetworkResourceData } from '../customModules/network.types';
export const getDefaultNetstatData = (dataType: RateUnit): NetworkResourceData => {
if (dataType === 'auto') {
return { in: `0 Kib/s`, out: `0 Kib/s` };
}
return { in: `0 ${dataType}/s`, out: `0 ${dataType}/s` };
};

View File

@@ -1,60 +0,0 @@
export const defaultColorMap = {
rosewater: '#f5e0dc',
flamingo: '#f2cdcd',
pink: '#f5c2e7',
mauve: '#cba6f7',
red: '#f38ba8',
maroon: '#eba0ac',
peach: '#fab387',
yellow: '#f9e2af',
green: '#a6e3a1',
teal: '#94e2d5',
sky: '#89dceb',
sapphire: '#74c7ec',
blue: '#89b4fa',
lavender: '#b4befe',
text: '#cdd6f4',
subtext1: '#bac2de',
subtext2: '#a6adc8',
overlay2: '#9399b2',
overlay1: '#7f849c',
overlay0: '#6c7086',
surface2: '#585b70',
surface1: '#45475a',
surface0: '#313244',
base2: '#242438',
base: '#1e1e2e',
mantle: '#181825',
crust: '#11111b',
surface1_2: '#454759',
text2: '#cdd6f3',
pink2: '#f5c2e6',
red2: '#f38ba7',
peach2: '#fab386',
mantle2: '#181824',
surface0_2: '#313243',
surface2_2: '#585b69',
overlay1_2: '#7f849b',
lavender2: '#b4befd',
mauve2: '#cba6f6',
green2: '#a6e3a0',
sky2: '#89dcea',
teal2: '#94e2d4',
yellow2: '#f9e2ad',
maroon2: '#eba0ab',
crust2: '#11111a',
pink3: '#f5c2e8',
red3: '#f38ba9',
mantle3: '#181826',
surface0_3: '#313245',
surface2_3: '#585b71',
overlay1_3: '#7f849d',
lavender3: '#b4beff',
mauve3: '#cba6f8',
green3: '#a6e3a2',
sky3: '#89dcec',
teal3: '#94e2d6',
yellow3: '#f9e2ae',
maroon3: '#eba0ad',
crust3: '#11111c',
} as const;

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +0,0 @@
import { Astal, Gtk } from 'astal/gtk3';
import { Binding } from 'astal';
import { WindowProps } from 'astal/gtk3/widget';
export interface DropdownMenuProps {
name: string;
child?: JSX.Element | JSX.Element[] | Binding<JSX.Element | undefined>;
layout?: string;
transition?: Gtk.RevealerTransitionType | Binding<Gtk.RevealerTransitionType>;
exclusivity?: Astal.Exclusivity;
fixed?: boolean;
onDestroy?: () => void;
}

View File

@@ -1,3 +0,0 @@
export type Config = {
[key: string]: string | number | boolean | object;
};

View File

@@ -1,27 +0,0 @@
import { Process } from 'astal';
export type GPUStatProcess = {
username: string;
command: string;
full_command: string[];
gpu_memory_usage: number;
cpu_percent: number;
cpu_memory_usage: number;
pid: number;
};
export type GPUStat = {
index: number;
uuid: string;
name: string;
'temperature.gpu': number;
'fan.speed': number;
'utilization.gpu': number;
'utilization.enc': number;
'utilization.dec': number;
'power.draw': number;
'enforced.power.limit': number;
'memory.used': number;
'memory.total': number;
processes: Process[];
};

View File

@@ -1,5 +0,0 @@
import AstalMpris from 'gi://AstalMpris?version=0.1';
export type PlaybackIconMap = {
[key in AstalMpris.PlaybackStatus]: string;
};

View File

@@ -1,12 +0,0 @@
export type AccessPoint = {
bssid: string | null;
address: string | null;
lastSeen: number;
ssid: string | null;
active: boolean;
strength: number;
frequency: number;
iconName: string | undefined;
};
export type WifiIcon = '󰤩' | '󰤨' | '󰤪' | '󰤨' | '󰤩' | '󰤮' | '󰤨' | '󰤥' | '󰤢' | '󰤟' | '󰤯';

View File

@@ -1,15 +0,0 @@
import icons from 'src/lib/icons/icons2';
export interface NotificationArgs {
appName?: string;
body?: string;
iconName?: string;
id?: number;
summary?: string;
urgency?: string;
category?: string;
timeout?: number;
transient?: boolean;
}
export type NotificationIcon = keyof typeof icons.notifications;

View File

@@ -1,51 +0,0 @@
import { Astal, Gtk } from 'astal/gtk3';
import { Binding } from 'astal';
import { WindowProps } from 'astal/gtk3/widget';
export interface PopupWindowProps extends WindowProps {
name: string;
child?: JSX.Element;
layout?: Layouts;
transition?: Gtk.RevealerTransitionType | Binding<Gtk.RevealerTransitionType>;
exclusivity?: Astal.Exclusivity;
}
export type LayoutFunction = (
name: string,
child: JSX.Element,
transition: Gtk.RevealerTransitionType | Binding<Gtk.RevealerTransitionType>,
) => {
center: () => JSX.Element;
top: () => JSX.Element;
'top-right': () => JSX.Element;
'top-center': () => JSX.Element;
'top-left': () => JSX.Element;
'bottom-left': () => JSX.Element;
'bottom-center': () => JSX.Element;
'bottom-right': () => JSX.Element;
};
export type Layouts =
| 'center'
| 'top'
| 'top-right'
| 'top-center'
| 'top-left'
| 'bottom-left'
| 'bottom-center'
| 'bottom-right';
export type Opts = {
className: string;
vexpand: boolean;
};
export type PaddingProps = {
name: string;
opts?: Opts;
};
export type PopupRevealerProps = {
name: string;
child: JSX.Element;
transition: Gtk.RevealerTransitionType | Binding<Gtk.RevealerTransitionType>;
};

View File

@@ -1 +0,0 @@
export type Action = 'sleep' | 'reboot' | 'logout' | 'shutdown';

View File

@@ -1 +0,0 @@
export type ProfileType = 'balanced' | 'power-saver' | 'performance';

View File

@@ -1,6 +0,0 @@
export type SystrayIconMap = {
[key: string]: {
icon: string;
color: string;
};
};

View File

@@ -1,13 +0,0 @@
import { Variable } from 'astal';
import { EventArgs } from './widget.types';
export type ThrottleFn = (
cmd: string,
args: EventArgs,
fn?: (output: string) => void,
postInputUpdated?: Variable<boolean>,
) => void;
export type ThrottleFnCallback = ((output: string) => void) | undefined;
export type Primitive = string | number | boolean | symbol | null | undefined | bigint;

View File

@@ -1,118 +0,0 @@
import { weatherIcons } from 'src/lib/icons/weather';
export type UnitType = 'imperial' | 'metric';
export type Weather = {
location: Location;
current: Current;
forecast: Forecast;
};
export type Current = {
last_updated_epoch?: number;
last_updated?: string;
temp_c: number;
temp_f: number;
is_day: number;
condition: Condition;
wind_mph: number;
wind_kph: number;
wind_degree: number;
wind_dir: string;
pressure_mb: number;
pressure_in: number;
precip_mm: number;
precip_in: number;
humidity: number;
cloud: number;
feelslike_c: number;
feelslike_f: number;
windchill_c: number;
windchill_f: number;
heatindex_c: number;
heatindex_f: number;
dewpoint_c: number;
dewpoint_f: number;
vis_km: number;
vis_miles: number;
uv: number;
gust_mph: number;
gust_kph: number;
time_epoch?: number;
time?: string;
snow_cm?: number;
will_it_rain?: number;
chance_of_rain?: number;
will_it_snow?: number;
chance_of_snow?: number;
};
export type Condition = {
text: string;
icon: string;
code: number;
};
export type Forecast = {
forecastday: Forecastday[];
};
export type Forecastday = {
date: string;
date_epoch: number;
day: Day;
astro: Astro;
hour: Current[];
};
export type Astro = {
sunrise: string;
sunset: string;
moonrise: string;
moonset: string;
moon_phase: string;
moon_illumination: number;
is_moon_up: number;
is_sun_up: number;
};
export type Day = {
maxtemp_c: number;
maxtemp_f: number;
mintemp_c: number;
mintemp_f: number;
avgtemp_c: number;
avgtemp_f: number;
maxwind_mph: number;
maxwind_kph: number;
totalprecip_mm: number;
totalprecip_in: number;
totalsnow_cm: number;
avgvis_km: number;
avgvis_miles: number;
avghumidity: number;
daily_will_it_rain: number;
daily_chance_of_rain: number;
daily_will_it_snow: number;
daily_chance_of_snow: number;
condition: Condition;
uv: number;
};
export type Location = {
name: string;
region: string;
country: string;
lat: number;
lon: number;
tz_id: string;
localtime_epoch: number;
localtime: string;
};
export type TemperatureIconColorMap = {
[key: number]: string;
};
export type WeatherIconTitle = keyof typeof weatherIcons;
export type WeatherIcon = (typeof weatherIcons)[WeatherIconTitle];

View File

@@ -1,48 +0,0 @@
import { Binding } from 'astal';
import { Astal, Gdk, Gtk } from 'astal/gtk3';
export type Anchor = 'left' | 'right' | 'top' | 'down';
export type Transition = 'none' | 'crossfade' | 'slide_right' | 'slide_left' | 'slide_up' | 'slide_down';
export type Layouts =
| 'center'
| 'top'
| 'top-right'
| 'top-center'
| 'top-left'
| 'bottom-left'
| 'bottom-center'
| 'bottom-right';
export type Attribute = unknown;
export type Child = Gtk.Widget;
export type BoxWidget = Gtk.Box;
export type GdkEvent = Gdk.Event;
export type EventHandler<Self> = (self: Self, event: Gdk.Event) => boolean | unknown;
export type EventArgs = {
clicked: GtkWidget;
event: Gdk.Event;
};
export interface WidgetProps {
onPrimaryClick?: (clicked: GtkWidget, event: Gdk.EventButton) => void;
onSecondaryClick?: (clicked: GtkWidget, event: Gdk.EventButton) => void;
onMiddleClick?: (clicked: GtkWidget, event: Gdk.EventButton) => void;
onScrollUp?: (clicked: GtkWidget, event: Gdk.EventScroll) => void;
onScrollDown?: (clicked: GtkWidget, event: Gdk.EventScroll) => void;
setup?: (self: GtkWidget) => void;
}
export interface GtkWidgetExtended extends Gtk.Widget {
props?: WidgetProps;
component?: JSX.Element;
primaryClick?: (clicked: GtkWidget, event: Astal.ClickEvent) => void;
isVisible?: boolean;
boxClass?: string;
isVis?: {
bind: (key: string) => Binding<boolean>;
};
}
export type GtkWidget = GtkWidgetExtended;

View File

@@ -1,36 +0,0 @@
export type WorkspaceRule = {
workspaceString: string;
monitor: string;
};
export type WorkspaceMonitorMap = {
[key: string]: number[];
};
export type MonitorMap = {
[key: number]: string;
};
export type ApplicationIcons = {
[key: string]: string;
};
export type WorkspaceIcons = {
[key: string]: string;
};
export type AppIconOptions = {
iconMap: ApplicationIcons;
defaultIcon: string;
emptyIcon: string;
};
export type ClientAttributes = [className: string, title: string];
export type WorkspaceIconsColored = {
[key: string]: {
color: string;
icon: string;
};
};
export type WorkspaceIconMap = WorkspaceIcons | WorkspaceIconsColored;

View File

@@ -0,0 +1,196 @@
import { LengthUnit } from './types';
export class LengthConverter {
private readonly _value: number;
private readonly _unit: LengthUnit;
private static readonly _TO_METERS: Record<LengthUnit, number> = {
mm: 0.001,
cm: 0.01,
m: 1,
km: 1000,
in: 0.0254,
ft: 0.3048,
mi: 1609.344,
};
private constructor(value: number, unit: LengthUnit) {
this._value = value;
this._unit = unit;
}
/**
* Creates a converter from millimeters
* @param value - Value in millimeters
*/
public static fromMm(value: number): LengthConverter {
return new LengthConverter(value, 'mm');
}
/**
* Creates a converter from centimeters
* @param value - Value in centimeters
*/
public static fromCm(value: number): LengthConverter {
return new LengthConverter(value, 'cm');
}
/**
* Creates a converter from meters
* @param value - Value in meters
*/
public static fromMeters(value: number): LengthConverter {
return new LengthConverter(value, 'm');
}
/**
* Creates a converter from kilometers
* @param value - Value in kilometers
*/
public static fromKm(value: number): LengthConverter {
return new LengthConverter(value, 'km');
}
/**
* Creates a converter from inches
* @param value - Value in inches
*/
public static fromInches(value: number): LengthConverter {
return new LengthConverter(value, 'in');
}
/**
* Creates a converter from feet
* @param value - Value in feet
*/
public static fromFeet(value: number): LengthConverter {
return new LengthConverter(value, 'ft');
}
/**
* Creates a converter from miles
* @param value - Value in miles
*/
public static fromMiles(value: number): LengthConverter {
return new LengthConverter(value, 'mi');
}
/**
* Converts to meters (base unit)
*/
private _toBaseUnit(): number {
return this._value * LengthConverter._TO_METERS[this._unit];
}
/**
* Converts from meters to target unit
*/
private _fromBaseUnit(targetUnit: LengthUnit): number {
return this._toBaseUnit() / LengthConverter._TO_METERS[targetUnit];
}
/**
* Converts to millimeters
*/
public toMm(): number {
return this._fromBaseUnit('mm');
}
/**
* Converts to centimeters
*/
public toCm(): number {
return this._fromBaseUnit('cm');
}
/**
* Converts to meters
*/
public toMeters(): number {
return this._toBaseUnit();
}
/**
* Converts to kilometers
*/
public toKm(): number {
return this._fromBaseUnit('km');
}
/**
* Converts to inches
*/
public toInches(): number {
return this._fromBaseUnit('in');
}
/**
* Converts to feet
*/
public toFeet(): number {
return this._fromBaseUnit('ft');
}
/**
* Converts to miles
*/
public toMiles(): number {
return this._fromBaseUnit('mi');
}
/**
* Formats to millimeters
* @param precision - Number of decimal places
*/
public formatMm(precision = 0): string {
return `${this.toMm().toFixed(precision)} mm`;
}
/**
* Formats to centimeters
* @param precision - Number of decimal places
*/
public formatCm(precision = 1): string {
return `${this.toCm().toFixed(precision)} cm`;
}
/**
* Formats to meters
* @param precision - Number of decimal places
*/
public formatMeters(precision = 2): string {
return `${this.toMeters().toFixed(precision)} m`;
}
/**
* Formats to kilometers
* @param precision - Number of decimal places
*/
public formatKm(precision = 1): string {
return `${this.toKm().toFixed(precision)} km`;
}
/**
* Formats to inches
* @param precision - Number of decimal places
*/
public formatInches(precision = 1): string {
return `${this.toInches().toFixed(precision)} in`;
}
/**
* Formats to feet
* @param precision - Number of decimal places
*/
public formatFeet(precision = 0): string {
return `${this.toFeet().toFixed(precision)} ft`;
}
/**
* Formats to miles
* @param precision - Number of decimal places
*/
public formatMiles(precision = 1): string {
return `${this.toMiles().toFixed(precision)} mi`;
}
}

View File

@@ -0,0 +1 @@
export type LengthUnit = 'mm' | 'cm' | 'm' | 'km' | 'in' | 'ft' | 'mi';

View File

@@ -0,0 +1,148 @@
import { PressureUnit } from './types';
export class PressureConverter {
private readonly _value: number;
private readonly _unit: PressureUnit;
private static readonly _TO_PA: Record<PressureUnit, number> = {
pa: 1,
hpa: 100,
mb: 100,
inHg: 3386.39,
psi: 6894.76,
};
private constructor(value: number, unit: PressureUnit) {
this._value = value;
this._unit = unit;
}
/**
* Creates a converter from pascals
* @param value - Value in pascals
*/
public static fromPa(value: number): PressureConverter {
return new PressureConverter(value, 'pa');
}
/**
* Creates a converter from hectopascals
* @param value - Value in hectopascals
*/
public static fromHPa(value: number): PressureConverter {
return new PressureConverter(value, 'hpa');
}
/**
* Creates a converter from millibars
* @param value - Value in millibars
*/
public static fromMb(value: number): PressureConverter {
return new PressureConverter(value, 'mb');
}
/**
* Creates a converter from inches of mercury
* @param value - Value in inches of mercury
*/
public static fromInHg(value: number): PressureConverter {
return new PressureConverter(value, 'inHg');
}
/**
* Creates a converter from pounds per square inch
* @param value - Value in PSI
*/
public static fromPsi(value: number): PressureConverter {
return new PressureConverter(value, 'psi');
}
/**
* Converts to pascals (base unit)
*/
private _toBaseUnit(): number {
return this._value * PressureConverter._TO_PA[this._unit];
}
/**
* Converts from pascals to target unit
*/
private _fromBaseUnit(targetUnit: PressureUnit): number {
return this._toBaseUnit() / PressureConverter._TO_PA[targetUnit];
}
/**
* Converts to pascals
*/
public toPa(): number {
return this._toBaseUnit();
}
/**
* Converts to hectopascals
*/
public toHPa(): number {
return this._fromBaseUnit('hpa');
}
/**
* Converts to millibars
*/
public toMb(): number {
return this._fromBaseUnit('mb');
}
/**
* Converts to inches of mercury
*/
public toInHg(): number {
return this._fromBaseUnit('inHg');
}
/**
* Converts to pounds per square inch
*/
public toPsi(): number {
return this._fromBaseUnit('psi');
}
/**
* Formats to pascals
* @param precision - Number of decimal places
*/
public formatPa(precision = 0): string {
return `${this.toPa().toFixed(precision)} Pa`;
}
/**
* Formats to hectopascals
* @param precision - Number of decimal places
*/
public formatHPa(precision = 0): string {
return `${this.toHPa().toFixed(precision)} hPa`;
}
/**
* Formats to millibars
* @param precision - Number of decimal places
*/
public formatMb(precision = 0): string {
return `${this.toMb().toFixed(precision)} mb`;
}
/**
* Formats to inches of mercury
* @param precision - Number of decimal places
*/
public formatInHg(precision = 2): string {
return `${this.toInHg().toFixed(precision)} inHg`;
}
/**
* Formats to pounds per square inch
* @param precision - Number of decimal places
*/
public formatPsi(precision = 1): string {
return `${this.toPsi().toFixed(precision)} PSI`;
}
}

View File

@@ -0,0 +1 @@
export type PressureUnit = 'pa' | 'hpa' | 'mb' | 'inHg' | 'psi';

225
src/lib/units/size/index.ts Normal file
View File

@@ -0,0 +1,225 @@
import { SizeUnit } from './types';
export class SizeConverter {
private readonly _value: number;
private readonly _unit: SizeUnit;
private constructor(value: number, unit: SizeUnit) {
this._value = value;
this._unit = unit;
}
/**
* Creates a converter from bytes
* @param value - Size in bytes
*/
public static fromBytes(value: number): SizeConverter {
return new SizeConverter(value, 'bytes');
}
/**
* Creates a converter from kibibytes
* @param value - Size in KiB
*/
public static fromKiB(value: number): SizeConverter {
return new SizeConverter(value, 'kibibytes');
}
/**
* Creates a converter from mebibytes
* @param value - Size in MiB
*/
public static fromMiB(value: number): SizeConverter {
return new SizeConverter(value, 'mebibytes');
}
/**
* Creates a converter from gibibytes
* @param value - Size in GiB
*/
public static fromGiB(value: number): SizeConverter {
return new SizeConverter(value, 'gibibytes');
}
/**
* Creates a converter from tebibytes
* @param value - Size in TiB
*/
public static fromTiB(value: number): SizeConverter {
return new SizeConverter(value, 'tebibytes');
}
/**
* Converts the size to bytes (base unit)
*/
private _toBaseUnit(): number {
switch (this._unit) {
case 'bytes':
return this._value;
case 'kibibytes':
return this._value * 1024;
case 'mebibytes':
return this._value * 1024 ** 2;
case 'gibibytes':
return this._value * 1024 ** 3;
case 'tebibytes':
return this._value * 1024 ** 4;
}
}
/**
* Converts to bytes
* @param precision - Number of decimal places (optional)
*/
public toBytes(precision?: number): number {
const value = this._toBaseUnit();
return precision !== undefined ? Number(value.toFixed(precision)) : value;
}
/**
* Converts to kibibytes
* @param precision - Number of decimal places (optional)
*/
public toKiB(precision?: number): number {
const bytes = this._toBaseUnit();
const value = bytes / 1024;
return precision !== undefined ? Number(value.toFixed(precision)) : value;
}
/**
* Converts to mebibytes
* @param precision - Number of decimal places (optional)
*/
public toMiB(precision?: number): number {
const bytes = this._toBaseUnit();
const value = bytes / 1024 ** 2;
return precision !== undefined ? Number(value.toFixed(precision)) : value;
}
/**
* Converts to gibibytes
* @param precision - Number of decimal places (optional)
*/
public toGiB(precision?: number): number {
const bytes = this._toBaseUnit();
const value = bytes / 1024 ** 3;
return precision !== undefined ? Number(value.toFixed(precision)) : value;
}
/**
* Converts to tebibytes
* @param precision - Number of decimal places (optional)
*/
public toTiB(precision?: number): number {
const bytes = this._toBaseUnit();
const value = bytes / 1024 ** 4;
return precision !== undefined ? Number(value.toFixed(precision)) : value;
}
/**
* Automatically converts to the most appropriate unit
* @param precision - Number of decimal places (optional)
*/
public toAuto(precision?: number): { value: number; unit: SizeUnit } {
const bytes = this._toBaseUnit();
if (bytes >= 1024 ** 4) {
return { value: this.toTiB(precision), unit: 'tebibytes' };
}
if (bytes >= 1024 ** 3) {
return { value: this.toGiB(precision), unit: 'gibibytes' };
}
if (bytes >= 1024 ** 2) {
return { value: this.toMiB(precision), unit: 'mebibytes' };
}
if (bytes >= 1024) {
return { value: this.toKiB(precision), unit: 'kibibytes' };
}
return { value: this.toBytes(precision), unit: 'bytes' };
}
/**
* Formats the size with a specific unit and precision
* @param unit - Target unit
* @param precision - Number of decimal places (default: 2)
*/
public format(unit: SizeUnit, precision = 2): string {
let value: number;
let symbol: string;
switch (unit) {
case 'bytes':
value = this.toBytes();
symbol = 'B';
break;
case 'kibibytes':
value = this.toKiB();
symbol = 'KiB';
break;
case 'mebibytes':
value = this.toMiB();
symbol = 'MiB';
break;
case 'gibibytes':
value = this.toGiB();
symbol = 'GiB';
break;
case 'tebibytes':
value = this.toTiB();
symbol = 'TiB';
break;
}
return `${value.toFixed(precision)} ${symbol}`;
}
/**
* Formats to bytes
* @param precision - Number of decimal places
*/
public formatBytes(precision = 0): string {
return this.format('bytes', precision);
}
/**
* Formats to kibibytes
* @param precision - Number of decimal places
*/
public formatKiB(precision = 2): string {
return this.format('kibibytes', precision);
}
/**
* Formats to mebibytes
* @param precision - Number of decimal places
*/
public formatMiB(precision = 2): string {
return this.format('mebibytes', precision);
}
/**
* Formats to gibibytes
* @param precision - Number of decimal places
*/
public formatGiB(precision = 2): string {
return this.format('gibibytes', precision);
}
/**
* Formats to tebibytes
* @param precision - Number of decimal places
*/
public formatTiB(precision = 2): string {
return this.format('tebibytes', precision);
}
/**
* Automatically formats to the most appropriate unit
* @param precision - Number of decimal places
*/
public formatAuto(precision = 2): string {
const { unit } = this.toAuto();
return this.format(unit, precision);
}
}

View File

@@ -0,0 +1 @@
export type SizeUnit = 'bytes' | 'kibibytes' | 'mebibytes' | 'gibibytes' | 'tebibytes';

View File

@@ -0,0 +1,131 @@
import { SpeedUnit } from './types';
export class SpeedConverter {
private readonly _value: number;
private readonly _unit: SpeedUnit;
private static readonly _TO_MPS: Record<SpeedUnit, number> = {
mps: 1,
kph: 0.277778,
mph: 0.44704,
knots: 0.514444,
};
private static readonly _LABELS: Record<SpeedUnit, string> = {
mps: 'm/s',
kph: 'km/h',
mph: 'mph',
knots: 'kn',
};
private constructor(value: number, unit: SpeedUnit) {
this._value = value;
this._unit = unit;
}
/**
* Creates a converter from meters per second
* @param value - Value in m/s
*/
public static fromMps(value: number): SpeedConverter {
return new SpeedConverter(value, 'mps');
}
/**
* Creates a converter from kilometers per hour
* @param value - Value in km/h
*/
public static fromKph(value: number): SpeedConverter {
return new SpeedConverter(value, 'kph');
}
/**
* Creates a converter from miles per hour
* @param value - Value in mph
*/
public static fromMph(value: number): SpeedConverter {
return new SpeedConverter(value, 'mph');
}
/**
* Creates a converter from knots
* @param value - Value in knots
*/
public static fromKnots(value: number): SpeedConverter {
return new SpeedConverter(value, 'knots');
}
/**
* Converts to m/s (base unit)
*/
private _toBaseUnit(): number {
return this._value * SpeedConverter._TO_MPS[this._unit];
}
/**
* Converts from m/s to target unit
*/
private _fromBaseUnit(targetUnit: SpeedUnit): number {
return this._toBaseUnit() / SpeedConverter._TO_MPS[targetUnit];
}
/**
* Converts to meters per second
*/
public toMps(): number {
return this._toBaseUnit();
}
/**
* Converts to kilometers per hour
*/
public toKph(): number {
return this._fromBaseUnit('kph');
}
/**
* Converts to miles per hour
*/
public toMph(): number {
return this._fromBaseUnit('mph');
}
/**
* Converts to knots
*/
public toKnots(): number {
return this._fromBaseUnit('knots');
}
/**
* Formats to meters per second
* @param precision - Number of decimal places
*/
public formatMps(precision = 1): string {
return `${this.toMps().toFixed(precision)} ${SpeedConverter._LABELS.mps}`;
}
/**
* Formats to kilometers per hour
* @param precision - Number of decimal places
*/
public formatKph(precision = 0): string {
return `${this.toKph().toFixed(precision)} ${SpeedConverter._LABELS.kph}`;
}
/**
* Formats to miles per hour
* @param precision - Number of decimal places
*/
public formatMph(precision = 0): string {
return `${this.toMph().toFixed(precision)} ${SpeedConverter._LABELS.mph}`;
}
/**
* Formats to knots
* @param precision - Number of decimal places
*/
public formatKnots(precision = 0): string {
return `${this.toKnots().toFixed(precision)} ${SpeedConverter._LABELS.knots}`;
}
}

View File

@@ -0,0 +1 @@
export type SpeedUnit = 'mps' | 'kph' | 'mph' | 'knots';

View File

@@ -0,0 +1,129 @@
import { TemperatureUnit } from './types';
export class TemperatureConverter {
private readonly _value: number;
private readonly _unit: TemperatureUnit;
private constructor(value: number, unit: TemperatureUnit) {
this._value = value;
this._unit = unit;
}
/**
* Creates a converter from Celsius
* @param value - Temperature in Celsius
*/
public static fromCelsius(value: number): TemperatureConverter {
return new TemperatureConverter(value, 'celsius');
}
/**
* Creates a converter from Fahrenheit
* @param value - Temperature in Fahrenheit
*/
public static fromFahrenheit(value: number): TemperatureConverter {
return new TemperatureConverter(value, 'fahrenheit');
}
/**
* Creates a converter from Kelvin
* @param value - Temperature in Kelvin
*/
public static fromKelvin(value: number): TemperatureConverter {
return new TemperatureConverter(value, 'kelvin');
}
/**
* Converts the temperature to Celsius (base unit)
*/
private _toBaseUnit(): number {
switch (this._unit) {
case 'celsius':
return this._value;
case 'fahrenheit':
return ((this._value - 32) * 5) / 9;
case 'kelvin':
return this._value - 273.15;
}
}
/**
* Converts to Celsius
* @param precision - Number of decimal places (optional)
*/
public toCelsius(precision?: number): number {
const value = this._toBaseUnit();
return precision !== undefined ? Number(value.toFixed(precision)) : value;
}
/**
* Converts to Fahrenheit
* @param precision - Number of decimal places (optional)
*/
public toFahrenheit(precision?: number): number {
const celsius = this._toBaseUnit();
const value = (celsius * 9) / 5 + 32;
return precision !== undefined ? Number(value.toFixed(precision)) : value;
}
/**
* Converts to Kelvin
* @param precision - Number of decimal places (optional)
*/
public toKelvin(precision?: number): number {
const celsius = this._toBaseUnit();
const value = celsius + 273.15;
return precision !== undefined ? Number(value.toFixed(precision)) : value;
}
/**
* Formats the temperature with a specific unit and precision
* @param unit - Target unit
* @param precision - Number of decimal places (default: 0)
*/
public format(unit: TemperatureUnit, precision = 0): string {
let value: number;
let symbol: string;
switch (unit) {
case 'celsius':
value = this.toCelsius();
symbol = '° C';
break;
case 'fahrenheit':
value = this.toFahrenheit();
symbol = '° F';
break;
case 'kelvin':
value = this.toKelvin();
symbol = ' K';
break;
}
return `${value.toFixed(precision)}${symbol}`;
}
/**
* Formats to Celsius
* @param precision - Number of decimal places
*/
public formatCelsius(precision = 0): string {
return this.format('celsius', precision);
}
/**
* Formats to Fahrenheit
* @param precision - Number of decimal places
*/
public formatFahrenheit(precision = 0): string {
return this.format('fahrenheit', precision);
}
/**
* Formats to Kelvin
* @param precision - Number of decimal places
*/
public formatKelvin(precision = 0): string {
return this.format('kelvin', precision);
}
}

View File

@@ -0,0 +1,2 @@
export type UnitType = 'imperial' | 'metric';
export type TemperatureUnit = 'celsius' | 'fahrenheit' | 'kelvin';

View File

@@ -0,0 +1,6 @@
import { GLib, Variable } from 'astal';
export const systemTime = Variable(GLib.DateTime.new_now_local()).poll(
1000,
(): GLib.DateTime => GLib.DateTime.new_now_local(),
);

View File

@@ -1,425 +0,0 @@
import { BarModule, NotificationAnchor, PositionAnchor } from './options/options.types';
import { OSDAnchor } from './options/options.types';
import icons from './icons/icons';
import GLib from 'gi://GLib?version=2.0';
import GdkPixbuf from 'gi://GdkPixbuf';
import { NotificationArgs } from './types/notification.types';
import { namedColors } from './constants/colors';
import { distroIcons } from './constants/distro';
import { distro } from './variables';
import options from '../options';
import { Astal, Gdk, Gtk } from 'astal/gtk3';
import { exec, execAsync } from 'astal/process';
import AstalNotifd from 'gi://AstalNotifd?version=0.1';
import { Primitive } from './types/utils.types';
const notifdService = AstalNotifd.get_default();
/**
* Checks if a value is a primitive type.
*
* @param value - The value to check
* @returns True if the value is a primitive (null, undefined, string, number, boolean, symbol, or bigint)
*/
export function isPrimitive(value: unknown): value is Primitive {
return value === null || (typeof value !== 'object' && typeof value !== 'function');
}
/**
* Handles errors by throwing a new Error with a message.
*
* This function takes an error object and throws a new Error with the provided message or a default message.
* If the error is an instance of Error, it uses the error's message. Otherwise, it converts the error to a string.
*
* @param error The error to handle.
*
* @throws Throws a new error with the provided message or a default message.
*/
export function errorHandler(error: unknown): never {
if (error instanceof Error) {
throw new Error(error.message);
}
throw new Error(String(error));
}
/**
* Looks up an icon by name and size.
*
* This function retrieves an icon from the default icon theme based on the provided name and size.
* If the name is not provided, it returns null.
*
* @param name The name of the icon to look up.
* @param size The size of the icon to look up. Defaults to 16.
*
* @returns The Gtk.IconInfo object if the icon is found, or null if not found.
*/
export function lookUpIcon(name?: string, size = 16): Gtk.IconInfo | null {
if (name === undefined) return null;
return Gtk.IconTheme.get_default().lookup_icon(name, size, Gtk.IconLookupFlags.USE_BUILTIN);
}
/**
* Retrieves all unique layout items from the bar options.
*
* This function extracts all unique layout items from the bar options defined in the `options` object.
* It iterates through the layouts for each monitor and collects items from the left, middle, and right sections.
*
* @returns An array of unique layout items.
*/
export function getLayoutItems(): BarModule[] {
const { layouts } = options.bar;
const itemsInLayout: BarModule[] = [];
Object.keys(layouts.get()).forEach((monitor) => {
const leftItems = layouts.get()[monitor].left;
const rightItems = layouts.get()[monitor].right;
const middleItems = layouts.get()[monitor].middle;
itemsInLayout.push(...leftItems);
itemsInLayout.push(...middleItems);
itemsInLayout.push(...rightItems);
});
return [...new Set(itemsInLayout)];
}
/**
* Executes a bash command asynchronously.
*
* This function runs a bash command using `execAsync` and returns the output as a string.
* It handles errors by logging them and returning an empty string.
*
* @param strings The command to execute as a template string or a regular string.
* @param values Additional values to interpolate into the command.
*
* @returns A promise that resolves to the command output as a string.
*/
export async function bash(strings: TemplateStringsArray | string, ...values: unknown[]): Promise<string> {
const stringsIsString = typeof strings === 'string';
const cmd = stringsIsString ? strings : strings.flatMap((str, i) => str + `${values[i] ?? ''}`).join('');
return execAsync(['bash', '-c', cmd]).catch((err) => {
console.error(cmd, err);
return '';
});
}
/**
* Executes a shell command asynchronously.
*
* This function runs a shell command using `execAsync` and returns the output as a string.
* It handles errors by logging them and returning an empty string.
*
* @param cmd The command to execute as a string or an array of strings.
*
* @returns A promise that resolves to the command output as a string.
*/
export async function sh(cmd: string | string[]): Promise<string> {
return execAsync(cmd).catch((err) => {
console.error(typeof cmd === 'string' ? cmd : cmd.join(' '), err);
return '';
});
}
/**
* Generates an array of JSX elements for each monitor.
*
* 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.
*/
export async function forMonitors(widget: (monitor: number) => Promise<JSX.Element>): Promise<JSX.Element[]> {
const n = Gdk.Display.get_default()?.get_n_monitors() ?? 1;
return Promise.all(range(n, 0).map(widget));
}
/**
* Generates an array of numbers within a specified range.
*
* This function creates an array of numbers starting from the `start` value up to the specified `length`.
*
* @param length The length of the array to generate.
* @param start The starting value of the range. Defaults to 1.
*
* @returns An array of numbers within the specified range.
*/
export function range(length: number, start = 1): number[] {
return Array.from({ length }, (_, i) => i + start);
}
/**
* Checks if all specified dependencies are available.
*
* This function verifies the presence of the specified binaries using the `which` command.
* It logs a warning and sends a notification if any dependencies are missing.
*
* @param bins The list of binaries to check.
*
* @returns True if all dependencies are found, false otherwise.
*/
export function dependencies(...bins: string[]): boolean {
const missing = bins.filter((bin) => {
try {
exec(`which ${bin}`);
return false;
} catch (e) {
console.error(e);
return true;
}
});
if (missing.length > 0) {
console.warn(Error(`missing dependencies: ${missing.join(', ')}`));
Notify({
summary: 'Dependencies not found!',
body: `The following dependencies are missing: ${missing.join(', ')}`,
iconName: icons.ui.warning,
});
}
return missing.length === 0;
}
/**
* Checks if the provided filepath is a valid image.
*
* This function attempts to load an image from the specified filepath using GdkPixbuf.
* If the image is successfully loaded, it returns true. Otherwise, it logs an error and returns false.
*
* Note: Unlike GdkPixbuf, this function will normalize the given path.
*
* @param imgFilePath The path to the image file.
*
* @returns True if the filepath is a valid image, false otherwise.
*/
export function isAnImage(imgFilePath: string): boolean {
try {
GdkPixbuf.Pixbuf.new_from_file(normalizePath(imgFilePath));
return true;
} catch (error) {
console.info(error);
return false;
}
}
/**
* Normalize a path to the absolute representation of the path.
*
* Note: This will only expand '~' if present. Path traversal is not supported.
*
* @param path The path to normalize.
*
* @returns The normalized path.
*/
export function normalizePath(path: string): string {
if (path.charAt(0) == '~') {
// Replace will only replace the first match, in this case, the first character
return path.replace('~', GLib.get_home_dir());
}
return path;
}
/**
* Sends a notification using the `notify-send` command.
*
* This function constructs a notification command based on the provided notification arguments and executes it asynchronously.
* It logs an error if the notification fails to send.
*
* @param notifPayload The notification arguments containing summary, body, appName, iconName, urgency, timeout, category, transient, and id.
*/
export function Notify(notifPayload: NotificationArgs): void {
// This line does nothing useful at runtime, but when bundling, it
// ensures that notifdService has been instantiated and, as such,
// that the notification daemon is active and the notification
// will be handled
notifdService; // eslint-disable-line @typescript-eslint/no-unused-expressions
let command = 'notify-send';
command += ` "${notifPayload.summary} "`;
if (notifPayload.body !== undefined) command += ` "${notifPayload.body}" `;
if (notifPayload.appName !== undefined) command += ` -a "${notifPayload.appName}"`;
if (notifPayload.iconName !== undefined) command += ` -i "${notifPayload.iconName}"`;
if (notifPayload.urgency !== undefined) command += ` -u "${notifPayload.urgency}"`;
if (notifPayload.timeout !== undefined) command += ` -t ${notifPayload.timeout}`;
if (notifPayload.category !== undefined) command += ` -c "${notifPayload.category}"`;
if (notifPayload.transient !== undefined) command += ' -e';
if (notifPayload.id !== undefined) command += ` -r ${notifPayload.id}`;
execAsync(command)
.then()
.catch((err) => {
console.error(`Failed to send notification: ${err.message}`);
});
}
/**
* Maps a notification or OSD anchor position to an Astal window anchor.
*
* This function converts a position anchor from the notification or OSD settings to the corresponding Astal window anchor.
*
* @param pos The position anchor to convert.
*
* @returns The corresponding Astal window anchor.
*/
export function getPosition(pos: NotificationAnchor | OSDAnchor): Astal.WindowAnchor {
const positionMap: PositionAnchor = {
top: Astal.WindowAnchor.TOP,
'top right': Astal.WindowAnchor.TOP | Astal.WindowAnchor.RIGHT,
'top left': Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT,
bottom: Astal.WindowAnchor.BOTTOM,
'bottom right': Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.RIGHT,
'bottom left': Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.LEFT,
right: Astal.WindowAnchor.RIGHT,
left: Astal.WindowAnchor.LEFT,
};
return positionMap[pos] ?? Astal.WindowAnchor.TOP;
}
/**
* Validates if a string is a valid GJS color.
*
* This function checks if the provided string is a valid color in GJS.
* It supports named colors, hex colors, RGB, and RGBA formats.
*
* @param color The color string to validate.
*
* @returns True if the color is valid, false otherwise.
*/
export function isValidGjsColor(color: string): boolean {
const colorLower = color.toLowerCase().trim();
if (namedColors.has(colorLower)) {
return true;
}
const hexColorRegex = /^#(?:[a-fA-F0-9]{3,4}|[a-fA-F0-9]{6,8})$/;
const rgbRegex = /^rgb\(\s*(\d{1,3}%?\s*,\s*){2}\d{1,3}%?\s*\)$/;
const rgbaRegex = /^rgba\(\s*(\d{1,3}%?\s*,\s*){3}(0|1|0?\.\d+)\s*\)$/;
if (hexColorRegex.test(color)) {
return true;
}
if (rgbRegex.test(colorLower) || rgbaRegex.test(colorLower)) {
return true;
}
return false;
}
/**
* Capitalizes the first letter of a string.
*
* This function takes a string and returns a new string with the first letter capitalized.
*
* @param str The string to capitalize.
*
* @returns The input string with the first letter capitalized.
*/
export function capitalizeFirstLetter(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
/**
* Retrieves the icon for the current distribution.
*
* This function returns the icon associated with the current distribution based on the `distroIcons` array.
* If no icon is found, it returns a default icon.
*
* @returns The icon for the current distribution as a string.
*/
export function getDistroIcon(): string {
const icon = distroIcons.find(([id]) => id === distro.id);
return icon ? icon[1] : ''; // default icon if not found
}
/**
* Checks if an event is a primary click.
*
* This function determines if the provided event is a primary click based on the button property.
*
* @param event The click event to check.
*
* @returns True if the event is a primary click, false otherwise.
*/
export const isPrimaryClick = (event: Astal.ClickEvent): boolean => event.button === Gdk.BUTTON_PRIMARY;
/**
* Checks if an event is a secondary click.
*
* This function determines if the provided event is a secondary click based on the button property.
*
* @param event The click event to check.
*
* @returns True if the event is a secondary click, false otherwise.
*/
export const isSecondaryClick = (event: Astal.ClickEvent): boolean => event.button === Gdk.BUTTON_SECONDARY;
/**
* Checks if an event is a middle click.
*
* This function determines if the provided event is a middle click based on the button property.
*
* @param event The click event to check.
*
* @returns True if the event is a middle click, false otherwise.
*/
export const isMiddleClick = (event: Astal.ClickEvent): boolean => event.button === Gdk.BUTTON_MIDDLE;
/**
* Checks if an event is a scroll up.
*
* This function determines if the provided event is a scroll up based on the direction property.
*
* @param event The scroll event to check.
*
* @returns True if the event is a scroll up, false otherwise.
*/
export const isScrollUp = (event: Gdk.Event): boolean => {
const [directionSuccess, direction] = event.get_scroll_direction();
const [deltaSuccess, , yScroll] = event.get_scroll_deltas();
if (directionSuccess && direction === Gdk.ScrollDirection.UP) {
return true;
}
if (deltaSuccess && yScroll < 0) {
return true;
}
return false;
};
/**
* Checks if an event is a scroll down.
*
* This function determines if the provided event is a scroll down based on the direction property.
*
* @param event The scroll event to check.
*
* @returns True if the event is a scroll down, false otherwise.
*/
export const isScrollDown = (event: Gdk.Event): boolean => {
const [directionSuccess, direction] = event.get_scroll_direction();
const [deltaSuccess, , yScroll] = event.get_scroll_deltas();
if (directionSuccess && direction === Gdk.ScrollDirection.DOWN) {
return true;
}
if (deltaSuccess && yScroll > 0) {
return true;
}
return false;
};

View File

@@ -0,0 +1,34 @@
import { namedColors } from './colorNames';
import { HexColor } from '../options/types';
/**
* Validates if a string is a valid GJS color
* Supports named colors, hex colors, RGB, and RGBA formats
* @param color - The color string to validate
* @returns True if the color is valid, false otherwise
*/
export function isValidGjsColor(color: string): boolean {
const colorLower = color.toLowerCase().trim();
if (namedColors.has(colorLower)) {
return true;
}
const hexColorRegex = /^#(?:[a-fA-F0-9]{3,4}|[a-fA-F0-9]{6,8})$/;
const rgbRegex = /^rgb\(\s*(\d{1,3}%?\s*,\s*){2}\d{1,3}%?\s*\)$/;
const rgbaRegex = /^rgba\(\s*(\d{1,3}%?\s*,\s*){3}(0|1|0?\.\d+)\s*\)$/;
if (hexColorRegex.test(color)) {
return true;
}
if (rgbRegex.test(colorLower) || rgbaRegex.test(colorLower)) {
return true;
}
return false;
}
export const isHexColor = (val: unknown): val is HexColor => {
return typeof val === 'string' && /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(val);
};

View File

@@ -0,0 +1,18 @@
import GdkPixbuf from 'gi://GdkPixbuf';
import { normalizeToAbsolutePath } from '../path/helpers';
/**
* Checks if the provided filepath is a valid image
* Note: Unlike GdkPixbuf, this function will normalize the given path
* @param imgFilePath - The path to the image file
* @returns True if the filepath is a valid image, false otherwise
*/
export function isAnImage(imgFilePath: string): boolean {
try {
GdkPixbuf.Pixbuf.new_from_file(normalizeToAbsolutePath(imgFilePath));
return true;
} catch (error) {
console.info(error);
return false;
}
}

View File

@@ -0,0 +1,10 @@
export type Primitive = string | number | boolean | symbol | null | undefined | bigint;
/**
* Checks if a value is a primitive type
* @param value - The value to check
* @returns True if the value is a primitive (null, undefined, string, number, boolean, symbol, or bigint)
*/
export function isPrimitive(value: unknown): value is Primitive {
return value === null || (typeof value !== 'object' && typeof value !== 'function');
}

View File

@@ -1,18 +0,0 @@
import { Variable } from 'astal';
import GLib from 'gi://GLib';
export const clock = Variable(GLib.DateTime.new_now_local()).poll(
1000,
(): GLib.DateTime => GLib.DateTime.new_now_local(),
);
export const uptime = Variable(0).poll(
60_00,
'cat /proc/uptime',
(line): number => Number.parseInt(line.split('.')[0]) / 60,
);
export const distro = {
id: GLib.get_os_info('ID'),
logo: GLib.get_os_info('LOGO'),
};

View File

@@ -0,0 +1,22 @@
import { Astal } from 'astal/gtk3';
import { NotificationAnchor, OSDAnchor, PositionAnchor } from 'src/lib/options/types';
/**
* Maps a notification or OSD anchor position to an Astal window anchor
* @param pos - The position anchor to convert
* @returns The corresponding Astal window anchor
*/
export function getPosition(pos: NotificationAnchor | OSDAnchor): Astal.WindowAnchor {
const positionMap: PositionAnchor = {
top: Astal.WindowAnchor.TOP,
'top right': Astal.WindowAnchor.TOP | Astal.WindowAnchor.RIGHT,
'top left': Astal.WindowAnchor.TOP | Astal.WindowAnchor.LEFT,
bottom: Astal.WindowAnchor.BOTTOM,
'bottom right': Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.RIGHT,
'bottom left': Astal.WindowAnchor.BOTTOM | Astal.WindowAnchor.LEFT,
right: Astal.WindowAnchor.RIGHT,
left: Astal.WindowAnchor.LEFT,
};
return positionMap[pos] ?? Astal.WindowAnchor.TOP;
}

View File

@@ -0,0 +1,14 @@
import { App } from 'astal/gtk3';
import { Variable } from 'astal';
export function isWindowVisible(windowName: string): boolean {
const appWindow = App.get_window(windowName);
if (appWindow === undefined || appWindow === null) {
throw new Error(`Window with name "${windowName}" not found.`);
}
return appWindow.visible;
}
export const idleInhibit = Variable(false);