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 <jaskiratpal.singh@outlook.com>
This commit is contained in:
Daniel Pfefferkorn
2024-10-28 04:11:42 +01:00
committed by GitHub
parent 25753e5f17
commit 4e2a774c7e
7 changed files with 186 additions and 5 deletions

View File

@@ -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: '󰕼',
};

View File

@@ -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;

View File

@@ -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<ClientAttributes> = 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}`;
};

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<ApplicationIcons>({}),
applicationIconFallback: opt('󰣆'),
applicationIconEmptyWorkspace: opt(''),
numbered_active_indicator: opt<ActiveWsIndicator>('underline'),
icons: {
available: opt(''),

View File

@@ -261,6 +261,34 @@ export const BarSettings = (): Scrollable<Gtk.Widget, Gtk.Widget> => {
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',