Skip to content

Commit a163de1

Browse files
committed
Merge branch 'main' into discord-rpc
2 parents 5cb10d1 + 85cf8f1 commit a163de1

35 files changed

+454
-154
lines changed

Directory.Packages.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@
148148
<PackageVersion Include="BitFaster.Caching" Version="2.5.2" />
149149
<PackageVersion Include="CliWrap" Version="3.6.7" />
150150
<PackageVersion Include="DynamicData" Version="9.3.2" />
151-
<PackageVersion Include="GameFinder" Version="4.8.0" />
151+
<PackageVersion Include="GameFinder" Version="4.9.0" />
152152
<PackageVersion Include="Humanizer" Version="2.14.1" />
153153
<PackageVersion Include="ini-parser-netstandard" Version="2.5.2" />
154154
<PackageVersion Include="Mutagen.Bethesda.Skyrim" Version="0.44.0" />

src/NexusMods.Abstractions.GameLocators/Stores/GOG/GOGLocatorResultMetadata.cs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,19 @@ public record GOGLocatorResultMetadata : IGameLocatorResultMetadata
1818
/// </summary>
1919
public required ulong BuildId { get; init; }
2020

21+
/// <summary>
22+
/// BuildIds of other installed DLC
23+
/// </summary>
24+
public required ulong[] DLCBuildIds { get; init; }
25+
2126
/// <inheritdoc/>
2227
public ILinuxCompatibilityDataProvider? LinuxCompatibilityDataProvider { get; init; }
2328

2429
/// <inheritdoc />
25-
public IEnumerable<LocatorId> ToLocatorIds() => [LocatorId.From(BuildId.ToString())];
30+
public IEnumerable<LocatorId> ToLocatorIds() => [
31+
LocatorId.From(BuildId.ToString()),
32+
..DLCBuildIds.Select(x => LocatorId.From(x.ToString())),
33+
];
2634
}
2735

