Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- **Default excluded paths are now empty** - Excluded path presets (e.g., VRCFury temp) are no longer applied by default; they remain available via the UI "Add Path" menu
- **Texture filter flags no longer managed by presets** - `ProcessMainTextures`, `ProcessNormalMaps`, `ProcessEmissionMaps`, and `ProcessOtherTextures` are now per-component settings that persist across preset changes, consistent with other filter settings (Data Protection, Path Exclusions)
- **Separate search boxes for Frozen Textures and Preview sections** - Each section now owns its own search state, allowing independent filtering; search boxes use Unity's standard `SearchField` control
- **Texture type label relocated in preview entries** - Moved below the thumbnail for better visual grouping
- **Analysis backends return raw complexity scores** - `ITextureAnalysisBackend.AnalyzeBatch` now returns `Dictionary<Texture2D, float>` (0–1 scores) instead of fully built `TextureAnalysisResult`; score→divisor→resolution conversion moved to the service layer via `AnalysisResultHelper`
- `CpuAnalysisBackend`, `GpuAnalysisBackend`, `AnalysisBackendFactory`, and `TextureAnalyzer` no longer depend on `ComplexityCalculator`
- Backend contract documented: returned keys must be a subset of input keys; analysis failures on valid textures return a default score rather than dropping the entry
Expand All @@ -29,6 +31,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Resolve original asset path via ObjectRegistry for textures replaced by other NDMF plugins
- Collect all material references for shared textures regardless of per-property type filter
- Disable preview entry when frozen state changes in either direction (freeze or unfreeze) since the previous scan
- Frozen texture search matches the GUID fallback when the asset path cannot be resolved, consistent with the displayed text
- Invalidate the GUID-to-path cache on Unity project changes so renamed/moved assets resolve correctly
- Freeze/Unfreeze button in Preview and Frozen entries is no longer pushed off-screen by long texture names; entries are capped to the inspector view width and the texture name label clips when it would overlap the button

## [v0.7.0] - 2026-03-27

Expand Down
259 changes: 99 additions & 160 deletions Editor/Common/UI/Controls/SearchBoxControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,21 @@
using System.Collections.Generic;
using dev.limitex.avatar.compressor.editor;
using UnityEditor;
using UnityEditor.IMGUI.Controls;
using UnityEngine;

