Skip to content

Add configurable window icons to workspace indicators#495

Open
gwiazdorrr wants to merge 14 commits intoMalpenZibo:mainfrom
gwiazdorrr:feature/window-icons
Open

Add configurable window icons to workspace indicators#495
gwiazdorrr wants to merge 14 commits intoMalpenZibo:mainfrom
gwiazdorrr:feature/window-icons

Conversation

@gwiazdorrr
Copy link
Copy Markdown
Contributor

Display application icons next to workspace names, similar to waybar's window-rewrite feature.

New config options:

[workspaces]
indicator_format = "NameAndIcons"  # or "Name" (default)
window_icons = { "firefox" = "󰈹", "code" = "󰨞" }
default_window_icon = "󰏗"

Window class to icon mappings use case-insensitive matching with an optional default icon. Window class collection from compositors is gated behind an AtomicBool flag so the feature has no cost when disabled.

This is how it looks on my setup. I find this extremely useful:
image

@1randomguy
Copy link
Copy Markdown

I like the idea of being able to see the what windows are open on which workspace.
But the way I understand it with you implementation you have to specify an icon for every application or it is gonna use the fallback icon?

I feel like that goes against the idea of ashell being a "ready to go" status bar that works out of the box without any configuration.

@clotodex
Copy link
Copy Markdown
Collaborator

Agreeing with @1randomguy
However, the tray implementation already has a way to look up xdg icons from the application and display them. Maybe that works for this case too?

@gwiazdorrr
Copy link
Copy Markdown
Contributor Author

I hijacked XDG icon handling from the tray module and added some caching.

image

Got to say, I was skeptical initially. I like nerdfonts, but it turns out I like things just working out of the box without config even more. I removed the ability to override icons completely, so I guess the PR name should be updated?

@1randomguy
Copy link
Copy Markdown

I tried it (niri), and I noticed that not all programs are shown, for example nautilus, gnome-calendar, and spotify do not show an icon.

One more thing that I noticed is that it shows only one icon per program per workspace, I think this is something worth thinking about whether we could show that there are multiple windows open.

Lastly, I think that the icons making the indicators bigger makes it kind of difficult to distinguishe the currently active workspace from the others.
I think there was an idea sometime to show the active workspace in a different color, maybe this would be something worth revisiting in this context.

@clotodex
Copy link
Copy Markdown
Collaborator

@1randomguy @gwiazdorrr potentially #475 has some fixes in icon discovery that also help here. Not sure if what you mention is an icon or process discovery issue though

Comment thread src/modules/workspaces.rs Outdated
if !seen.insert(class_lower.clone()) {
continue;
}
if let Some(xdg) = xdg_icons::get_icon_from_name(&class_lower) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we can't get an icon, we might want to put a placeholder?

@1randomguy
Copy link
Copy Markdown

It does indeed seem like it can't find the icon for a bunch of them, so those fixes will probably help a lot.

Idk if it will be able to find every icon though, maybe we could consider adding a placeholder icon like the circle that appears in the tray for programs where the icon is not found.
image

@1randomguy
Copy link
Copy Markdown

I had a look at #475 , and it seems like he uses the statusnotifier icon first and falls back to the .desktop icon if not available.

For this one, I'd argue that using the .desktop icon as the first choice probably makes more sense, right?
But it could still use the functions for getting that .desktop icon from the other pr.

@clotodex clotodex added the UI/UX label Feb 24, 2026
@gwiazdorrr gwiazdorrr force-pushed the feature/window-icons branch 2 times, most recently from 6a26771 to ae0e0e6 Compare February 26, 2026 23:11
@gwiazdorrr
Copy link
Copy Markdown
Contributor Author

I tried it (niri), and I noticed that not all programs are shown, for example nautilus, gnome-calendar, and spotify do not show an icon.

From the list I can verify that spotify works here, but the icon for nautilus is missing. I'll investigate.

In the meantime, I rebased and pushed support for both fallback icon (using StaticIcon::Point) and active_workspace_colors appearance option