2836
[PublicAPI]
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<UserControl x:Class="NexusMods.App.UI.Controls.Search.SearchControl"
2+
xmlns="https://github.com/avaloniaui"
3+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
4+
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
5+
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
6+
xmlns:controls="clr-namespace:NexusMods.App.UI.Controls"
7+
xmlns:icons="clr-namespace:NexusMods.UI.Sdk.Icons;assembly=NexusMods.UI.Sdk"
8+
xmlns:resources="clr-namespace:NexusMods.App.UI.Resources"
9+
mc:Ignorable="d" d:DesignWidth="300" d:DesignHeight="40">
10+
<DockPanel>
11+
<controls:StandardButton x:Name="SearchButton"
12+
Type="Tertiary"
13+
Size="{Binding ButtonSize, RelativeSource={RelativeSource AncestorType=UserControl}}"
14+
Fill="WeakAlt"
15+
ShowIcon="Left"
16+
ShowLabel="False"
17+
LeftIcon="{x:Static icons:IconValues.Search}"
18+
DockPanel.Dock="Left"
19+
Click="SearchButton_OnClick"
20+
ToolTip.Tip="Search (Ctrl+F)"/>
21+
<Panel x:Name="SearchPanel" HorizontalAlignment="Left" Width="188" IsVisible="False">
22+
<TextBox x:Name="SearchTextBox" MinHeight="24"
23+
Watermark="{x:Static resources:Language.SearchBox__Search_Watermark}"
24+
Padding="4 2 24 2" Margin="4 0 0 0"/>
25+
<controls:StandardButton x:Name="SearchClearButton"
26+
Margin="0 0 4 0"
27+
HorizontalAlignment="Right"
28+
Type="Tertiary"
29+
Size="{Binding ButtonSize, RelativeSource={RelativeSource AncestorType=UserControl}}"
30+
Fill="None"
31+
ShowIcon="IconOnly"
32+
ShowLabel="False"
33+
LeftIcon="{x:Static icons:IconValues.Close}"/>
34+
</Panel>
35+
</DockPanel>
36+
</UserControl>
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
using System.Reactive.Linq;
2+
using Avalonia;
3+
using Avalonia.Controls;
4+
using Avalonia.Input;
5+
using Avalonia.Interactivity;
6+
using Avalonia.ReactiveUI;
7+
using AvaloniaEdit.Search;
8+
using JetBrains.Annotations;
9+
using NexusMods.Telemetry;
10+
using R3;
11+
using ReactiveUI;
12+
using static NexusMods.App.UI.Controls.Filters.Filter;
13+
using CompositeDisposable = System.Reactive.Disposables.CompositeDisposable;
14+
using Disposable = System.Reactive.Disposables.Disposable;
15+
16+
namespace NexusMods.App.UI.Controls.Search;
17+
18+
/// <remarks>
19+
/// Note(sewer):
20+
/// This represents a standalone control which implements a search button, search box
21+
/// and a clear button.
22+
///
23+
/// Since this is not a full view, or a component you would arbitrarily render,
24+
/// we are not using <see cref="ReactiveUserControl{TViewModel}"/> here. Same way you wouldn't
25+
/// make a <see cref="ReactiveUserControl"/> for a Button or a TextBox.
26+
/// </remarks>
27+
[UsedImplicitly]
28+
public partial class SearchControl : UserControl
29+
{
30+
/// <summary>
31+
/// The <see cref="TreeDataGridAdapter{TModel,TKey}"/> that supports search functionality.
32+
/// </summary>
33+
public static readonly StyledProperty<ISearchableTreeDataGridAdapter?> AdapterProperty =
34+
AvaloniaProperty.Register<SearchControl, ISearchableTreeDataGridAdapter?>(nameof(Adapter));
35+
36+
/// <summary>
37+
/// The name of the page for telemetry tracking.
38+
/// </summary>
39+
public static readonly StyledProperty<string> PageNameProperty =
40+
AvaloniaProperty.Register<SearchControl, string>(nameof(PageName), defaultValue: "Unknown");
41+
42+
/// <summary>
43+
/// The size of the search button.
44+
/// </summary>
45+
public static readonly StyledProperty<StandardButton.Sizes> ButtonSizeProperty =
46+
AvaloniaProperty.Register<SearchControl, StandardButton.Sizes>(nameof(ButtonSize), defaultValue: StandardButton.Sizes.Toolbar);
47+
48+
public ISearchableTreeDataGridAdapter? Adapter
49+
{
50+
get => GetValue(AdapterProperty);
51+
set => SetValue(AdapterProperty, value);
52+
}
53+
54+
public string PageName
55+
{
56+
get => GetValue(PageNameProperty);
57+
set => SetValue(PageNameProperty, value);
58+
}
59+
60+
public StandardButton.Sizes ButtonSize
61+
{
62+
get => GetValue(ButtonSizeProperty);
63+
set => SetValue(ButtonSizeProperty, value);
64+
}
65+
66+
/// <summary>
67+
/// Gets whether the search panel is currently visible.
68+
/// </summary>
69+
public bool IsSearchVisible => SearchPanel.IsVisible;
70+
71+
/// <summary>
72+
/// Gets the current search text.
73+
/// </summary>
74+
[PublicAPI]
75+
public string SearchText => SearchTextBox.Text ?? string.Empty;
76+
77+
public SearchControl()
78+
{
79+
InitializeComponent();
80+
SetupBindings();
81+
}
82+
83+
private void SetupBindings()
84+
{
85+
// Note(sewer): The SearchControl subscribes to self here.
86+
// So there is no risk of event leaks, as the lifetime of self equals self.
87+
88+
// Setup search text binding
89+
this.WhenAnyValue(x => x.SearchTextBox.Text)
90+
.OnUI()
91+
.Subscribe(ApplySearchFilter);
92+
93+
// Clear button functionality
94+
SearchClearButton.Click += (_, _) => ClearSearch();
95+
96+
// Handle focus when search panel visibility changes
97+
this.WhenAnyValue(x => x.SearchPanel.IsVisible)
98+
.Skip(1) // Skip the initial value to avoid focusing on startup
99+
.OnUI()
100+
.Subscribe(isVisible =>
101+
{
102+
if (isVisible)
103+
{
104+
// Focus the textbox when the search panel becomes visible
105+
SearchTextBox.Focus();
106+
107+
// Tracking
108+
Tracking.AddEvent(Events.Search.OpenSearch, new EventMetadata(name: PageName));
109+
}
110+
});
111+
}
112+
113+
private void ApplySearchFilter(string? searchText)
114+
{
115+
if (Adapter?.Filter != null)
116+
{
117+
Adapter.Filter.Value = string.IsNullOrWhiteSpace(searchText)
118+
? NoFilter.Instance
119+
: new TextFilter(searchText, CaseSensitive: false);
120+
}
121+
}
122+
123+
private void SearchButton_OnClick(object? sender, RoutedEventArgs e) => ToggleSearchPanelVisibility();
124+
125+
/// <summary>
126+
/// Toggles the visibility of the search panel.
127+
/// </summary>
128+
public void ToggleSearchPanelVisibility() => SearchPanel.IsVisible = !SearchPanel.IsVisible;
129+
130+
/// <summary>
131+
/// Clears the search text and hides the search panel.
132+
/// </summary>
133+
public void ClearSearch()
134+
{
135+
SearchTextBox.Text = string.Empty;
136+
SearchPanel.IsVisible = false;
137+
}
138+
139+
/// <summary>
140+
/// Attaches keyboard shortcuts (Ctrl+F, Escape) to the specified control within a WhenActivated block.
141+
/// This ensures proper disposal when the view is deactivated.
142+
/// </summary>
143+
/// <param name="control">The control to attach keyboard handlers to</param>
144+
/// <param name="disposables">The disposables collection from WhenActivated</param>
145+
public void AttachKeyboardHandlers(Control control, CompositeDisposable disposables)
146+
{
147+
control.KeyDown += OnKeyDown;
148+
149+
// Auto remove the event handler
150+
Disposable.Create(control, ctrl => ctrl.KeyDown -= OnKeyDown)
151+
.AddTo(disposables);
152+
}
153+
154+
private void OnKeyDown(object? sender, KeyEventArgs e)
155+
{
156+
switch (e.Key)
157+
{
158+
// Handle Ctrl+F to toggle search panel
159+
case Key.F when e.KeyModifiers.HasFlag(KeyModifiers.Control):
160+
ToggleSearchPanelVisibility();
161+
e.Handled = true; // Prevent the event from bubbling up
162+
return;
163+
case Key.Escape when IsSearchVisible:
164+
ClearSearch();
165+
e.Handled = true; // Prevent the event from bubbling up
166+
return;
167+
}
168+
169+
}
170+
}

