From 4e2a774c7e67e57df07f59c5bd85b805a22be5b9 Mon Sep 17 00:00:00 2001 From: Daniel Pfefferkorn Date: Mon, 28 Oct 2024 04:11:42 +0100 Subject: [PATCH] feat(workspace): map client classes to application icons (#368) * feat(workspace): map client classes to application icons * refactor: extract app icon detection * feat: hide duplicate icons per workspace * feat: use dedicated icons for empty workspace and fallback * provide default icons * feat: title or class matcher can no provided as regex * style: change option description * style: use more descriptive param name * style: fix comment * fix(lint): missing return type * refactor: move type definitions into separate file * feat: defined default app icon set * docs: change option subtitles * style: change icons * fix: add missing default variant --------- Co-authored-by: Jas Singh --- lib/constants/workspaces.ts | 29 +++++++++ lib/types/workspace.d.ts | 11 ++++ modules/bar/workspaces/utils.ts | 70 ++++++++++++++++++++- modules/bar/workspaces/variants/default.ts | 22 ++++++- modules/bar/workspaces/variants/occupied.ts | 24 ++++++- options.ts | 7 ++- widget/settings/pages/config/bar/index.ts | 28 +++++++++ 7 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 lib/constants/workspaces.ts diff --git a/lib/constants/workspaces.ts b/lib/constants/workspaces.ts new file mode 100644 index 0000000..40f8a14 --- /dev/null +++ b/lib/constants/workspaces.ts @@ -0,0 +1,29 @@ +export const defaultApplicationIcons = { + '[dD]iscord': '󰙯', + '^thunderbird': '', + 'class:wezterm$': '', + 'draw.io': '󰇟', + 'firefox-developer-edition': '', + 'google-chrome': '', + 'title:.*youtube': '', + Spotify: '󰓇', + chromium: '', + code: '󰨞', + dbeaver: '', + edge: '󰇩', + evince: '', + firefox: '', + foot: '', + keepassxc: '', + keymapp: '', + kitty: '', + obsidian: '󰠮', + password$: '', + qBittorrent$: '', + rofi: '', + slack: '', + spotube: '󰓇', + steam: '', + telegram: '', + vlc: '󰕼', +}; diff --git a/lib/types/workspace.d.ts b/lib/types/workspace.d.ts index 9c46c15..5066332 100644 --- a/lib/types/workspace.d.ts +++ b/lib/types/workspace.d.ts @@ -11,10 +11,21 @@ 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; diff --git a/modules/bar/workspaces/utils.ts b/modules/bar/workspaces/utils.ts index c9f2bc2..446e460 100644 --- a/modules/bar/workspaces/utils.ts +++ b/modules/bar/workspaces/utils.ts @@ -1,4 +1,5 @@ -import { WorkspaceIconMap } from 'lib/types/workspace'; +import { defaultApplicationIcons } from 'lib/constants/workspaces'; +import type { ClientAttributes, AppIconOptions, WorkspaceIconMap } from 'lib/types/workspace'; import { isValidGjsColor } from 'lib/utils'; import options from 'options'; import { Monitor } from 'types/service/hyprland'; @@ -68,6 +69,65 @@ export const getWsColor = ( return ''; }; +export const getAppIcon = ( + workspaceIndex: number, + removeDuplicateIcons: boolean, + { iconMap: userDefinedIconMap, defaultIcon, emptyIcon }: AppIconOptions, +): string => { + // append the default icons so user defined icons take precedence + const iconMap = { ...userDefinedIconMap, ...defaultApplicationIcons }; + + // detect the clients attributes on the current workspace + const clients: ReadonlyArray = hyprland.clients + .filter((c) => c.workspace.id === workspaceIndex) + .map((c) => [c.class, c.title]); + + if (!clients.length) { + return emptyIcon; + } + + // map the client attributes to icons + let icons = clients + .map(([clientClass, clientTitle]) => { + const maybeIcon = Object.entries(iconMap).find(([matcher]) => { + // non-valid Regex construction could result in a syntax error + try { + if (matcher.startsWith('class:')) { + const re = matcher.substring(6); + return new RegExp(re, 'i').test(clientClass); + } + + if (matcher.startsWith('title:')) { + const re = matcher.substring(6); + return new RegExp(re, 'i').test(clientTitle); + } + + return new RegExp(matcher, 'i').test(clientClass); + } catch { + return false; + } + }); + + if (!maybeIcon) { + return undefined; + } + + return maybeIcon.at(1); + }) + .filter((x) => x); + + // remove duplicate icons + if (removeDuplicateIcons) { + icons = [...new Set(icons)]; + } + + if (icons.length) { + return icons.join(' '); + } + + return defaultIcon; +}; + export const renderClassnames = ( showIcons: boolean, showNumbered: boolean, @@ -104,6 +164,8 @@ export const renderLabel = ( available: string, active: string, occupied: string, + showAppIcons: boolean, + appIcons: string, workspaceMask: boolean, showWsIcons: boolean, wsIconMap: WorkspaceIconMap, @@ -112,6 +174,10 @@ export const renderLabel = ( monitor: number, monitors: Monitor[], ): string => { + if (showAppIcons) { + return appIcons; + } + if (showIcons) { if (hyprland.active.workspace.id === i || isWorkspaceActiveOnMonitor(monitor, monitors, i)) { return active; @@ -123,8 +189,10 @@ export const renderLabel = ( return available; } } + if (showWsIcons) { return getWsIcon(wsIconMap, i); } + return workspaceMask ? `${index + 1}` : `${i}`; }; diff --git a/modules/bar/workspaces/variants/default.ts b/modules/bar/workspaces/variants/default.ts index dddabc9..b1938be 100644 --- a/modules/bar/workspaces/variants/default.ts +++ b/modules/bar/workspaces/variants/default.ts @@ -3,7 +3,7 @@ import options from 'options'; import { getWorkspaceRules, getWorkspacesForMonitor, isWorkspaceIgnored } from '../helpers'; import { range } from 'lib/utils'; import { BoxWidget } from 'lib/types/widget'; -import { getWsColor, renderClassnames, renderLabel } from '../utils'; +import { getAppIcon, getWsColor, renderClassnames, renderLabel } from '../utils'; import { WorkspaceIconMap } from 'lib/types/workspace'; import { Monitor } from 'types/service/hyprland'; @@ -98,6 +98,11 @@ export const defaultWses = (monitor: number): BoxWidget => { options.bar.workspaces.icons.occupied.bind('value'), options.bar.workspaces.workspaceIconMap.bind('value'), options.bar.workspaces.showWsIcons.bind('value'), + options.bar.workspaces.showApplicationIcons.bind('value'), + options.bar.workspaces.applicationIconOncePerWorkspace.bind('value'), + options.bar.workspaces.applicationIconMap.bind('value'), + options.bar.workspaces.applicationIconEmptyWorkspace.bind('value'), + options.bar.workspaces.applicationIconFallback.bind('value'), workspaceMask.bind('value'), hyprland.bind('monitors'), ], @@ -108,14 +113,29 @@ export const defaultWses = (monitor: number): BoxWidget => { occupied: string, wsIconMap: WorkspaceIconMap, showWsIcons: boolean, + showAppIcons, + applicationIconOncePerWorkspace, + applicationIconMap, + applicationIconEmptyWorkspace, + applicationIconFallback, workspaceMask: boolean, monitors: Monitor[], ) => { + const appIcons = showAppIcons + ? getAppIcon(i, applicationIconOncePerWorkspace, { + iconMap: applicationIconMap, + defaultIcon: applicationIconFallback, + emptyIcon: applicationIconEmptyWorkspace, + }) + : ''; + return renderLabel( showIcons, available, active, occupied, + showAppIcons, + appIcons, workspaceMask, showWsIcons, wsIconMap, diff --git a/modules/bar/workspaces/variants/occupied.ts b/modules/bar/workspaces/variants/occupied.ts index 31f3f14..afffa97 100644 --- a/modules/bar/workspaces/variants/occupied.ts +++ b/modules/bar/workspaces/variants/occupied.ts @@ -2,11 +2,10 @@ const hyprland = await Service.import('hyprland'); import options from 'options'; import { getWorkspaceRules, getWorkspacesForMonitor, isWorkspaceIgnored } from '../helpers'; import { Monitor, Workspace } from 'types/service/hyprland'; -import { getWsColor, renderClassnames, renderLabel } from '../utils'; +import { getAppIcon, getWsColor, renderClassnames, renderLabel } from '../utils'; import { range } from 'lib/utils'; import { BoxWidget } from 'lib/types/widget'; import { WorkspaceIconMap } from 'lib/types/workspace'; - const { workspaces, monitorSpecific, workspaceMask, spacing, ignored, showAllActive } = options.bar.workspaces; export const occupiedWses = (monitor: number): BoxWidget => { @@ -26,6 +25,11 @@ export const occupiedWses = (monitor: number): BoxWidget => { spacing.bind('value'), options.bar.workspaces.workspaceIconMap.bind('value'), options.bar.workspaces.showWsIcons.bind('value'), + options.bar.workspaces.showApplicationIcons.bind('value'), + options.bar.workspaces.applicationIconOncePerWorkspace.bind('value'), + options.bar.workspaces.applicationIconMap.bind('value'), + options.bar.workspaces.applicationIconEmptyWorkspace.bind('value'), + options.bar.workspaces.applicationIconFallback.bind('value'), options.theme.matugen.bind('value'), options.theme.bar.buttons.workspaces.smartHighlight.bind('value'), hyprland.bind('monitors'), @@ -46,6 +50,11 @@ export const occupiedWses = (monitor: number): BoxWidget => { spacing: number, wsIconMap: WorkspaceIconMap, showWsIcons: boolean, + showAppIcons, + applicationIconOncePerWorkspace, + applicationIconMap, + applicationIconEmptyWorkspace, + applicationIconFallback, matugen: boolean, smartHighlight: boolean, monitors: Monitor[], @@ -98,6 +107,15 @@ export const occupiedWses = (monitor: number): BoxWidget => { if (isWorkspaceIgnored(ignored, i)) { return Widget.Box(); } + + const appIcons = showAppIcons + ? getAppIcon(i, applicationIconOncePerWorkspace, { + iconMap: applicationIconMap, + defaultIcon: applicationIconFallback, + emptyIcon: applicationIconEmptyWorkspace, + }) + : ''; + return Widget.Button({ class_name: 'workspace-button', on_primary_click: () => { @@ -124,6 +142,8 @@ export const occupiedWses = (monitor: number): BoxWidget => { available, active, occupied, + showAppIcons, + appIcons, workspaceMask, showWsIcons, wsIconMap, diff --git a/options.ts b/options.ts index 4f57b41..edf6e47 100644 --- a/options.ts +++ b/options.ts @@ -14,7 +14,7 @@ import { import { MatugenScheme, MatugenTheme, MatugenVariations } from 'lib/types/options'; import { UnitType } from 'lib/types/weather'; import { Transition } from 'lib/types/widget'; -import { WorkspaceIcons, WorkspaceIconsColored } from 'lib/types/workspace'; +import { ApplicationIcons, WorkspaceIcons, WorkspaceIconsColored } from 'lib/types/workspace'; // WARN: CHANGING THESE VALUES WILL PREVENT MATUGEN COLOR GENERATION FOR THE CHANGED VALUE export const colors = { @@ -868,6 +868,11 @@ const options = mkOptions(OPTIONS, { ignored: opt(''), show_numbered: opt(false), showWsIcons: opt(false), + showApplicationIcons: opt(false), + applicationIconOncePerWorkspace: opt(true), + applicationIconMap: opt({}), + applicationIconFallback: opt('󰣆'), + applicationIconEmptyWorkspace: opt(''), numbered_active_indicator: opt('underline'), icons: { available: opt(''), diff --git a/widget/settings/pages/config/bar/index.ts b/widget/settings/pages/config/bar/index.ts index 921f475..dc83f85 100644 --- a/widget/settings/pages/config/bar/index.ts +++ b/widget/settings/pages/config/bar/index.ts @@ -261,6 +261,34 @@ export const BarSettings = (): Scrollable => { title: 'Map Workspaces to Icons', type: 'boolean', }), + Option({ + opt: options.bar.workspaces.showApplicationIcons, + title: 'Map Workspaces to Application Icons', + subtitle: "Requires 'Map Workspace to Icons' to be enabled", + type: 'boolean', + }), + Option({ + opt: options.bar.workspaces.applicationIconOncePerWorkspace, + title: 'Hide Duplicate App Icons', + type: 'boolean', + }), + Option({ + opt: options.bar.workspaces.applicationIconMap, + title: 'App Icon Mappings', + subtitle: "Use the class/title output of 'hyprctl clients' to match against", + type: 'object', + }), + Option({ + opt: options.bar.workspaces.applicationIconFallback, + title: 'Fallback App Icon', + subtitle: 'Fallback icon to display if no specific icon is defined for the application', + type: 'string', + }), + Option({ + opt: options.bar.workspaces.applicationIconEmptyWorkspace, + title: 'App Icon for empty workspace', + type: 'string', + }), Option({ opt: options.bar.workspaces.workspaceIconMap, title: 'Workspace Icon Mappings',