@1randomguy
Copy link
Copy Markdown

1randomguy commented Mar 2, 2026

From the list I can verify that spotify works here

That's very interesting, still doesn't work here :P
But I would still vote for using .desktop icons as a first choice for this one, or do you think that thats not a good idea?

The fallback icon and color for active workspaces (#347 ) work great though 👍

image

@1randomguy
Copy link
Copy Markdown

One more thing that I was wondering about is whether or not a small space or visual divider between the workspace name and the icons would help with legibility, as I feel like the numbers of the workspaces can get a bit harder to make out when there are a lot of icons. But thats just an idea

@gwiazdorrr gwiazdorrr force-pushed the feature/window-icons branch from ae0e0e6 to e3aed91 Compare March 7, 2026 16:41
@gwiazdorrr
Copy link
Copy Markdown
Contributor Author

Fixed the missing nautilus icon, hopefully spotify now also works for you :)

@1randomguy
Copy link
Copy Markdown

unfortunately still not working for me 😖
image

@gwiazdorrr
Copy link
Copy Markdown
Contributor Author

I've just added parsing of .desktop files that fixed the last "dot icon" for me (cura).

@1randomguy
Copy link
Copy Markdown

image 🥳

Copy link
Copy Markdown
Collaborator

@clotodex clotodex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I got today with review

Comment thread src/modules/workspaces.rs Outdated
Comment thread src/modules/workspaces.rs Outdated
Comment thread src/modules/workspaces.rs Outdated
Comment thread src/modules/workspaces.rs Outdated
Comment thread src/modules/workspaces.rs Outdated
Comment thread src/modules/workspaces.rs Outdated
Comment thread src/modules/workspaces.rs Outdated
@gwiazdorrr gwiazdorrr requested a review from clotodex March 22, 2026 22:22
@clotodex
Copy link
Copy Markdown
Collaborator

please fix the conflict :)

@clotodex
Copy link
Copy Markdown
Collaborator

also generally check out #537 for performance, maybe this PR can adopt some of the same improvements

Copy link
Copy Markdown
Collaborator

@clotodex clotodex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are getting there! Thank you for the contributions

Looking at the code this feels in many cases AI generated. This is not a problem, but makes it REALLY hard to understand the code quickly, and every change and review changes a lot of things again needing to understand the code from scratch. Not sure how to deal with this yes.

@romanstingler could you do me a favor and check out the xdg_icons.rs file, since you worked on the tray icons and icon discovery (if not, feel free to tag who did if I am mistaken)

static BROADCASTER: OnceCell<broadcast::Sender<ServiceEvent<CompositorService>>> =
OnceCell::const_new();

static COLLECT_WINDOW_CLASSES: AtomicBool = AtomicBool::new(false);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel weird about using an atomic boolean to essentially communicate a config field

Copy link
Copy Markdown
Contributor Author