src/NexusMods.App.UI/Controls/TreeDataGrid/FileEntryComponent.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using NexusMods.Abstractions.UI;
2+
using NexusMods.App.UI.Controls.Filters;
3+
using NexusMods.App.UI.Controls.TreeDataGrid.Filters;
24

35
namespace NexusMods.App.UI.Controls;
46

@@ -15,6 +17,9 @@ public FileEntryComponent(
1517
IsDeleted = isDeleted;
1618
}
1719

20+
/// <inheritdoc/>
21+
public FilterResult MatchesFilter(Filter filter) => Name.MatchesFilter(filter); // Delegate to StringComponent
22+
1823
public int CompareTo(FileEntryComponent? other) => Name.CompareTo(other?.Name);
1924

2025
public int CompareTo(object? obj) => obj is FileEntryComponent other ? CompareTo(other) : 1;

src/NexusMods.App.UI/Controls/TreeDataGrid/TreeDataGridAdapter.cs

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,20 @@
55
using NexusMods.Abstractions.UI;
66
using NexusMods.Abstractions.UI.Extensions;
77
using NexusMods.App.UI.Extensions;
8-
using NexusMods.MnemonicDB.Abstractions;
98
using ObservableCollections;
109
using R3;
1110
using System.Reactive.Linq;
1211
using Avalonia.Input;
13-
using DynamicData.Kernel;
1412
using System.Diagnostics;
1513
using NexusMods.App.UI.Controls.Filters;
1614
using static NexusMods.App.UI.Controls.Filters.Filter;
17-
using NexusMods.Telemetry;
1815

1916
namespace NexusMods.App.UI.Controls;
2017

2118
/// <summary>
2219
/// Adapter class for working with <see cref="TreeDataGrid"/>.
2320
/// </summary>
24-
public abstract class TreeDataGridAdapter<TModel, TKey> : ReactiveR3Object
21+
public abstract class TreeDataGridAdapter<TModel, TKey> : ReactiveR3Object, ISearchableTreeDataGridAdapter
2522
where TModel : class, ITreeDataGridItemModel<TModel, TKey>
2623
where TKey : notnull
2724
{
@@ -37,8 +34,8 @@ public abstract class TreeDataGridAdapter<TModel, TKey> : ReactiveR3Object
3734
public BindableReactiveProperty<bool> IsSourceEmpty { get; } = new(value: true);
3835
public BindableReactiveProperty<int> SourceCount { get; } = new(value: 0);
3936
public BindableReactiveProperty<IComparer<TModel>?> CustomSortComparer { get; } = new(value: null);
40-
public ReactiveProperty<Filter> Filter { get; } = new(value: NoFilter.Instance);
4137
public ObservableHashSet<TModel> SelectedModels { get; private set; } = [];
38+
public ReactiveProperty<Filter> Filter { get; } = new(value: NoFilter.Instance);
4239
protected ObservableList<TModel> Roots { get; private set; } = [];
4340
private ISynchronizedView<TModel, TModel> RootsView { get; }
4441
private INotifyCollectionChangedSynchronizedViewList<TModel> RootsCollectionChangedView { get; }
@@ -183,10 +180,6 @@ protected TreeDataGridAdapter()
183180
});
184181
}
185182

