@@ -36,6 +36,7 @@ IModelIndexService modelIndexService
3636 private bool filterOptionsLoaded ;
3737 private bool searchQueued ;
3838 private int currentPage = 1 ;
39+ private DispatcherTimer ? searchDebounceTimer ;
3940
4041 /// <summary>
4142 /// All search results we've fetched so far across pages, regardless of client-side filters.
@@ -95,6 +96,25 @@ IModelIndexService modelIndexService
9596 ? string . Empty
9697 : $ "{ AllBaseModels . Count ( x => x . IsSelected ) } of { AllBaseModels . Count } selected";
9798
99+ /// <summary>
100+ /// True when a partial selection is active — at least one selected and at least one
101+ /// deselected. The "all selected" stock state and the "none selected" degenerate state
102+ /// both render plain (no badge), since neither is a meaningful filter to surface.
103+ /// </summary>
104+ public bool HasModelTypeFilter =>
105+ AllModelTypes . Count > 0
106+ && AllModelTypes . Any ( x => x . IsSelected )
107+ && AllModelTypes . Any ( x => ! x . IsSelected ) ;
108+
109+ public bool HasBaseModelFilter =>
110+ AllBaseModels . Count > 0
111+ && AllBaseModels . Any ( x => x . IsSelected )
112+ && AllBaseModels . Any ( x => ! x . IsSelected ) ;
113+
114+ public int SelectedModelTypeCount => AllModelTypes . Count ( x => x . IsSelected ) ;
115+
116+ public int SelectedBaseModelCount => AllBaseModels . Count ( x => x . IsSelected ) ;
117+
98118 [ ObservableProperty ]
99119 private string searchQuery = string . Empty ;
100120
@@ -252,9 +272,54 @@ protected override async Task OnInitialLoadedAsync()
252272 EventManager . Instance . ModelIndexChanged += indexHandler ;
253273 AddDisposable ( Disposable . Create ( ( ) => EventManager . Instance . ModelIndexChanged -= indexHandler ) ) ;
254274
275+ // Debounce filter-driven searches so rapid toggles (e.g. picking a dozen base
276+ // models in a row) collapse into a single API call instead of N — CivArchive
277+ // 429s aggressively otherwise.
278+ searchDebounceTimer = new DispatcherTimer { Interval = TimeSpan . FromMilliseconds ( 300 ) } ;
279+ searchDebounceTimer . Tick += OnSearchDebounceElapsed ;
280+ AddDisposable (
281+ Disposable . Create ( ( ) =>
282+ {
283+ searchDebounceTimer . Stop ( ) ;
284+ searchDebounceTimer . Tick -= OnSearchDebounceElapsed ;
285+ } )
286+ ) ;
287+
288+ // Filter options (Model Type / Base Model dropdown contents) only come back
289+ // populated when the URL has no query string, so we have to fetch them with a
290+ // dedicated parameterless call before the first filtered search runs.
291+ await LoadFilterOptionsAsync ( ) ;
292+
255293 await SearchModels ( ) ;
256294 }
257295
296+ private async Task LoadFilterOptionsAsync ( )
297+ {
298+ if ( filterOptionsLoaded )
299+ {
300+ return ;
301+ }
302+
303+ try
304+ {
305+ var options = await civArchiveApiClient . GetFilterOptionsAsync ( ) ;
306+ ApplyFilterOptions ( options ) ;
307+ filterOptionsLoaded = true ;
308+ }
309+ catch ( Exception ex )
310+ {
311+ // Don't block the search itself — failure here just means the multi-select
312+ // dropdowns stay empty until the user reloads the page.
313+ NoResultsText = ex . Message ;
314+ }
315+ finally
316+ {
317+ // ApplyFilterOptions sets suppressSearch=true while it populates the option
318+ // collections; release it before the real search runs so user input flows.
319+ suppressSearch = false ;
320+ }
321+ }
322+
258323 partial void OnHideInstalledModelsChanged ( bool value ) => RebuildVisibleResults ( ) ;
259324
260325 private void OnLocalModelIndexChanged ( )
@@ -285,9 +350,42 @@ private void RebuildVisibleResults()
285350 OnPropertyChanged ( nameof ( IsEndOfResults ) ) ;
286351 }
287352
353+ private void OnSearchDebounceElapsed ( object ? sender , EventArgs e )
354+ {
355+ searchDebounceTimer ? . Stop ( ) ;
356+ _ = SearchModels ( ) ;
357+ }
358+
359+ /// <summary>
360+ /// Restart the debounce timer. The actual search runs once the user pauses for the
361+ /// timer's interval — multiple rapid filter changes within that window collapse into
362+ /// a single fetch.
363+ /// </summary>
364+ private void RequestDebouncedSearch ( )
365+ {
366+ if ( suppressSearch )
367+ {
368+ return ;
369+ }
370+
371+ SaveSettings ( ) ;
372+
373+ if ( ! HasSearched )
374+ {
375+ return ;
376+ }
377+
378+ searchDebounceTimer ? . Stop ( ) ;
379+ searchDebounceTimer ? . Start ( ) ;
380+ }
381+
288382 [ RelayCommand ]
289383 private async Task SearchModels ( bool isInfiniteScroll = false )
290384 {
385+ // Cancel any pending debounced search so an explicit invocation (search button,
386+ // ResetFilters, etc.) doesn't get shadowed by a redundant fire 300ms later.
387+ searchDebounceTimer ? . Stop ( ) ;
388+
291389 if ( IsLoading )
292390 {
293391 if ( ! isInfiniteScroll )
@@ -321,31 +419,6 @@ private async Task SearchModels(bool isInfiniteScroll = false)
321419 {
322420 var response = await civArchiveApiClient . SearchAsync ( filters ) ;
323421
324- if ( ! filterOptionsLoaded )
325- {
326- ApplyFilterOptions ( response . FilterOptions ) ;
327- filterOptionsLoaded = true ;
328-
329- // ApplyFilterOptions sets suppressSearch=true while populating the option
330- // collections. Only re-fetch if the user actually has saved selections
331- // (Types/BaseModels) that we couldn't apply on the first call — for a
332- // first-run user with no saved selections the initial response is already
333- // correct and a second fetch is wasted bandwidth.
334- var savedOptions = settingsManager . Settings . CivArchiveBrowserOptions ;
335- var hasSelections =
336- savedOptions . SelectedModelTypes . Count > 0 || savedOptions . SelectedBaseModels . Count > 0 ;
337-
338- if ( ! isInfiniteScroll && suppressSearch && hasSelections )
339- {
340- suppressSearch = false ;
341- response = await civArchiveApiClient . SearchAsync ( BuildFilters ( currentPage ) ) ;
342- }
343- else
344- {
345- suppressSearch = false ;
346- }
347- }
348-
349422 TotalHits = response . TotalHits ;
350423 currentPage = response . EffectiveFilters . Page ;
351424
@@ -613,11 +686,15 @@ IReadOnlyCollection<string> selectedValues
613686 {
614687 ApplyModelTypeFilter ( ) ;
615688 OnPropertyChanged ( nameof ( ModelTypeSelectionSummary ) ) ;
689+ OnPropertyChanged ( nameof ( HasModelTypeFilter ) ) ;
690+ OnPropertyChanged ( nameof ( SelectedModelTypeCount ) ) ;
616691 }
617692 else if ( ReferenceEquals ( target , AllBaseModels ) )
618693 {
619694 ApplyBaseModelFilter ( ) ;
620695 OnPropertyChanged ( nameof ( BaseModelSelectionSummary ) ) ;
696+ OnPropertyChanged ( nameof ( HasBaseModelFilter ) ) ;
697+ OnPropertyChanged ( nameof ( SelectedBaseModelCount ) ) ;
621698 }
622699 }
623700
@@ -756,7 +833,7 @@ private void SaveSettings()
756833 ) ;
757834 }
758835
759- private async void OnSelectableOptionChanged ( object ? sender , PropertyChangedEventArgs e )
836+ private void OnSelectableOptionChanged ( object ? sender , PropertyChangedEventArgs e )
760837 {
761838 if ( e . PropertyName != nameof ( BaseModelOptionViewModel . IsSelected ) )
762839 {
@@ -765,18 +842,12 @@ private async void OnSelectableOptionChanged(object? sender, PropertyChangedEven
765842
766843 OnPropertyChanged ( nameof ( ModelTypeSelectionSummary ) ) ;
767844 OnPropertyChanged ( nameof ( BaseModelSelectionSummary ) ) ;
845+ OnPropertyChanged ( nameof ( HasModelTypeFilter ) ) ;
846+ OnPropertyChanged ( nameof ( HasBaseModelFilter ) ) ;
847+ OnPropertyChanged ( nameof ( SelectedModelTypeCount ) ) ;
848+ OnPropertyChanged ( nameof ( SelectedBaseModelCount ) ) ;
768849
769- if ( suppressSearch )
770- {
771- return ;
772- }
773-
774- SaveSettings ( ) ;
775-
776- if ( HasSearched )
777- {
778- await SearchModels ( ) ;
779- }
850+ RequestDebouncedSearch ( ) ;
780851 }
781852
782853 partial void OnSelectedPlatformChanged ( NamedOption < CivArchivePlatformOption > ? value ) =>
@@ -793,18 +864,5 @@ partial void OnSelectedPlatformStatusChanged(NamedOption<CivArchivePlatformStatu
793864
794865 partial void OnSelectedKindChanged ( NamedOption < CivArchiveKindOption > ? value ) => TriggerFilterSearch ( ) ;
795866
796- private async void TriggerFilterSearch ( )
797- {
798- if ( suppressSearch )
799- {
800- return ;
801- }
802-
803- SaveSettings ( ) ;
804-
805- if ( HasSearched )
806- {
807- await SearchModels ( ) ;
808- }
809- }
867+ private void TriggerFilterSearch ( ) => RequestDebouncedSearch ( ) ;
810868}
0 commit comments