@gwiazdorrr gwiazdorrr Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to familiarize myself more with tokio. Or just make hyprland/niri always collect windows classes and process them in workspaces.rs. Not sure which is the right call atm.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So every kind of configuration should be sent in the subscribe() service function. In theory, it should also be used in the Subscription::run_with(TypeId::of::<Self>() as id, for example run_with((TypeId::of::<Self>(), configuration_x), .... This tells iced to recreate the subscription if the id changes.

TypeId::of::() is constant, so adding the configuration_x in the tuple should recreate the subscription only when the configuration_x value changes.

In this case, we should send an indicator_format == WorkspaceIndicatorFormat::NameAndIcons to know if we had to collect the class name, if I understand correctly the changes.

Comment thread src/modules/workspaces.rs
.map(|class| class.to_lowercase())
.unique()
.map(|class_lower| {
xdg_icons::get_icon_from_name(&class_lower).unwrap_or_else(xdg_icons::fallback_icon)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this swallows errors, is there a way to somehow only swallow "not found"?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think there anything other than "not found" at this stage; iced images are loaded lazily and all the debug logs are really just progress, not real failures.

Comment thread src/services/xdg_icons.rs Outdated
Comment thread src/modules/workspaces.rs

impl Workspaces {
pub fn new(config: WorkspacesModuleConfig) -> Self {
set_collect_window_classes(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as discussed with the atomic bool, this is a strong anti-pattern and a new messaging channel for config which we should not do

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part should go in the subscription method of the module. We should pass config.indicator_format == WorkspaceIndicatorFormat::NameAndIcons as parameter to the service subscribe method

@romanstingler
Copy link
Copy Markdown
Collaborator

romanstingler commented Mar 24, 2026

I applied my performance changes and a few other suggestions to the
XDG file.

I think that we can rework the whole thing in future,
and with more testing maybe get rid of some fallback strategies and maybe
working only with the desktop files is ok.
I have no idea, there are so many strange apps, so many themes, .....

There are things missing (also in the tray module) where we invalidate the caches if the user changes the theme of the system.
Tbh, not a blocker for me for now.

overall works fine
image

use freedesktop_icons::lookup;
use iced::widget::{image, svg};
use linicon_theme::get_icon_theme;
use log::debug;
use std::{
    borrow::Cow,
    collections::{BTreeSet, HashMap},
    env,
    ffi::OsString,
    fs,
    path::{Path, PathBuf},
    sync::LazyLock,
};

use std::sync::RwLock;

const MAX_SIMILAR_ICON_CANDIDATES: usize = 5;

static ICON_CACHE: LazyLock<RwLock<HashMap<String, Option<XdgIcon>>>> =
    LazyLock::new(|| RwLock::new(HashMap::new()));
static SYSTEM_ICON_NAMES: LazyLock<BTreeSet<OsString>> = LazyLock::new(load_system_icon_names);
static SYSTEM_ICON_ENTRIES: LazyLock<Vec<(Cow<'static, str>, Cow<'static, str>)>> =
    LazyLock::new(|| {
        SYSTEM_ICON_NAMES
            .iter()
            .filter_map(|name| {
                let name_str = name.to_str()?;
                let normalized = normalize_icon_name(name_str);
                let normalized_cow = if normalized.as_ref() == name_str {
                    Cow::Borrowed(name_str)
                } else {
                    Cow::Owned(normalized.into_owned())
                };
                Some((Cow::Owned(name_str.to_string()), normalized_cow))
            })
            .collect()
    });
static DESKTOP_ICON_INDEX: LazyLock<HashMap<String, String>> =
    LazyLock::new(build_desktop_icon_index);

#[derive(Debug, Clone)]
pub enum XdgIcon {
    Image(image::Handle),
    Svg(svg::Handle),
    NerdFont(&'static str),
}

pub fn fallback_icon() -> XdgIcon {
    XdgIcon::NerdFont(crate::components::icons::StaticIcon::Point.get_str())
}

pub fn get_icon_from_name(icon_name: &str) -> Option<XdgIcon> {
    if icon_name.is_empty() {
        return None;
    }

    let cache = match ICON_CACHE.read() {
        Ok(c) => c,
        Err(_) => {
            return lookup_icon(icon_name);
        }
    };

    if let Some(cached) = cache.get(icon_name) {
        return cached.clone();
    }
    drop(cache); // Release read lock before write

    let mut cache = match ICON_CACHE.write() {
        Ok(c) => c,
        Err(_) => {
            return lookup_icon(icon_name);
        }
    };

    // Double-check after acquiring write lock (another thread may have populated it)
    if let Some(cached) = cache.get(icon_name) {
        return cached.clone();
    }

    let result = lookup_icon(icon_name);
    cache.insert(icon_name.to_string(), result.clone());
    result
}

fn lookup_icon(icon_name: &str) -> Option<XdgIcon> {
    if let Some(path) = find_icon_path(icon_name) {
        debug!("icon '{icon_name}': direct match at {path:?}");
        return icon_from_path(path);
    }
    debug!("icon '{icon_name}': no direct match");

    if let Some(candidates) = find_similar_icon(icon_name) {
        for candidate in candidates {
            debug!("icon '{icon_name}': similar candidate '{candidate}'");
            if let Some(path) = find_icon_path(&candidate) {
                return icon_from_path(path);
            }
        }
    }
    debug!("icon '{icon_name}': no similar match");

    if let Some(path) = find_desktop_icon(icon_name) {
        debug!("icon '{icon_name}': desktop index match at {path:?}");
        return icon_from_path(path);
    }
    debug!("icon '{icon_name}': no desktop index match");

    if let Some(prefix_candidate) = prefix_match_icon(icon_name)
        && let Some(path) = find_icon_path(&prefix_candidate)
    {
        debug!("icon '{icon_name}': prefix match '{prefix_candidate}' at {path:?}");
        return icon_from_path(path);
    }
    debug!("icon '{icon_name}': no prefix match — unresolved");

    None
}

fn icon_from_path(path: PathBuf) -> Option<XdgIcon> {
    if path.extension().is_some_and(|ext| ext == "svg") {
        debug!("svg icon found. Path: {path:?}");

        Some(XdgIcon::Svg(svg::Handle::from_path(path)))
    } else {
        debug!("raster icon found. Path: {path:?}");

        Some(XdgIcon::Image(image::Handle::from_path(path)))
    }
}

fn find_icon_path(icon_name: &str) -> Option<PathBuf> {
    let base_lookup = lookup(icon_name).with_cache();

    match get_icon_theme() {
        Some(theme) => base_lookup.with_theme(&theme).find().or_else(|| {
            let fallback_lookup = lookup(icon_name).with_cache();
            fallback_lookup.find()
        }),
        None => base_lookup.find(),
    }
}

fn find_similar_icon(icon_name: &str) -> Option<Vec<Cow<'static, str>>> {
    if SYSTEM_ICON_ENTRIES.is_empty() {
        return None;
    }

    let normalized = normalize_icon_name(icon_name);
    let normalized_no_separators = strip_icon_separators(normalized.as_ref());
    let mut matches: Vec<Cow<'static, str>> = Vec::with_capacity(MAX_SIMILAR_ICON_CANDIDATES);

    for (candidate_name, candidate_normalized) in SYSTEM_ICON_ENTRIES.iter() {
        if candidate_normalized.as_ref() == normalized.as_ref() {
            continue;
        }

        if candidate_normalized.as_ref().contains(normalized.as_ref())
            || normalized.as_ref().contains(candidate_normalized.as_ref())
            || candidate_normalized
                .as_ref()
                .contains(normalized_no_separators.as_ref())
        {
            matches.push(Cow::Borrowed(candidate_name.as_ref()));
            if matches.len() >= MAX_SIMILAR_ICON_CANDIDATES {
                break;
            }
        }
    }

    if matches.is_empty() {
        None
    } else {
        Some(matches)
    }
}

fn normalize_icon_name(name: &str) -> Cow<'_, str> {
    if name
        .chars()
        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
    {
        return Cow::Borrowed(name);
    }

    Cow::Owned(
        name.to_lowercase()
            .chars()
            .filter(|c| c.is_ascii_alphanumeric())
            .collect(),
    )
}

fn strip_icon_separators(name: &str) -> Cow<'_, str> {
    if name.bytes().all(|byte| byte != b'-' && byte != b'_') {
        return Cow::Borrowed(name);
    }

    Cow::Owned(name.chars().filter(|ch| *ch != '-' && *ch != '_').collect())
}

fn prefix_match_icon(icon_name: &str) -> Option<Cow<'static, str>> {
    if SYSTEM_ICON_ENTRIES.is_empty() {
        return None;
    }

    let normalized = normalize_icon_name(icon_name);

    if let Some(exact) = SYSTEM_ICON_ENTRIES
        .iter()
        .find(|(_, norm)| norm.as_ref() == normalized.as_ref())
    {
        return Some(Cow::Borrowed(exact.0.as_ref()));
    }

    let mut candidates: Vec<_> = SYSTEM_ICON_ENTRIES.iter().collect();
    for (idx, ch) in normalized.chars().enumerate() {
        candidates.retain(|(_, name)| name.chars().nth(idx) == Some(ch));

        if candidates.len() == 1 {
            return Some(Cow::Borrowed(candidates[0].0.as_ref()));
        }

        if candidates.is_empty() {
            break;
        }
    }

    candidates
        .first()
        .map(|(name, _)| Cow::Borrowed(name.as_ref()))
}

fn find_desktop_icon(icon_name: &str) -> Option<PathBuf> {
    let normalized = normalize_icon_name(icon_name);
    let normalized_str: String = normalized.into_owned();
    let Some(icon_value) = DESKTOP_ICON_INDEX.get(&normalized_str) else {
        debug!("icon '{icon_name}': normalized '{normalized_str}' not in desktop index");
        return None;
    };
    debug!("icon '{icon_name}': desktop index '{normalized_str}' → '{icon_value}'");

    if icon_value.starts_with('/') {
        let path = PathBuf::from(icon_value);
        if path.exists() {
            Some(path)
        } else {
            debug!("icon '{icon_name}': absolute path '{icon_value}' does not exist");
            None
        }
    } else {
        find_icon_path(icon_value)
    }
}

fn build_desktop_icon_index() -> HashMap<String, String> {
    let mut map = HashMap::new();

    for dir in desktop_application_dirs() {
        if !dir.is_dir() {
            continue;
        }
        if let Ok(entries) = fs::read_dir(&dir) {
            for entry in entries.flatten() {
                let path = entry.path();
                if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
                    continue;
                }
                parse_desktop_file(&path, &mut map);
            }
        }
    }

    map
}

const MAX_DESKTOP_FILE_SIZE: u64 = 64 * 1024;

fn parse_desktop_file(path: &Path, map: &mut HashMap<String, String>) {
    let Ok(metadata) = fs::metadata(path) else {
        return;
    };

    if metadata.len() > MAX_DESKTOP_FILE_SIZE {
        debug!("desktop file too large: {}", path.display());
        return;
    }

    let Ok(contents) = fs::read_to_string(path) else {
        return;
    };

    let stem = path
        .file_stem()
        .and_then(|s| s.to_str())
        .unwrap_or_default();

    let mut icon_value: Option<String> = None;
    let mut wm_class: Option<String> = None;

    for line in contents.lines() {
        if let Some(val) = line.strip_prefix("Icon=") {
            icon_value = Some(val.trim().to_string());
        } else if let Some(val) = line.strip_prefix("StartupWMClass=") {
            wm_class = Some(val.trim().to_string());
        }
    }

    let Some(icon) = icon_value else {
        return;
    };

    if let Some(wm) = wm_class {
        let key = normalize_icon_name(&wm).into_owned();
        map.entry(key).or_insert_with(|| icon.clone());
    }
    let parts: Vec<&str> = stem.split('.').collect();
    for start in 0..parts.len() {
        let key = normalize_icon_name(&parts[start..].join(".")).into_owned();
        map.entry(key).or_insert_with(|| icon.clone());
    }
}

fn desktop_application_dirs() -> Vec<PathBuf> {
    let mut dirs = Vec::new();

    if let Ok(data_home) = env::var("XDG_DATA_HOME") {
        dirs.push(PathBuf::from(data_home).join("applications"));
    }

    if let Ok(home) = env::var("HOME") {
        dirs.push(PathBuf::from(home).join(".local/share/applications"));
    }

    let data_dirs =
        env::var("XDG_DATA_DIRS").unwrap_or_else(|_| "/usr/local/share:/usr/share".into());
    for dir in data_dirs.split(':') {
        if !dir.is_empty() {
            dirs.push(PathBuf::from(dir).join("applications"));
        }
    }

    dirs.push(PathBuf::from("/usr/local/share/applications"));
    dirs.push(PathBuf::from("/usr/share/applications"));

    dirs.sort();
    dirs.dedup();
    dirs
}

fn load_system_icon_names() -> BTreeSet<OsString> {
    let mut names = BTreeSet::new();

    for dir in icon_directories() {
        if !dir.is_dir() {
            continue;
        }

        collect_icon_names_recursive(&dir, &mut names);
    }

    names
}

fn collect_icon_names_recursive(dir: &Path, names: &mut BTreeSet<OsString>) {
    if let Ok(entries) = fs::read_dir(dir) {
        for entry in entries.flatten() {
            let path = entry.path();
            if let Ok(file_type) = entry.file_type() {
                if file_type.is_dir() {
                    collect_icon_names_recursive(&path, names);
                } else if file_type.is_file()
                    && let Some(stem) = path.file_stem()
                {
                    names.insert(stem.to_os_string());
                }
            }
        }
    }
}

fn icon_directories() -> Vec<PathBuf> {
    let mut dirs = Vec::new();

    if let Ok(data_home) = env::var("XDG_DATA_HOME") {
        let base = PathBuf::from(data_home);
        dirs.push(base.join("icons"));
        dirs.push(base.join("pixmaps"));
    }

    if let Ok(home) = env::var("HOME") {
        let base = PathBuf::from(home);
        dirs.push(base.join(".local/share/icons"));
        dirs.push(base.join(".local/share/pixmaps"));
    }

    let data_dirs =
        env::var("XDG_DATA_DIRS").unwrap_or_else(|_| "/usr/local/share:/usr/share".into());
    for dir in data_dirs.split(':') {
        if dir.is_empty() {
            continue;
        }
        let base = PathBuf::from(dir);
        dirs.push(base.join("icons"));
        dirs.push(base.join("pixmaps"));
    }

    dirs.push(PathBuf::from("/usr/share/icons"));
    dirs.push(PathBuf::from("/usr/share/pixmaps"));

    dirs.sort();
    dirs.dedup();
    dirs
}

gwiazdorrr added a commit to gwiazdorrr/ashell that referenced this pull request Mar 24, 2026
@gwiazdorrr
Copy link
Copy Markdown
Contributor Author

Looking at the code this feels in many cases AI generated. This is not a problem, but makes it REALLY hard to understand the code quickly, and every change and review changes a lot of things again needing to understand the code from scratch. Not sure how to deal with this yes.

This PR does contain some AI assistance. The "AI code review fatigue" is something I struggle with myself at work, sorry for bringing this upon you. Rebasing surely did not help the clarity, either. The solution is, well, higher standards on the submitter's end.

Display application icons next to workspace names, similar to waybar's
window-rewrite feature.

New config options:
  [workspaces]
  indicator_format = "NameAndIcons"  # or "Name" (default)
  window_icons = { "firefox" = "󰈹", "code" = "󰨞" }
  default_window_icon = "󰏗"

Window class to icon mappings use case-insensitive matching with an
optional default icon. Window class collection from compositors is gated
behind an AtomicBool flag so the feature has no cost when disabled.
Move the XDG icon resolution pipeline (direct lookup, fuzzy matching,
prefix matching) from the tray module into a shared xdg_icons module.
Workspace icons now resolve automatically via XDG without requiring
manual glyph config, with priority: user config > XDG lookup > default.
Drop window_icons and default_window_icon config fields, remove
WorkspaceIcon enum, and inline the tray get_icon_from_name wrapper.
Workspace icons now resolve exclusively via XDG desktop icon lookup.
get_icon_from_name() is called on every compositor event for every
unique window class. Add a process-wide HashMap cache so the expensive
resolution chain (freedesktop lookup, similar names, prefix match) only
runs once per icon name.
…lasses

Add configurable active_workspace_colors to override workspace indicator
colors for the active workspace, following the same per-monitor pattern as
workspace_colors. Use Nerd Font point glyph as fallback when XDG icon
lookup fails for a window class.
Replace collect-then-iterate with an eager find_similar_icon that resolves
each candidate immediately and returns on first hit. The old 5-candidate
collect limit could cut off valid matches that sorted alphabetically after
earlier misses.
…dd debug logging

Index all dot-suffixes of reverse-DNS desktop file stems (e.g. `com.ultimaker.cura` also registers `ultimaker.cura` and `cura` as keys), fixing lookup failures where `StartupWMClass` is absent
Guard `get_icon_from_name` and `find_similar_icon` against empty/blank icon names
Add `debug!` trace at each step of `lookup_icon` and `find_desktop_icon` to make resolution failures diagnosable via `RUST_LOG=ashell::services::xdg_icons=debug`
- UiWorkspace.icons: Vec -> Option<Vec> (None when feature is off)
- resolve_workspace_icons: drop config param, gate at call sites with .then(|| ...)
- Replace HashSet dedup loop with .unique().map().collect() iterator chain
- Remove needs_window_classes helper, inline condition at both call sites
- Import set_collect_window_classes via use instead of full path
- Inline let i = i as usize into colors.get(i as usize)
- Replace mut children + extend with iter::once().chain().collect()
@gwiazdorrr gwiazdorrr force-pushed the feature/window-icons branch from 85eb968 to 1e3ecd4 Compare March 25, 2026 00:03
Copy link
Copy Markdown
Owner

@MalpenZibo MalpenZibo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see the new xdg_icons.rs. Please be sure to NOT introduce regression to the Tray module. I notice that you deleted some code from the tray module. I presume that xdg_icons is the new home of that code. Am I right, or are there some changes?

Comment thread src/components/icons.rs Outdated
Comment thread src/config.rs
pub text_color: AppearanceColor,
pub workspace_colors: Vec<AppearanceColor>,
pub special_workspace_colors: Option<Vec<AppearanceColor>>,
pub active_workspace_colors: Option<Vec<AppearanceColor>>,
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new config? Is it related to the window icon? Could be placed in a dedicated PR?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could extract it, but: it is only needed for when icons are enabled, as then with the visual clutter you might not notice the "capsule" resizing.

static BROADCASTER: OnceCell<broadcast::Sender<ServiceEvent<CompositorService>>> =
OnceCell::const_new();

static COLLECT_WINDOW_CLASSES: AtomicBool = AtomicBool::new(false);
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So every kind of configuration should be sent in the subscribe() service function. In theory, it should also be used in the Subscription::run_with(TypeId::of::<Self>() as id, for example run_with((TypeId::of::<Self>(), configuration_x), .... This tells iced to recreate the subscription if the id changes.

TypeId::of::() is constant, so adding the configuration_x in the tuple should recreate the subscription only when the configuration_x value changes.

In this case, we should send an indicator_format == WorkspaceIndicatorFormat::NameAndIcons to know if we had to collect the class name, if I understand correctly the changes.

Comment thread src/modules/workspaces.rs

impl Workspaces {
pub fn new(config: WorkspacesModuleConfig) -> Self {
set_collect_window_classes(
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part should go in the subscription method of the module. We should pass config.indicator_format == WorkspaceIndicatorFormat::NameAndIcons as parameter to the service subscribe method

Copy link
Copy Markdown
Owner

@MalpenZibo MalpenZibo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some changes are not directly related to this PR, but let's fix them anyway 😄

Comment thread src/modules/workspaces.rs Outdated
Comment thread src/modules/workspaces.rs Outdated
Comment thread src/modules/workspaces.rs Outdated
Comment thread src/modules/workspaces.rs Outdated
Comment thread src/services/xdg_icons.rs Outdated
Comment thread src/modules/workspaces.rs Outdated
Comment thread src/modules/workspaces.rs Outdated
Comment thread src/modules/workspaces.rs Outdated
Comment thread src/modules/workspaces.rs Outdated
Comment thread src/modules/workspaces.rs Outdated
gwiazdorrr and others added 2 commits April 15, 2026 23:53
Co-authored-by: Simone Camito <32327779+MalpenZibo@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants