diff --git a/MapIconsSettings.cs b/MapIconsSettings.cs
index 4195147..d114d28 100644
--- a/MapIconsSettings.cs
+++ b/MapIconsSettings.cs
@@ -2,9 +2,60 @@
using ExileCore.Shared.Interfaces;
using ExileCore.Shared.Nodes;
using MinimapIcons.IconsBuilder;
+using System;
namespace MinimapIcons;
+///
+/// One "never draw an icon for this" entry, editable from the plugin menu.
+///
+///
+/// The existing ignore mechanisms are both applied when the icon is *built*: entries in
+/// ignored_entities.txt and in make
+/// IconsBuilder.SkipIcon return before an icon object exists. That is fine for a
+/// permanent exclusion, but it means an entry cannot be undone without an area change, and
+/// the hardcoded list cannot be undone at all. These rules are applied at draw time instead,
+/// so toggling one takes effect on the next frame in both directions.
+///
+[Submenu]
+public class IconIgnoreRule
+{
+ public IconIgnoreRule()
+ {
+ }
+
+ public IconIgnoreRule(string label, string metadataRegex, bool hide)
+ {
+ Label = new TextNode(label);
+ MetadataRegex = new TextNode(metadataRegex);
+ Hide = new ToggleNode(hide);
+ }
+
+ [Menu("Hide", "On = matching entities get no icon. Off = they draw as normal.")]
+ public ToggleNode Hide { get; set; } = new ToggleNode(true);
+
+ [Menu("Label", "Free text, so the row is recognisable in this list. Not used for matching.")]
+ public TextNode Label { get; set; } = new TextNode("");
+
+ [Menu("Metadata regex", "Unanchored regex tested against the entity metadata path.")]
+ public TextNode MetadataRegex { get; set; } = new TextNode("");
+
+ // Names the row in the settings UI, which otherwise falls back to object.ToString() and shows
+ // every row as "MinimapIcons.IconIgnoreRule". The trailing ### keeps the ImGui id of the row
+ // stable while the visible part of the label changes, so the row does not collapse itself
+ // while you are typing in the label or pattern field.
+ public override string ToString()
+ {
+ var label = !string.IsNullOrWhiteSpace(Label?.Value)
+ ? Label.Value
+ : !string.IsNullOrWhiteSpace(MetadataRegex?.Value)
+ ? MetadataRegex.Value
+ : "(empty rule)";
+
+ return $"{label}###";
+ }
+}
+
public class MapIconsSettings : ISettings
{
public ToggleNode DrawMonsters { get; set; } = new ToggleNode(true);
@@ -21,6 +72,23 @@ public class MapIconsSettings : ISettings
public RangeNode IconListRefreshPeriod { get; set; } = new RangeNode(100, 0, 1000);
public ToggleNode HighlightHiddenMonsters { get; set; } = new ToggleNode(true);
+ [Menu("Hidden icons", "Entities you never want an icon for, each with its own on/off. Unlike " +
+ "ignored_entities.txt these are applied at draw time, so a toggle takes effect " +
+ "immediately and can be undone without changing areas. Empty by default.",
+ CollapsedByDefault = true)]
+ public ContentNode HiddenIcons { get; set; } =
+ new ContentNode()
+ {
+ Content =
+ [
+ ],
+ EnableControls = true,
+ EnableItemCollapsing = true,
+ ItemFactory = () => new IconIgnoreRule(),
+ ItemFilter = (o, s) => o.Label.Value.Contains(s, StringComparison.OrdinalIgnoreCase) ||
+ o.MetadataRegex.Value.Contains(s, StringComparison.OrdinalIgnoreCase),
+ };
+
[Menu(null, CollapsedByDefault = true)]
public ContentNode AlwaysShownIngameIcons { get; set; } =
new ContentNode()
diff --git a/MinimapIcons.cs b/MinimapIcons.cs
index c4d2446..2aadddd 100644
--- a/MinimapIcons.cs
+++ b/MinimapIcons.cs
@@ -11,6 +11,7 @@
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
+using System.Text.RegularExpressions;
using Color = SharpDX.Color;
using RectangleF = SharpDX.RectangleF;
using Vector2 = System.Numerics.Vector2;
@@ -158,6 +159,18 @@ public class MinimapIcons : BaseSettingsPlugin
private readonly Dictionary IgnoreCache = new Dictionary();
+ // Compiled regexes for Settings.HiddenIcons keyed by pattern text, plus the per-path answers
+ // derived from them. A breach puts hundreds of icons behind a handful of distinct paths, so
+ // this turns the regex work into a dictionary hit after the first sighting of each path.
+ private readonly Dictionary _hiddenIconRegexes = new(StringComparer.Ordinal);
+ private readonly HashSet _badHiddenIconPatterns = new(StringComparer.Ordinal);
+ private readonly Dictionary _hiddenIconCache = new(StringComparer.Ordinal);
+
+ // Cheap hash of the rule set, so an edit in the menu invalidates the cache above. Recomputed
+ // once per frame rather than per icon, and an int rather than a joined string, because
+ // Render() runs every frame and should not allocate.
+ private int _hiddenIconSignature;
+
private IngameUIElements _ingameUi;
private bool? _largeMap;
private float _mapScale;
@@ -257,6 +270,8 @@ public override void Render()
var baseIcons = _iconListCache.Value;
if (baseIcons == null) return;
+ RefreshHiddenIconRules();
+
foreach (var icon in baseIcons)
{
if (icon?.Entity == null) continue;
@@ -267,6 +282,9 @@ public override void Render()
if (IgnoreCache.GetOrAdd(icon.Entity.Path, () => Ignored.Any(x => icon.Entity.Path.StartsWith(x))))
continue;
+ if (IsHiddenByRule(icon.Entity.Path))
+ continue;
+
if (icon.Entity.Path.StartsWith(
"Metadata/Monsters/AtlasExiles/BasiliskInfluenceMonsters/BasiliskBurrowingViper", StringComparison.Ordinal)
&& icon.Entity.Rarity != MonsterRarity.Unique)
@@ -316,6 +334,76 @@ icon is not CustomIcon &&
}
}
+ ///
+ /// Drops the cached per-path answers whenever a rule's toggle or pattern changes, so an edit
+ /// in the menu takes effect on the next frame. Called once per frame, before the icon loop.
+ ///
+ private void RefreshHiddenIconRules()
+ {
+ var rules = Settings.HiddenIcons?.Content;
+ var signature = 17;
+ if (rules != null)
+ {
+ foreach (var rule in rules)
+ {
+ if (rule == null) continue;
+ signature = signature * 31 + (rule.Hide.Value ? 1 : 0);
+ signature = signature * 31 + (rule.MetadataRegex?.Value?.GetHashCode() ?? 0);
+ }
+ }
+
+ if (signature == _hiddenIconSignature) return;
+ _hiddenIconSignature = signature;
+ _hiddenIconCache.Clear();
+ }
+
+ private bool IsHiddenByRule(string path)
+ {
+ if (string.IsNullOrEmpty(path)) return false;
+ var rules = Settings.HiddenIcons?.Content;
+ if (rules == null || rules.Count == 0) return false;
+ if (_hiddenIconCache.TryGetValue(path, out var cached)) return cached;
+
+ var hidden = false;
+ foreach (var rule in rules)
+ {
+ if (rule == null || !rule.Hide.Value) continue;
+ var pattern = rule.MetadataRegex?.Value;
+ if (string.IsNullOrWhiteSpace(pattern)) continue;
+
+ var regex = GetHiddenIconRegex(pattern);
+ if (regex != null && regex.IsMatch(path))
+ {
+ hidden = true;
+ break;
+ }
+ }
+
+ _hiddenIconCache[path] = hidden;
+ return hidden;
+ }
+
+ private Regex GetHiddenIconRegex(string pattern)
+ {
+ if (_hiddenIconRegexes.TryGetValue(pattern, out var cached)) return cached;
+ // A half-typed pattern is a normal state while editing the field, so a pattern that fails
+ // to compile is reported once and then skipped rather than logged every frame.
+ if (_badHiddenIconPatterns.Contains(pattern)) return null;
+
+ try
+ {
+ var regex = new Regex(pattern, RegexOptions.Compiled);
+ _hiddenIconRegexes[pattern] = regex;
+ return regex;
+ }
+ catch (ArgumentException ex)
+ {
+ _badHiddenIconPatterns.Add(pattern);
+ LogError($"MinimapIcons: invalid hidden icon regex '{pattern}' -- rule skipped. {ex.Message}");
+ return null;
+ }
+ }
+
private const float CameraAngle = 38.7f * MathF.PI / 180;
private static readonly float CameraAngleCos = MathF.Cos(CameraAngle);
private static readonly float CameraAngleSin = MathF.Sin(CameraAngle);