users/ryan/modules/simple-bar/simple-bar-source/lib/utils.js
6c2bdcffebb4a0851d0f0288aa8cbe9c443b6860
· 25.5 KB · 828 lines
raw
| 1 | import * as Uebersicht from "uebersicht"; |
| 2 | import * as Settings from "./settings"; |
| 3 | |
| 4 | /** |
| 5 | * Parses a JSON string and returns the corresponding JavaScript object. |
| 6 | * Cleans up JSON that contains escape sequences, such as `\\` or `\"`. |
| 7 | * |
| 8 | * @param {string} json - The JSON string to parse. |
| 9 | * @returns {Object|undefined} The parsed JavaScript object, or undefined if parsing fails. |
| 10 | */ |
| 11 | export function parseJson(json) { |
| 12 | try { |
| 13 | // clean up JSON with escape sequences, (i.e.) |
| 14 | // empty arrays: [,] -> [] |
| 15 | // escape sequences: \\" -> " |
| 16 | let cleanedJson = json |
| 17 | .replace(/\[,+/g, "[") |
| 18 | .replace(/,+\]/g, "]") |
| 19 | .replace(/,+,/g, ",") |
| 20 | .replace(/\[,/g, "[") |
| 21 | .replace(/,\]/g, "]") |
| 22 | .replace(/\\/g, "\\\\") |
| 23 | .replace(/\\"/g, '"'); |
| 24 | return JSON.parse(cleanedJson); |
| 25 | } catch (error) { |
| 26 | // eslint-disable-next-line no-console |
| 27 | console.error(error, json); |
| 28 | return undefined; |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | /*! |
| 33 | Copyright (c) 2017 Jed Watson. |
| 34 | Licensed under the MIT License (MIT), see |
| 35 | http://jedwatson.github.io/classnames |
| 36 | */ |
| 37 | const hasOwn = {}.hasOwnProperty; |
| 38 | |
| 39 | /** |
| 40 | * Combines multiple class names into a single string. |
| 41 | * |
| 42 | * @param {...(string|boolean|undefined|null)} arguments - The class names to combine. |
| 43 | * Each argument can be a string, boolean, undefined, or null. Only truthy values will be included. |
| 44 | * @returns {string} The combined class names. |
| 45 | */ |
| 46 | export function classNames() { |
| 47 | let classes = ""; |
| 48 | |
| 49 | for (let i = 0; i < arguments.length; i++) { |
| 50 | const arg = arguments[i]; |
| 51 | if (arg) { |
| 52 | classes = appendClass(classes, parseValue(arg)); |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | return classes; |
| 57 | } |
| 58 | |
| 59 | function parseValue(arg) { |
| 60 | if (typeof arg === "string" || typeof arg === "number") { |
| 61 | return arg; |
| 62 | } |
| 63 | if (typeof arg !== "object") { |
| 64 | return ""; |
| 65 | } |
| 66 | if (Array.isArray(arg)) { |
| 67 | return classNames.apply(null, arg); |
| 68 | } |
| 69 | if ( |
| 70 | arg.toString !== Object.prototype.toString && |
| 71 | !arg.toString.toString().includes("[native code]") |
| 72 | ) { |
| 73 | return arg.toString(); |
| 74 | } |
| 75 | |
| 76 | let classes = ""; |
| 77 | for (let key in arg) { |
| 78 | if (hasOwn.call(arg, key) && arg[key]) { |
| 79 | classes = appendClass(classes, key); |
| 80 | } |
| 81 | } |
| 82 | return classes; |
| 83 | } |
| 84 | |
| 85 | function appendClass(value, newClass) { |
| 86 | if (!newClass) { |
| 87 | return value; |
| 88 | } |
| 89 | if (value) { |
| 90 | return value + " " + newClass; |
| 91 | } |
| 92 | return value + newClass; |
| 93 | } |
| 94 | /*! |
| 95 | End of Jed Watson's classNames |
| 96 | */ |
| 97 | |
| 98 | const WIDTH = 20; |
| 99 | const DURATION = 320; |
| 100 | |
| 101 | const CACHE_PREFIX = "sb_cmd_"; |
| 102 | const CACHE_CLEANUP_INTERVAL = 300000; // Clean up every 5 minutes |
| 103 | let _lastCleanup = 0; |
| 104 | |
| 105 | /** |
| 106 | * Simple string hash for generating cache keys from command strings. |
| 107 | * @param {string} str - The string to hash. |
| 108 | * @returns {string} A base-36 hash string. |
| 109 | */ |
| 110 | function hashString(str) { |
| 111 | let hash = 0; |
| 112 | for (let i = 0; i < str.length; i++) { |
| 113 | const char = str.charCodeAt(i); |
| 114 | hash = (hash << 5) - hash + char; |
| 115 | hash |= 0; |
| 116 | } |
| 117 | return Math.abs(hash).toString(36); |
| 118 | } |
| 119 | |
| 120 | /** |
| 121 | * Removes expired cache entries from localStorage to prevent unbounded growth. |
| 122 | * Uses a conservative 60-second grace period beyond the longest widget refresh. |
| 123 | */ |
| 124 | function cleanupExpiredCache() { |
| 125 | const now = Date.now(); |
| 126 | if (now - _lastCleanup < CACHE_CLEANUP_INTERVAL) return; |
| 127 | _lastCleanup = now; |
| 128 | |
| 129 | try { |
| 130 | const keysToRemove = []; |
| 131 | for (let i = 0; i < localStorage.length; i++) { |
| 132 | const key = localStorage.key(i); |
| 133 | if (key && key.startsWith(CACHE_PREFIX)) { |
| 134 | try { |
| 135 | const cached = localStorage.getItem(key); |
| 136 | if (cached) { |
| 137 | const parsed = JSON.parse(cached); |
| 138 | // Remove if older than 60 seconds (well beyond any widget refresh) |
| 139 | if (now - parsed.t > 60000) { |
| 140 | keysToRemove.push(key); |
| 141 | } |
| 142 | } |
| 143 | } catch { |
| 144 | // Remove malformed entries |
| 145 | keysToRemove.push(key); |
| 146 | } |
| 147 | } |
| 148 | } |
| 149 | keysToRemove.forEach((key) => localStorage.removeItem(key)); |
| 150 | } catch { |
| 151 | // Ignore cleanup errors |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | /** |
| 156 | * Runs a shell command via Uebersicht.run() with cross-display caching. |
| 157 | * |
| 158 | * In multi-display setups, each display runs its own simple-bar instance. |
| 159 | * System-wide data (CPU, memory, battery, etc.) is identical across displays, |
| 160 | * so this function uses localStorage (shared across all Übersicht WebViews) |
| 161 | * to cache command results and prevent redundant execution. |
| 162 | * |
| 163 | * Expired cache entries are automatically cleaned up every 5 minutes. |
| 164 | * |
| 165 | * @param {string} command - The shell command to execute. |
| 166 | * @param {number} cacheTimeout - How long (ms) the cached result remains valid. |
| 167 | * Typically set to the widget's refresh frequency so that within one refresh |
| 168 | * cycle, only one display actually executes the command. |
| 169 | * @returns {Promise<string>} The command output (from cache or fresh execution). |
| 170 | */ |
| 171 | export async function cachedRun(command, cacheTimeout) { |
| 172 | if (!cacheTimeout || cacheTimeout <= 0) { |
| 173 | return Uebersicht.run(command); |
| 174 | } |
| 175 | |
| 176 | cleanupExpiredCache(); |
| 177 | |
| 178 | const cacheKey = CACHE_PREFIX + hashString(command); |
| 179 | const now = Date.now(); |
| 180 | |
| 181 | try { |
| 182 | const cached = localStorage.getItem(cacheKey); |
| 183 | if (cached) { |
| 184 | const parsed = JSON.parse(cached); |
| 185 | if (now - parsed.t < cacheTimeout) { |
| 186 | return parsed.r; |
| 187 | } |
| 188 | } |
| 189 | } catch { |
| 190 | // Ignore cache read errors |
| 191 | } |
| 192 | |
| 193 | const result = await Uebersicht.run(command); |
| 194 | try { |
| 195 | localStorage.setItem(cacheKey, JSON.stringify({ r: result, t: now })); |
| 196 | } catch { |
| 197 | // Ignore cache write errors (e.g., storage full) |
| 198 | } |
| 199 | return result; |
| 200 | } |
| 201 | |
| 202 | /** |
| 203 | * Creates a click effect animation at the location of the mouse click event. |
| 204 | * |
| 205 | * @param {MouseEvent} e - The mouse event object containing the click coordinates. |
| 206 | */ |
| 207 | export function clickEffect(e) { |
| 208 | const { body } = document; |
| 209 | const { clientX, clientY } = e; |
| 210 | const cursor = Object.assign(document.createElement("div"), { |
| 211 | id: "simple-bar-click-effect", |
| 212 | }); |
| 213 | Object.assign(cursor.style, { |
| 214 | top: `${clientY - WIDTH / 2}px`, |
| 215 | left: `${clientX - WIDTH / 2}px`, |
| 216 | width: `${WIDTH}px`, |
| 217 | height: `${WIDTH}px`, |
| 218 | transition: `transform ${DURATION} ease`, |
| 219 | }); |
| 220 | if (cursor && "animate" in cursor) { |
| 221 | body.appendChild(cursor); |
| 222 | cursor.animate( |
| 223 | [ |
| 224 | { opacity: 0, transform: "scale(0)" }, |
| 225 | { opacity: 1, transform: "scale(2)" }, |
| 226 | { opacity: 0, transform: "scale(1.6)" }, |
| 227 | ], |
| 228 | { duration: DURATION }, |
| 229 | ); |
| 230 | } |
| 231 | setTimeout(() => cursor && body.removeChild(cursor), DURATION); |
| 232 | } |
| 233 | |
| 234 | function isObject(item) { |
| 235 | return item && typeof item === "object" && !Array.isArray(item); |
| 236 | } |
| 237 | |
| 238 | /** |
| 239 | * Deeply merges multiple source objects into a target object. |
| 240 | * |
| 241 | * @param {Object} target - The target object to merge properties into. |
| 242 | * @param {...Object} sources - One or more source objects to merge properties from. |
| 243 | * @returns {Object} - The target object after merging. |
| 244 | */ |
| 245 | export function mergeDeep(target, ...sources) { |
| 246 | if (!sources.length) return target; |
| 247 | const source = sources.shift(); |
| 248 | if (isObject(target) && isObject(source)) { |
| 249 | for (const key in source) { |
| 250 | if (isObject(source[key])) { |
| 251 | if (!target[key]) Object.assign(target, { [key]: {} }); |
| 252 | mergeDeep(target[key], source[key]); |
| 253 | } else { |
| 254 | Object.assign(target, { [key]: source[key] }); |
| 255 | } |
| 256 | } |
| 257 | } |
| 258 | return mergeDeep(target, ...sources); |
| 259 | } |
| 260 | |
| 261 | /*! |
| 262 | * (c) 2019 Chris Ferdinandi & Jascha Brinkmann, MIT License, https://gomakethings.com & https://twitter.com/jaschaio |
| 263 | */ |
| 264 | /** |
| 265 | * Compares two objects and returns the differences. |
| 266 | * |
| 267 | * @param {Object} obj1 - The first object to compare. |
| 268 | * @param {Object} obj2 - The second object to compare. |
| 269 | * @returns {Object} An object containing the differences between obj1 and obj2. |
| 270 | * |
| 271 | * @example |
| 272 | * const obj1 = { a: 1, b: 2, c: 3 }; |
| 273 | * const obj2 = { a: 1, b: 4, d: 5 }; |
| 274 | * const diffs = compareObjects(obj1, obj2); |
| 275 | * // diffs = { b: 4, c: null, d: 5 } |
| 276 | */ |
| 277 | export function compareObjects(obj1, obj2) { |
| 278 | if (!obj2 || Object.prototype.toString.call(obj2) !== "[object Object]") { |
| 279 | return obj1; |
| 280 | } |
| 281 | const diffs = {}; |
| 282 | let key; |
| 283 | const arraysMatch = (arr1, arr2) => { |
| 284 | if (arr1.length !== arr2.length) return false; |
| 285 | for (let i = 0; i < arr1.length; i++) { |
| 286 | if (arr1[i] !== arr2[i]) return false; |
| 287 | } |
| 288 | return true; |
| 289 | }; |
| 290 | const compare = (item1, item2, key) => { |
| 291 | const type1 = Object.prototype.toString.call(item1); |
| 292 | const type2 = Object.prototype.toString.call(item2); |
| 293 | if (type2 === "[object Undefined]") { |
| 294 | diffs[key] = null; |
| 295 | return; |
| 296 | } |
| 297 | if (type1 !== type2) { |
| 298 | diffs[key] = item2; |
| 299 | return; |
| 300 | } |
| 301 | if (type1 === "[object Object]") { |
| 302 | const objDiff = compareObjects(item1, item2); |
| 303 | if (Object.keys(objDiff).length > 0) { |
| 304 | diffs[key] = objDiff; |
| 305 | } |
| 306 | return; |
| 307 | } |
| 308 | if (type1 === "[object Array]") { |
| 309 | if (!arraysMatch(item1, item2)) { |
| 310 | diffs[key] = item2; |
| 311 | } |
| 312 | return; |
| 313 | } |
| 314 | if (type1 === "[object Function]") { |
| 315 | if (item1.toString() !== item2.toString()) { |
| 316 | diffs[key] = item2; |
| 317 | } |
| 318 | } else { |
| 319 | if (item1 !== item2) { |
| 320 | diffs[key] = item2; |
| 321 | } |
| 322 | } |
| 323 | }; |
| 324 | for (key in obj1) { |
| 325 | if (Object.prototype.hasOwnProperty.call(obj1, key)) { |
| 326 | compare(obj1[key], obj2[key], key); |
| 327 | } |
| 328 | } |
| 329 | for (key in obj2) { |
| 330 | if (Object.prototype.hasOwnProperty.call(obj2, key)) { |
| 331 | if (!obj1[key] && obj1[key] !== obj2[key]) { |
| 332 | diffs[key] = obj2[key]; |
| 333 | } |
| 334 | } |
| 335 | } |
| 336 | return diffs; |
| 337 | } |
| 338 | |
| 339 | /** |
| 340 | * Filters applications based on provided exclusions and title exclusions. |
| 341 | * |
| 342 | * @param {Object} app - The application object to be filtered. |
| 343 | * @param {Array<string>} exclusions - List of application names to be excluded. |
| 344 | * @param {Array<string>} titleExclusions - List of application titles to be excluded. |
| 345 | * @param {boolean} exclusionsAsRegex - Flag indicating if exclusions should be treated as regular expressions. |
| 346 | * @returns {boolean} - Returns true if the application should be included, false otherwise. |
| 347 | */ |
| 348 | export function filterApps( |
| 349 | app, |
| 350 | exclusions, |
| 351 | titleExclusions, |
| 352 | exclusionsAsRegex, |
| 353 | ) { |
| 354 | const fullscreen = app.isNativeFullscreen ?? app.__legacyIsNativeFullscreen; |
| 355 | const appName = app.app || app["app-name"]; |
| 356 | const appTitle = app.title || app["window-title"]; |
| 357 | |
| 358 | const isAppNameExcluded = exclusionsAsRegex |
| 359 | ? exclusions.length !== 0 && new RegExp(exclusions).test(appName) |
| 360 | : exclusions.includes(appName); |
| 361 | |
| 362 | const isAppTitleExcluded = exclusionsAsRegex |
| 363 | ? titleExclusions.length !== 0 && new RegExp(titleExclusions).test(appTitle) |
| 364 | : titleExclusions.includes(appTitle); |
| 365 | |
| 366 | return ( |
| 367 | fullscreen || |
| 368 | (!isAppNameExcluded && !appTitle?.length) || |
| 369 | (!isAppNameExcluded && !isAppTitleExcluded) |
| 370 | ); |
| 371 | } |
| 372 | |
| 373 | export function isSpaceExcluded(id, exclusions /*list*/, exclusionsAsRegex) { |
| 374 | const isSpaceNameExcluded = exclusionsAsRegex |
| 375 | ? exclusions.some( |
| 376 | (rx) => |
| 377 | rx.length !== 0 && |
| 378 | (rx instanceof RegExp ? rx : new RegExp(rx)).test(id), |
| 379 | ) |
| 380 | : exclusions.includes(id); |
| 381 | |
| 382 | return exclusions && isSpaceNameExcluded; |
| 383 | } |
| 384 | |
| 385 | /** |
| 386 | * Filters and categorizes windows into sticky and non-sticky based on various criteria. |
| 387 | * |
| 388 | * @param {Object} params - The parameters for the function. |
| 389 | * @param {Array} params.windows - The list of windows to filter. |
| 390 | * @param {boolean} params.uniqueApps - Whether to treat apps as unique. |
| 391 | * @param {number} params.currentDisplay - The current display identifier. |
| 392 | * @param {number} params.currentSpace - The current space identifier. |
| 393 | * @param {Array} params.exclusions - The list of app names to exclude. |
| 394 | * @param {Array} params.titleExclusions - The list of window titles to exclude. |
| 395 | * @param {boolean} params.exclusionsAsRegex - Whether exclusions are regex patterns. |
| 396 | * @returns {Object} An object containing two arrays: `nonStickyWindows` and `stickyWindows`. |
| 397 | */ |
| 398 | export function stickyWindowWorkaround({ |
| 399 | windows = [], |
| 400 | uniqueApps, |
| 401 | currentDisplay, |
| 402 | currentSpace, |
| 403 | exclusions, |
| 404 | titleExclusions, |
| 405 | exclusionsAsRegex, |
| 406 | }) { |
| 407 | const stickySet = new Set(); |
| 408 | const stickyWindows = windows.filter((app) => { |
| 409 | const { |
| 410 | "is-sticky": isSticky, |
| 411 | sticky: __legacyIsSticky, |
| 412 | "is-native-fullscreen": isNativeFullscreen, |
| 413 | "native-fullscreen": __legacyIsNativeFullscreen, |
| 414 | display, |
| 415 | app: appName, |
| 416 | id, |
| 417 | } = app; |
| 418 | return ( |
| 419 | (isSticky ?? __legacyIsSticky) && |
| 420 | !(isNativeFullscreen ?? __legacyIsNativeFullscreen) && |
| 421 | display === currentDisplay && |
| 422 | filterApps(app, exclusions, titleExclusions, exclusionsAsRegex) && |
| 423 | !stickySet.has(uniqueApps ? appName : id) && |
| 424 | stickySet.add(uniqueApps ? appName : id) |
| 425 | ); |
| 426 | }); |
| 427 | const nonStickySet = new Set(); |
| 428 | const nonStickyWindows = windows.filter((app) => { |
| 429 | const { |
| 430 | "is-sticky": isSticky, |
| 431 | sticky: __legacyIsSticky, |
| 432 | "is-native-fullscreen": isNativeFullscreen, |
| 433 | "native-fullscreen": __legacyIsNativeFullscreen, |
| 434 | space, |
| 435 | app: appName, |
| 436 | id, |
| 437 | } = app; |
| 438 | return ( |
| 439 | (!(isSticky ?? __legacyIsSticky) || |
| 440 | (isNativeFullscreen ?? __legacyIsNativeFullscreen)) && |
| 441 | space === currentSpace && |
| 442 | filterApps(app, exclusions, titleExclusions, exclusionsAsRegex) && |
| 443 | !nonStickySet.has(uniqueApps ? appName : id) && |
| 444 | nonStickySet.add(uniqueApps ? appName : id) |
| 445 | ); |
| 446 | }); |
| 447 | return { nonStickyWindows, stickyWindows }; |
| 448 | } |
| 449 | |
| 450 | /** |
| 451 | * Sorts an array of window objects based on their frame coordinates and stack index. |
| 452 | * |
| 453 | * The sorting priority is as follows: |
| 454 | * 1. `frame.x` - Windows are sorted by their x-coordinate. |
| 455 | * 2. `frame.y` - If x-coordinates are equal, windows are sorted by their y-coordinate. |
| 456 | * 3. `stack-index` - If both x and y coordinates are equal, windows are sorted by their stack index. |
| 457 | * 4. `id` - If all previous criteria are equal, windows are sorted by their id. |
| 458 | * |
| 459 | * @param {Array} windows - The array of window objects to be sorted. |
| 460 | * @param {Object} windows[].frame - The frame object containing x and y coordinates. |
| 461 | * @param {number} windows[].frame.x - The x-coordinate of the window. |
| 462 | * @param {number} windows[].frame.y - The y-coordinate of the window. |
| 463 | * @param {number} windows[].stack-index - The stack index of the window. |
| 464 | * @param {number} windows[].id - The unique identifier of the window. |
| 465 | * @returns {Array} The sorted array of window objects. |
| 466 | */ |
| 467 | export function sortWindows(windows) { |
| 468 | return windows.sort((a, b) => { |
| 469 | if (a.frame.x !== b.frame.x) { |
| 470 | return a.frame.x > b.frame.x; |
| 471 | } |
| 472 | if (a.frame.y !== b.frame.y) { |
| 473 | return a.frame.y > b.frame.y; |
| 474 | } |
| 475 | if (a["stack-index"] !== b["stack-index"]) { |
| 476 | return a["stack-index"] > b["stack-index"]; |
| 477 | } |
| 478 | return a.id > b.id; |
| 479 | }); |
| 480 | } |
| 481 | |
| 482 | /** |
| 483 | * Softly refreshes the Uebersicht widget with the specified ID. |
| 484 | * |
| 485 | * This function runs an AppleScript command to refresh the widget with the ID "simple-bar-index-jsx" |
| 486 | * in the Uebersicht application. |
| 487 | * |
| 488 | * @async |
| 489 | * @function softRefresh |
| 490 | * @returns {Promise<void>} A promise that resolves when the refresh command has been executed. |
| 491 | */ |
| 492 | export async function softRefresh() { |
| 493 | await Uebersicht.run( |
| 494 | `osascript -e 'tell application id "tracesOf.Uebersicht" to refresh widget id "simple-bar-index-jsx"'`, |
| 495 | ); |
| 496 | } |
| 497 | |
| 498 | /** |
| 499 | * Triggers a hard refresh of the Uebersicht application by running an AppleScript command. |
| 500 | * |
| 501 | * @async |
| 502 | * @function hardRefresh |
| 503 | * @returns {Promise<void>} A promise that resolves when the refresh command has been executed. |
| 504 | */ |
| 505 | export async function hardRefresh() { |
| 506 | await Uebersicht.run( |
| 507 | `osascript -e 'tell application id "tracesOf.Uebersicht" to refresh'`, |
| 508 | ); |
| 509 | } |
| 510 | |
| 511 | /** |
| 512 | * Displays a notification using either the pushMissive function or the system notification. |
| 513 | * |
| 514 | * @param {string} content - The content of the notification. |
| 515 | * @param {Function} pushMissive - A function to push a missive notification. |
| 516 | * @param {Object} [options] - Optional settings for the notification. |
| 517 | * @param {string} [options.side="right"] - The side where the notification should appear. |
| 518 | * @param {number} [options.delay=5000] - The delay before the notification disappears. |
| 519 | */ |
| 520 | export function notification( |
| 521 | content, |
| 522 | pushMissive, |
| 523 | { side = "right", delay = 5000 } = {}, |
| 524 | ) { |
| 525 | const settings = Settings.get(); |
| 526 | const { disableNotifications, enableMissives } = settings.global; |
| 527 | if (disableNotifications) return; |
| 528 | if (enableMissives && typeof pushMissive === "function") { |
| 529 | pushMissive({ side, content, delay }); |
| 530 | } else { |
| 531 | Uebersicht.run( |
| 532 | `osascript -e 'tell app "System Events" to display notification "${content}" with title "simple-bar"'`, |
| 533 | ); |
| 534 | } |
| 535 | } |
| 536 | |
| 537 | /** |
| 538 | * Injects styles into the document head. If styles with the given ID already exist, it updates them. |
| 539 | * Otherwise, it creates a new style element and appends it to the document head. |
| 540 | * |
| 541 | * @param {string} id - The ID to assign to the style element. |
| 542 | * @param {string[]} [styles=[]] - An array of CSS styles to inject. |
| 543 | */ |
| 544 | export function injectStyles(id, styles = []) { |
| 545 | const existingStyles = document.getElementById(id); |
| 546 | // Merge all styles and minify them |
| 547 | const stylesToInject = styles.join("").replace(/\s+/g, " "); |
| 548 | if (existingStyles) { |
| 549 | existingStyles.innerHTML = stylesToInject; |
| 550 | return; |
| 551 | } |
| 552 | document.head.appendChild( |
| 553 | Object.assign(document.createElement("style"), { |
| 554 | id, |
| 555 | innerHTML: stylesToInject, |
| 556 | }), |
| 557 | ); |
| 558 | } |
| 559 | |
| 560 | const DEFAULT_PACE = 4; |
| 561 | |
| 562 | /** |
| 563 | * Starts the sliding animation for a given container. |
| 564 | * |
| 565 | * @param {HTMLElement} container - The container element that holds the inner and slider elements. |
| 566 | * @param {string} innerSelector - The CSS selector for the inner element. |
| 567 | * @param {string} sliderSelector - The CSS selector for the slider element. |
| 568 | */ |
| 569 | export function startSliding(container, innerSelector, sliderSelector) { |
| 570 | if (!container) return; |
| 571 | const settings = Settings.get(); |
| 572 | const { slidingAnimationPace = DEFAULT_PACE } = settings.global; |
| 573 | const pace = |
| 574 | !slidingAnimationPace || slidingAnimationPace < 1 |
| 575 | ? DEFAULT_PACE |
| 576 | : parseInt(slidingAnimationPace); |
| 577 | const inner = container.querySelector(innerSelector); |
| 578 | const slider = container.querySelector(sliderSelector); |
| 579 | const delta = inner.clientWidth - slider.clientWidth; |
| 580 | if (delta > 0) return; |
| 581 | const timing = Math.round((Math.abs(delta) * 100) / pace); |
| 582 | Object.assign(slider.style, { |
| 583 | transform: `translateX(${delta}px)`, |
| 584 | transition: `transform ${timing}ms linear`, |
| 585 | }); |
| 586 | } |
| 587 | |
| 588 | /** |
| 589 | * Stops the sliding effect by removing the inline style of the slider element. |
| 590 | * |
| 591 | * @param {HTMLElement} container - The container element that holds the slider. |
| 592 | * @param {string} sliderSelector - The CSS selector for the slider element. |
| 593 | */ |
| 594 | export function stopSliding(container, sliderSelector) { |
| 595 | if (!container) return; |
| 596 | container.querySelector(sliderSelector).removeAttribute("style"); |
| 597 | } |
| 598 | |
| 599 | /** |
| 600 | * Cleans up the given output by trimming whitespace and removing all newline characters. |
| 601 | * |
| 602 | * @param {string} output - The string output to be cleaned. |
| 603 | * @returns {string} - The cleaned output string. |
| 604 | */ |
| 605 | export function cleanupOutput(output) { |
| 606 | return output?.trim().replace(/(\r\n|\n|\r)/gm, ""); |
| 607 | } |
| 608 | |
| 609 | /** |
| 610 | * Returns a promise that resolves after a specified number of milliseconds. |
| 611 | * |
| 612 | * @param {number} ms - The number of milliseconds to wait before the promise resolves. |
| 613 | * @returns {Promise<void>} An empty promise that resolves after the specified delay. |
| 614 | */ |
| 615 | export function timeout(ms) { |
| 616 | return new Promise((resolve) => setTimeout(resolve, ms)); |
| 617 | } |
| 618 | |
| 619 | /** |
| 620 | * Switches the space (virtual desktop) on macOS. |
| 621 | * |
| 622 | * @param {number} currentIndex - The current space index. |
| 623 | * @param {number} desiredIndex - The desired space index to switch to. |
| 624 | * @returns {Promise<void>} A promise that resolves when the space switch is complete. |
| 625 | */ |
| 626 | export async function switchSpace(currentIndex, desiredIndex) { |
| 627 | const repeats = Math.abs(currentIndex - desiredIndex); |
| 628 | const left = currentIndex > desiredIndex; |
| 629 | for (let i = 0; i < repeats; i++) { |
| 630 | await Uebersicht.run( |
| 631 | `osascript -e 'tell app "System Events" to key code ${ |
| 632 | left ? "123" : "124" |
| 633 | } using control down'`, |
| 634 | ); |
| 635 | } |
| 636 | } |
| 637 | |
| 638 | /** |
| 639 | * Adds event listeners to handle focus and blur events on the bar element. |
| 640 | * |
| 641 | * This function selects the element with the class "simple-bar" and adds |
| 642 | * event listeners for "click" and "mouseleave" events. When the bar is clicked, |
| 643 | * it checks if the click target is the bar itself and then calls the `focusBar` function. |
| 644 | * When the mouse leaves the bar, it calls the `blurBar` function. |
| 645 | */ |
| 646 | export function handleBarFocus() { |
| 647 | const bar = document.querySelector(".simple-bar"); |
| 648 | if (!bar) return; |
| 649 | bar.addEventListener("click", (e) => { |
| 650 | if (e.target !== bar) return; |
| 651 | focusBar(); |
| 652 | }); |
| 653 | bar.addEventListener("mouseleave", blurBar); |
| 654 | } |
| 655 | |
| 656 | /** |
| 657 | * Focuses the bar element by adding the "simple-bar--focused" class to it. |
| 658 | * If the bar element is not found, the function does nothing. |
| 659 | */ |
| 660 | function focusBar() { |
| 661 | const bar = document.querySelector(".simple-bar"); |
| 662 | if (!bar) return; |
| 663 | bar.classList.add("simple-bar--focused"); |
| 664 | } |
| 665 | |
| 666 | /** |
| 667 | * Removes the "simple-bar--focused" class from the element with the class "simple-bar". |
| 668 | * If the element is not found, the function does nothing. |
| 669 | */ |
| 670 | export function blurBar() { |
| 671 | const bar = document.querySelector(".simple-bar"); |
| 672 | if (!bar) return; |
| 673 | bar.classList.remove("simple-bar--focused"); |
| 674 | } |
| 675 | |
| 676 | /** |
| 677 | * Retrieves the system architecture. |
| 678 | * |
| 679 | * This function runs two shell commands using Uebersicht to get the system's |
| 680 | * architecture and system type. It then processes the output to determine |
| 681 | * if the system is using an ARM64 architecture or x86_64. |
| 682 | * |
| 683 | * The result is cached permanently (in memory and localStorage) since |
| 684 | * system architecture never changes at runtime. |
| 685 | * |
| 686 | * @returns {Promise<"arm64" | "x86_64">} A promise that resolves to a string indicating the system architecture ("arm64" or "x86_64"). |
| 687 | */ |
| 688 | let _cachedSystem = null; |
| 689 | |
| 690 | export async function getSystem() { |
| 691 | if (_cachedSystem) return _cachedSystem; |
| 692 | try { |
| 693 | const stored = localStorage.getItem("sb_system_arch"); |
| 694 | if (stored) { |
| 695 | _cachedSystem = stored; |
| 696 | return _cachedSystem; |
| 697 | } |
| 698 | } catch { |
| 699 | // Ignore localStorage errors |
| 700 | } |
| 701 | const [bareArchitecture, bareSystem] = await Promise.all([ |
| 702 | Uebersicht.run("uname -a"), |
| 703 | Uebersicht.run("uname -m"), |
| 704 | ]); |
| 705 | const architecture = cleanupOutput(bareArchitecture); |
| 706 | const system = cleanupOutput(bareSystem); |
| 707 | if ( |
| 708 | system.startsWith("arm64") || |
| 709 | (system.startsWith("x86_64") && architecture.includes("ARM64")) |
| 710 | ) { |
| 711 | _cachedSystem = "arm64"; |
| 712 | } else { |
| 713 | _cachedSystem = "x86_64"; |
| 714 | } |
| 715 | try { |
| 716 | localStorage.setItem("sb_system_arch", _cachedSystem); |
| 717 | } catch { |
| 718 | // Ignore localStorage errors |
| 719 | } |
| 720 | return _cachedSystem; |
| 721 | } |
| 722 | |
| 723 | /** |
| 724 | * Checks if a display is visible based on the provided setting. |
| 725 | * |
| 726 | * @param {number} display - The display number to check. |
| 727 | * @param {string} setting - A comma-separated string of display numbers. |
| 728 | * @returns {boolean} - Returns true if the display is visible, otherwise false. |
| 729 | */ |
| 730 | export function isVisibleOnDisplay(display, setting) { |
| 731 | try { |
| 732 | if (!setting?.trim()) return true; |
| 733 | const displays = setting.split(",").map((d) => parseInt(d, 10)); |
| 734 | return displays.includes(display); |
| 735 | } catch { |
| 736 | return true; |
| 737 | } |
| 738 | } |
| 739 | |
| 740 | /** |
| 741 | * Adds a value to the graph history and ensures the graph does not exceed the specified maximum length. |
| 742 | * |
| 743 | * @param {any} value - The value to add to the graph history. |
| 744 | * @param {function} setGraph - The state setter function for updating the graph. |
| 745 | * @param {number} maxLength - The maximum length of the graph history. |
| 746 | */ |
| 747 | export function addToGraphHistory(value, setGraph, maxLength) { |
| 748 | setGraph((graph) => { |
| 749 | const newGraph = [...graph, value]; |
| 750 | if (newGraph.length > maxLength) { |
| 751 | newGraph.shift(); |
| 752 | } |
| 753 | return newGraph; |
| 754 | }); |
| 755 | } |
| 756 | |
| 757 | /** |
| 758 | * Parses a string value into an integer and returns it if valid, otherwise returns a default value. |
| 759 | * |
| 760 | * @param {string} value - The string value to parse. |
| 761 | * @param {number} defaultValue - The default value to return if parsing fails. |
| 762 | * @returns {number} - The parsed integer or the default value if parsing fails. |
| 763 | */ |
| 764 | export function getRefreshFrequency(value, defaultValue) { |
| 765 | const parsedValue = parseInt(value, 10); |
| 766 | return isNaN(parsedValue) ? defaultValue : parsedValue; |
| 767 | } |
| 768 | |
| 769 | /** |
| 770 | * Executes a given command in the user's preferred terminal application. |
| 771 | * |
| 772 | * The terminal application is determined by the global settings. |
| 773 | * Supported terminals are "Terminal" and "iTerm2". |
| 774 | * |
| 775 | * @param {string} command - The command to be executed in the terminal. |
| 776 | */ |
| 777 | export function runInUserTerminal(command) { |
| 778 | const settings = Settings.get(); |
| 779 | const { terminal } = settings.global; |
| 780 | switch (terminal) { |
| 781 | case "Terminal": |
| 782 | Uebersicht.run( |
| 783 | `osascript ./simple-bar/lib/scripts/run-command-in-terminal.applescript` + |
| 784 | ` "${command}"`, |
| 785 | ); |
| 786 | break; |
| 787 | case "iTerm2": |
| 788 | Uebersicht.run( |
| 789 | `osascript ./simple-bar/lib/scripts/run-command-in-iterm2.applescript` + |
| 790 | ` "${command}"`, |
| 791 | ); |
| 792 | break; |
| 793 | default: |
| 794 | break; |
| 795 | } |
| 796 | } |
| 797 | |
| 798 | /** |
| 799 | * Formats a given number of bytes into a more readable string with appropriate units. |
| 800 | * |
| 801 | * @param {number} bytes - The number of bytes to format. |
| 802 | * @param {number} [decimals=1] - The number of decimal places to include in the formatted string. |
| 803 | * @returns {string} The formatted string with the appropriate unit. |
| 804 | */ |
| 805 | export function formatBytes(bytes, decimals = 1) { |
| 806 | if (!+bytes) return "0b"; |
| 807 | |
| 808 | const k = 1024; |
| 809 | const dm = decimals < 0 ? 0 : decimals; |
| 810 | const sizes = ["b", "kb", "mb", "gb", "tb", "pb", "eb", "zb", "yb"]; |
| 811 | |
| 812 | const i = Math.floor(Math.log(bytes) / Math.log(k)); |
| 813 | |
| 814 | return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))}<em>${ |
| 815 | sizes[i] |
| 816 | }</em>`; |
| 817 | } |
| 818 | |
| 819 | /** |
| 820 | * Normalizes an application name by removing any left-to-right mark characters (U+200E). |
| 821 | * |
| 822 | * @param {string} name - The application name to normalize. |
| 823 | * @returns {string} The normalized application name without left-to-right mark characters. |
| 824 | */ |
| 825 | export function normalizeAppName(name) { |
| 826 | if (!name) return ""; |
| 827 | return name.replace(/[\u200E]/g, ""); |
| 828 | } |