namespace dev.limitex.avatar.compressor.editor.ui
{
/// <summary>
/// A reusable search box control with fuzzy search support and result caching.
/// Reusable search control with fuzzy search support.
/// Holds search state, draws the UI via Unity's <see cref="SearchField"/>, and provides matching logic.
/// </summary>
public class SearchBoxControl
{
/// <summary>
/// Current search text.
/// </summary>
public string SearchText { get; private set; } = "";
public string SearchText { get; private set; }

/// <summary>
/// Whether fuzzy search is enabled.
Expand All @@ -26,131 +28,126 @@ public class SearchBoxControl
/// </summary>
public bool IsSearching => !string.IsNullOrEmpty(SearchText);

private readonly SearchField _searchField = new();

// Count cache — avoids recomputing every IMGUI frame
private int _cachedCount;
private string _cachedCountSearchText;
private bool _cachedCountUseFuzzy;
private object _cachedSourceRef;
private int _cachedSourceCount;
private object _cachedPredicate;

/// <summary>
/// Event fired when search text or fuzzy search option changes.
/// Creates a new SearchBoxControl with optional initial state.
/// </summary>
public event Action OnSearchChanged;

// Cache state
private string _cachedSearchText;
private bool _cachedUseFuzzySearch;
private HashSet<int> _cachedMatchIndices;
private Func<int, bool> _matchFunction;
private int _cachedItemCount;
/// <param name="searchText">Initial search text.</param>
/// <param name="useFuzzySearch">Initial fuzzy search state.</param>
public SearchBoxControl(string searchText = "", bool useFuzzySearch = false)
{
SearchText = searchText;
UseFuzzySearch = useFuzzySearch;
}

/// <summary>
/// Draws the search box UI.
/// Draws the search box UI and updates internal state.
/// </summary>
/// <param name="matchedCount">Number of matched items (-1 to hide the summary).</param>
/// <param name="totalCount">Total number of items (-1 to hide the summary).</param>
/// <returns>True if search parameters changed.</returns>
public bool Draw()
public bool Draw(int matchedCount = -1, int totalCount = -1)
{
bool changed = false;

EditorGUILayout.BeginVertical(EditorStyles.helpBox);
EditorGUILayout.BeginHorizontal();

// Search icon
var searchIcon = EditorGUIUtility.IconContent("d_Search Icon");
if (searchIcon != null && searchIcon.image != null)
{
GUILayout.Label(searchIcon, GUILayout.Width(20), GUILayout.Height(18));
}

// Text field with placeholder
var textFieldRect = EditorGUILayout.GetControlRect();
GUI.SetNextControlName("SearchField");
EditorGUI.BeginChangeCheck();
var newSearchText = EditorGUI.TextField(textFieldRect, SearchText);
if (EditorGUI.EndChangeCheck())
var newText = _searchField.OnGUI(SearchText);
if (newText != SearchText)
{
SearchText = newSearchText;
SearchText = newText;
changed = true;
}

// Draw placeholder when empty and not focused
if (string.IsNullOrEmpty(SearchText) && GUI.GetNameOfFocusedControl() != "SearchField")
{
var placeholderRect = textFieldRect;
placeholderRect.x += 3;
EditorGUI.LabelField(
placeholderRect,
"Search textures...",
EditorStylesCache.PlaceholderStyle
);
}

// Clear button
EditorGUI.BeginDisabledGroup(string.IsNullOrEmpty(SearchText));
if (GUILayout.Button("Clear", GUILayout.Width(50)))
{
SearchText = "";
GUI.FocusControl(null);
changed = true;
}
EditorGUI.EndDisabledGroup();

EditorGUILayout.EndHorizontal();

// Show options when searching
if (!string.IsNullOrEmpty(SearchText))
if (IsSearching)
{
EditorGUILayout.BeginHorizontal();
GUILayout.Space(30);

// Fuzzy search toggle
EditorGUI.BeginChangeCheck();
var newUseFuzzySearch = EditorGUILayout.ToggleLeft(
var fuzzy = EditorGUILayout.ToggleLeft(
"Fuzzy",
UseFuzzySearch,
GUILayout.Width(55)
);
if (EditorGUI.EndChangeCheck())
{
UseFuzzySearch = newUseFuzzySearch;
UseFuzzySearch = fuzzy;
changed = true;
}

EditorGUILayout.EndHorizontal();
}
GUILayout.FlexibleSpace();

EditorGUILayout.EndVertical();
if (totalCount >= 0)
{
GUILayout.Label(
$"Showing {matchedCount} of {totalCount}",
EditorStyles.miniLabel
);
}

if (changed)
{
InvalidateCache();
OnSearchChanged?.Invoke();
EditorGUILayout.EndHorizontal();
}

return changed;
Comment thread
Limitex marked this conversation as resolved.
}

/// <summary>
/// Draws the hit count display.
/// Returns the number of items matching the current search, with caching.
/// When not searching, returns <c>items.Count</c> without invoking the predicate.
/// The cache is keyed on (SearchText, UseFuzzySearch, collection reference, items.Count, predicate)
/// and is automatically invalidated when any of these change.
/// </summary>
/// <param name="totalHits">Total number of matching items.</param>
/// <param name="frozenHits">Number of frozen texture matches.</param>
/// <param name="previewHits">Number of preview texture matches.</param>
public void DrawHitCount(int totalHits, int frozenHits, int previewHits)
/// <param name="items">Source collection.</param>
/// <param name="predicate">Per-item match function (only called on cache miss).</param>
public int CountMatches<T>(IReadOnlyCollection<T> items, Func<T, bool> predicate)
{
if (string.IsNullOrEmpty(SearchText))
return;
if (!IsSearching)
return items.Count;

string hitText = totalHits == 1 ? "1 hit" : $"{totalHits} hits";
if (frozenHits > 0 || previewHits > 0)
if (
SearchText == _cachedCountSearchText
&& UseFuzzySearch == _cachedCountUseFuzzy
&& ReferenceEquals(items, _cachedSourceRef)
&& items.Count == _cachedSourceCount
&& Equals(predicate, _cachedPredicate)
)
{
return _cachedCount;
}

int count = 0;
foreach (var item in items)
{
hitText += $" (Frozen: {frozenHits}, Preview: {previewHits})";
if (predicate(item))
count++;
}
EditorGUILayout.LabelField(hitText, EditorStylesCache.HitCountStyle);

_cachedCount = count;
_cachedCountSearchText = SearchText;
_cachedCountUseFuzzy = UseFuzzySearch;
_cachedSourceRef = items;
_cachedSourceCount = items.Count;
_cachedPredicate = predicate;
return count;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// <summary>
/// Draws a hidden count indicator.
/// Invalidates the count cache, forcing <see cref="CountMatches{T}"/> to recount
/// on its next call. Use when the source data changes without a count change
/// (e.g. preview regeneration).
/// </summary>
/// <param name="hiddenCount">Number of hidden items.</param>
public static void DrawHiddenCount(int hiddenCount)
public void InvalidateCountCache()
{
string hiddenText = hiddenCount == 1 ? "1 hidden" : $"{hiddenCount} hidden";
EditorGUILayout.LabelField(hiddenText, EditorStylesCache.HiddenCountStyle);
_cachedCountSearchText = null;
_cachedSourceRef = null;
_cachedPredicate = null;
}

/// <summary>
Expand All @@ -160,61 +157,52 @@ public void Clear()
{
SearchText = "";
UseFuzzySearch = false;
InvalidateCache();
InvalidateCountCache();
}

/// <summary>
/// Invalidates the search cache, forcing recalculation on next access.
/// Checks if a string matches the current search.
/// When not searching, always returns true (all items visible).
/// When searching, returns false for null/empty text (no content to match against).
/// </summary>
public void InvalidateCache()
/// <param name="text">Text to match against.</param>
/// <returns>True if text matches search, or true if not searching.</returns>
public bool MatchesSearch(string text)
{
_cachedMatchIndices = null;
_cachedSearchText = null;
_cachedItemCount = 0;
if (!IsSearching)
return true;
return MatchesCore(text);
}

/// <summary>
/// Checks if an item at the given index matches the search.
/// Checks if either of the provided strings matches the current search.
/// </summary>
/// <param name="index">Item index.</param>
/// <param name="itemCount">Total item count.</param>
/// <param name="matchFunction">Function that returns true if item at index matches search.</param>
/// <returns>True if item matches or no search is active.</returns>
public bool IsMatch(int index, int itemCount, Func<int, bool> matchFunction)
public bool MatchesSearchAny(string text1, string text2)
{
if (!IsSearching)
return true;

UpdateCache(itemCount, matchFunction);
return _cachedMatchIndices?.Contains(index) ?? false;
return MatchesCore(text1) || MatchesCore(text2);
}

/// <summary>
/// Counts the number of matching items.
/// Checks if any of the three provided strings matches the current search.
/// </summary>
/// <param name="itemCount">Total item count.</param>
/// <param name="matchFunction">Function that returns true if item at index matches search.</param>
/// <returns>Number of matching items.</returns>
public int CountMatches(int itemCount, Func<int, bool> matchFunction)
public bool MatchesSearchAny(string text1, string text2, string text3)
{
if (!IsSearching)
return itemCount;
return true;

UpdateCache(itemCount, matchFunction);
return _cachedMatchIndices?.Count ?? 0;
return MatchesCore(text1) || MatchesCore(text2) || MatchesCore(text3);
}

/// <summary>
/// Checks if a string matches the current search.
/// Core matching logic. Assumes IsSearching is true.
/// </summary>
/// <param name="text">Text to match against.</param>
/// <returns>True if text matches search.</returns>
public bool MatchesSearch(string text)
private bool MatchesCore(string text)
{
if (string.IsNullOrEmpty(text))
return false;
if (!IsSearching)
return true;

if (UseFuzzySearch)
{
Expand All @@ -225,54 +213,5 @@ public bool MatchesSearch(string text)
return text.IndexOf(SearchText, StringComparison.OrdinalIgnoreCase) >= 0;
}
}

/// <summary>
/// Checks if any of the provided strings matches the current search.
/// </summary>
/// <param name="texts">Texts to match against.</param>
/// <returns>True if any text matches search.</returns>
public bool MatchesSearchAny(params string[] texts)
{
if (!IsSearching)
return true;

foreach (var text in texts)
{
if (MatchesSearch(text))
return true;
}
return false;
}

private void UpdateCache(int itemCount, Func<int, bool> matchFunction)
{
// Check if cache is valid
if (
_cachedSearchText == SearchText
&& _cachedUseFuzzySearch == UseFuzzySearch
&& _cachedItemCount == itemCount
&& _matchFunction == matchFunction
&& _cachedMatchIndices != null
)
{
return;
}

// Update cache state
_cachedSearchText = SearchText;
_cachedUseFuzzySearch = UseFuzzySearch;
_cachedItemCount = itemCount;
_matchFunction = matchFunction;

// Rebuild cache
_cachedMatchIndices = new HashSet<int>();
for (int i = 0; i < itemCount; i++)
{
if (matchFunction(i))
{
_cachedMatchIndices.Add(i);
}
}
}
}
}
Loading
Loading