From 7435e6560a25e5bcc10656a2888dfe6c18879e84 Mon Sep 17 00:00:00 2001 From: Xytras <44824049+xytras1@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:16:54 -0400 Subject: [PATCH] Add "Hidden icons" rules, applied at draw time Both existing ways to suppress an icon run at icon *creation*: an entry in ignored_entities.txt or in the hardcoded MinimapIcons.Ignored list makes IconsBuilder.SkipIcon return before an icon object exists. That has two consequences. An exclusion cannot be undone without an area change, because ReadIgnoreFile only runs on AreaChange and the icon was never built; and the hardcoded list cannot be undone at all without editing the plugin. There is also no way to add one from the UI. This adds a ContentNode of rules to the settings menu, each with a label, an unanchored metadata regex, and its own Hide toggle. They are applied in the draw loop instead, so toggling one takes effect on the next frame in both directions, and an unwanted icon can be silenced without a restart or a rebuild. The list ships empty, so behaviour is unchanged unless a rule is added. Render() runs every frame, so the matching is kept cheap: regexes compile once per pattern, path -> hide answers are cached, and the cache is invalidated by a per-frame integer hash of the rule set rather than by rebuilding a string. A pattern that fails to compile is logged once and skipped, since a half-typed regex is a normal state while editing the field. Co-Authored-By: Claude Opus 5 (1M context) --- MapIconsSettings.cs | 68 +++++++++++++++++++++++++++++++++++ MinimapIcons.cs | 88 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) 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);