186-
public void OnOpenSearchPanel(string pageName)
187-
{
188-
Tracking.AddEvent(Events.Search.OpenSearch, new EventMetadata(name: pageName));
189-
}
190183
public void ClearSelection() => _selectionModel?.Clear();
191184

192185
/// <summary>
@@ -343,3 +336,8 @@ protected override void Dispose(bool disposing)
343336
base.Dispose(disposing);
344337
}
345338
}
339+
340+
public interface ISearchableTreeDataGridAdapter
341+
{
342+
public ReactiveProperty<Filter> Filter { get; }
343+
}

src/NexusMods.App.UI/Controls/TreeDataGrid/TreeDataGridItemModel.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
using JetBrains.Annotations;
66
using NexusMods.Abstractions.UI;
77
using NexusMods.Abstractions.UI.Extensions;
8+
using NexusMods.App.UI.Controls.Filters;
89
using ObservableCollections;
910
using R3;
11+
using static NexusMods.App.UI.Controls.Filters.Filter;
1012
using Observable = System.Reactive.Linq.Observable;
1113

1214
namespace NexusMods.App.UI.Controls;

src/NexusMods.App.UI/Pages/LibraryPage/LibraryView.axaml

Lines changed: 4 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
xmlns:pageHeader="clr-namespace:NexusMods.App.UI.Controls.PageHeader"
1515
xmlns:loadouts="clr-namespace:NexusMods.Abstractions.Loadouts;assembly=NexusMods.Abstractions.Loadouts"
1616
xmlns:generic="clr-namespace:System.Collections.Generic;assembly=System.Runtime"
17+
xmlns:search="clr-namespace:NexusMods.App.UI.Controls.Search"
1718
mc:Ignorable="d" d:DesignWidth="1000" d:DesignHeight="450"
1819
x:Class="NexusMods.App.UI.Pages.LibraryPage.LibraryView"
1920
Background="{StaticResource SurfaceTransparentBrush}">
@@ -46,34 +47,10 @@
4647
<!-- toolbar component -->
4748
<controls:Toolbar Margin="24,12, 24, 0">
4849

49-
<!-- search button group -->
5050
<ItemsControl>
51-
<DockPanel>
52-
<controls:StandardButton x:Name="SearchButton"
53-
Text="x selected"
54-
Type="Tertiary"
55-
Size="Toolbar"
56-
Fill="WeakAlt"
57-
ShowIcon="Left"
58-
ShowLabel="False"
59-
LeftIcon="{x:Static icons:IconValues.Search}"
60-
DockPanel.Dock="Left"
61-
Click="SearchButton_OnClick"
62-
ToolTip.Tip="Search (Ctrl+F)"/>
63-
<Panel x:Name="SearchPanel" HorizontalAlignment="Left" Width="188" IsVisible="False">
64-
<TextBox x:Name="SearchTextBox" MinHeight="24" Watermark="{x:Static resources:Language.SearchBox__Search_Watermark}" Padding="4 2 24 2"
65-
Margin="4 0 0 0"/>
66-
<controls:StandardButton x:Name="SearchClearButton"
67-
Margin="0 0 4 0"
68-
HorizontalAlignment="Right"
69-
Type="Tertiary"
70-
Size="Toolbar"
71-
Fill="None"
72-
ShowIcon="IconOnly"
73-
ShowLabel="False"
74-
LeftIcon="{x:Static icons:IconValues.Close}"/>
75-
</Panel>
76-
</DockPanel>
51+
<search:SearchControl x:Name="SearchControl"
52+
PageName="Library"
53+
ButtonSize="Toolbar" />
7754
</ItemsControl>
7855

7956
<!-- selection related button group -->

0 commit comments

Comments
 (0)