Add ability to trim and untrim XCI files from the application context menu AND in Bulk #105

Merged
amurgshere merged 6 commits from add-ability-to-trim-and-untrim-XCI-files-in-bulk into master 2024-11-06 23:37:30 +00:00
19 changed files with 1426 additions and 52 deletions
Showing only changes of commit f33540670f - Show all commits

View File

@ -1,9 +1,13 @@
// Uncomment the line below to ensure XCIFileTrimmer does not modify files
//#define XCI_TRIMMER_READ_ONLY_MODE
using Gommon;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
namespace Ryujinx.Common.Utilities
{
@ -71,6 +75,7 @@ namespace Ryujinx.Common.Utilities
public enum OperationOutcome
{
Undetermined,
InvalidXCIFile,
NoTrimNecessary,
NoUntrimPossible,
@ -78,7 +83,8 @@ namespace Ryujinx.Common.Utilities
FileIOWriteError,
ReadOnlyFileCannotFix,
FileSizeChanged,
Successful
Successful,
Cancelled
}
public enum LogType
@ -139,7 +145,7 @@ namespace Ryujinx.Common.Utilities
ReadHeader();
}
public void CheckFreeSpace()
public void CheckFreeSpace(CancellationToken? cancelToken = null)
{
if (FreeSpaceChecked)
return;
@ -160,7 +166,7 @@ namespace Ryujinx.Common.Utilities
Stopwatch timedSw = Lambda.Timed(() =>
{
freeSpaceValid = CheckPadding(readSizeB);
freeSpaceValid = CheckPadding(readSizeB, cancelToken);
});
if (timedSw.Elapsed.TotalSeconds > 0)
@ -191,7 +197,7 @@ namespace Ryujinx.Common.Utilities
}
}
private bool CheckPadding(long readSizeB)
private bool CheckPadding(long readSizeB, CancellationToken? cancelToken = null)
{
long maxReads = readSizeB / XCIFileTrimmer.BufferSize;
long read = 0;
@ -199,6 +205,11 @@ namespace Ryujinx.Common.Utilities
while (true)
{
if (cancelToken.HasValue && cancelToken.Value.IsCancellationRequested)
{
return false;
}
int bytes = _fileStream.Read(buffer, 0, XCIFileTrimmer.BufferSize);
if (bytes == 0)
break;
@ -223,7 +234,7 @@ namespace Ryujinx.Common.Utilities
ReadHeader();
}
public OperationOutcome Trim()
public OperationOutcome Trim(CancellationToken? cancelToken = null)
{
if (!FileOK)
{
@ -237,12 +248,19 @@ namespace Ryujinx.Common.Utilities
if (!FreeSpaceChecked)
{
CheckFreeSpace();
CheckFreeSpace(cancelToken);
}
if (!FreeSpaceValid)
{
return OperationOutcome.FreeSpaceCheckFailed;
if (cancelToken.HasValue && cancelToken.Value.IsCancellationRequested)
{
return OperationOutcome.Cancelled;
}
else
{
return OperationOutcome.FreeSpaceCheckFailed;
}
}
Log?.Write(LogType.Info, "Trimming...");
@ -274,7 +292,10 @@ namespace Ryujinx.Common.Utilities
try
{
outfileStream.SetLength(TrimmedFileSizeB);
#if !XCI_TRIMMER_READ_ONLY_MODE
outfileStream.SetLength(TrimmedFileSizeB);
#endif
return OperationOutcome.Successful;
}
finally
@ -290,7 +311,7 @@ namespace Ryujinx.Common.Utilities
}
}
public OperationOutcome Untrim()
public OperationOutcome Untrim(CancellationToken? cancelToken = null)
{
if (!FileOK)
{
@ -334,7 +355,7 @@ namespace Ryujinx.Common.Utilities
{
Stopwatch timedSw = Lambda.Timed(() =>
{
WritePadding(outfileStream, bytesToWriteB);
WritePadding(outfileStream, bytesToWriteB, cancelToken);
});
if (timedSw.Elapsed.TotalSeconds > 0)
@ -342,7 +363,14 @@ namespace Ryujinx.Common.Utilities
Log?.Write(LogType.Info, $"Wrote at {bytesToWriteB / (double)XCIFileTrimmer.BytesInAMegabyte / timedSw.Elapsed.TotalSeconds:N} Mb/sec");
}
return OperationOutcome.Successful;
if (cancelToken.HasValue && cancelToken.Value.IsCancellationRequested)
{
return OperationOutcome.Cancelled;
}
else
{
return OperationOutcome.Successful;
}
}
finally
{
@ -357,7 +385,7 @@ namespace Ryujinx.Common.Utilities
}
}
private void WritePadding(FileStream outfileStream, long bytesToWriteB)
private void WritePadding(FileStream outfileStream, long bytesToWriteB, CancellationToken? cancelToken = null)
{
long bytesLeftToWriteB = bytesToWriteB;
long writes = bytesLeftToWriteB / XCIFileTrimmer.BufferSize;
@ -370,8 +398,17 @@ namespace Ryujinx.Common.Utilities
while (bytesLeftToWriteB > 0)
{
if (cancelToken.HasValue && cancelToken.Value.IsCancellationRequested)
{
return;
}
long bytesToWrite = Math.Min(XCIFileTrimmer.BufferSize, bytesLeftToWriteB);
outfileStream.Write(buffer, 0, (int)bytesToWrite);
#if !XCI_TRIMMER_READ_ONLY_MODE
outfileStream.Write(buffer, 0, (int)bytesToWrite);
#endif
bytesLeftToWriteB -= bytesToWrite;
Log?.Progress(write, writes, "Writing padding data...", false);
write++;

View File

@ -13,6 +13,7 @@ namespace Ryujinx.HLE.Generators
var syntaxReceiver = (ServiceSyntaxReceiver)context.SyntaxReceiver;
CodeGenerator generator = new CodeGenerator();
generator.AppendLine("#nullable enable");
generator.AppendLine("using System;");
generator.EnterScope($"namespace Ryujinx.HLE.HOS.Services.Sm");
generator.EnterScope($"partial class IUserInterface");
@ -58,6 +59,7 @@ namespace Ryujinx.HLE.Generators
generator.LeaveScope();
generator.LeaveScope();
generator.AppendLine("#nullable disable");
context.AddSource($"IUserInterface.g.cs", generator.ToString());
}

View File

@ -0,0 +1,55 @@
using Ryujinx.Common.Logging;
using Ryujinx.Common.Utilities;
using Ryujinx.UI.App.Common;
namespace Ryujinx.UI.Common.Models
{
public record XCITrimmerFileModel(
string Name,
string Path,
bool Trimmable,
bool Untrimmable,
long PotentialSavingsB,
long CurrentSavingsB,
int? PercentageProgress,
XCIFileTrimmer.OperationOutcome ProcessingOutcome)
{
public static XCITrimmerFileModel FromApplicationData(ApplicationData applicationData, XCIFileTrimmerLog logger)
{
var trimmer = new XCIFileTrimmer(applicationData.Path, logger);
return new XCITrimmerFileModel(
applicationData.Name,
applicationData.Path,
trimmer.CanBeTrimmed,
trimmer.CanBeUntrimmed,
trimmer.DiskSpaceSavingsB,
trimmer.DiskSpaceSavedB,
null,
XCIFileTrimmer.OperationOutcome.Undetermined
);
}
public bool IsFailed
{
get
{
return ProcessingOutcome != XCIFileTrimmer.OperationOutcome.Undetermined &&
ProcessingOutcome != XCIFileTrimmer.OperationOutcome.Successful;
}
}
public virtual bool Equals(XCITrimmerFileModel obj)
{
if (obj == null)
return false;
else
return this.Path == obj.Path;
}
public override int GetHashCode()
{
return this.Path.GetHashCode();
}
}
}

View File

@ -33,6 +33,7 @@
"MenuBarToolsManageFileTypes": "Manage file types",
"MenuBarToolsInstallFileTypes": "Install file types",
"MenuBarToolsUninstallFileTypes": "Uninstall file types",
"MenuBarToolsXCITrimmer": "Trim XCI Files",
"MenuBarView": "_View",
"MenuBarViewWindow": "Window Size",
"MenuBarViewWindow720": "720p",
@ -402,6 +403,8 @@
"InputDialogTitle": "Input Dialog",
"InputDialogOk": "OK",
"InputDialogCancel": "Cancel",
"InputDialogCancelling": "Cancelling",
"InputDialogClose": "Close",
"InputDialogAddNewProfileTitle": "Choose the Profile Name",
"InputDialogAddNewProfileHeader": "Please Enter a Profile Name",
"InputDialogAddNewProfileSubtext": "(Max Length: {0})",
@ -470,6 +473,7 @@
"DialogUninstallFileTypesSuccessMessage": "Successfully uninstalled file types!",
"DialogUninstallFileTypesErrorMessage": "Failed to uninstall file types.",
"DialogOpenSettingsWindowLabel": "Open Settings Window",
"DialogOpenXCITrimmerWindowLabel": "XCI Trimmer Window",
"DialogControllerAppletTitle": "Controller Applet",
"DialogMessageDialogErrorExceptionMessage": "Error displaying Message Dialog: {0}",
"DialogSoftwareKeyboardErrorExceptionMessage": "Error displaying Software Keyboard: {0}",
@ -672,6 +676,12 @@
"TitleUpdateVersionLabel": "Version {0}",
"TitleBundledUpdateVersionLabel": "Bundled: Version {0}",
"TitleBundledDlcLabel": "Bundled:",
"TitleXCIStatusPartialLabel": "Partial",
"TitleXCIStatusTrimmableLabel": "Untrimmed",
"TitleXCIStatusUntrimmableLabel": "Trimmed",
"TitleXCIStatusFailedLabel": "(Failed)",
"TitleXCICanSaveLabel": "Save {0:n0} Mb",
"TitleXCISavingLabel": "Saved {0:n0} Mb",
"RyujinxInfo": "Ryujinx - Info",
"RyujinxConfirm": "Ryujinx - Confirmation",
"FileDialogAllTypes": "All types",
@ -728,17 +738,33 @@
"TrimXCIFileDialogPrimaryText": "This function will first check the empty space and then trim the XCI File to save disk space.",
"TrimXCIFileDialogSecondaryText": "Current File Size: {0:n} MB\nGame Data Size: {1:n} MB\nDisk Space Savings: {2:n} MB",
"TrimXCIFileNoTrimNecessary": "XCI File does not need to be trimmed. Check logs for further details",
"TrimXCIFileNoUntrimPossible": "XCI File cannot be untrimmed. Check logs for further details",
"TrimXCIFileReadOnlyFileCannotFix": "XCI File is Read Only and could not be made writable. Check logs for further details",
"TrimXCIFileFileSizeChanged": "XCI File has changed in size since it was scanned. Please check the file is not being written to and try again.",
"TrimXCIFileFreeSpaceCheckFailed": "XCI File has data in the free space area, it is not safe to trim",
"TrimXCIFileInvalidXCIFile": "XCI File contains invalid data. Check logs for further details",
"TrimXCIFileFileIOWriteError": "XCI File could not be opened for writing. Check logs for further details",
"TrimXCIFileFailedPrimaryText": "Trimming of the XCI file failed",
"TrimXCIFileCancelled": "The operation was cancelled",
"TrimXCIFileFileUndertermined": "No operation was performed",
"UserProfileWindowTitle": "User Profiles Manager",
"CheatWindowTitle": "Cheats Manager",
"DlcWindowTitle": "Manage Downloadable Content for {0} ({1})",
"ModWindowTitle": "Manage Mods for {0} ({1})",
"UpdateWindowTitle": "Title Update Manager",
"XCITrimmerWindowTitle": "XCI File Trimmer",
"XCITrimmerTitleStatusCount": "{0} of {1} Title(s) Selected",
"XCITrimmerTitleStatusCountWithFilter": "{0} of {1} Title(s) Selected ({2} displayed)",
"XCITrimmerTitleStatusTrimming": "Trimming {0} Title(s)...",
"XCITrimmerTitleStatusUntrimming": "Untrimming {0} Title(s)...",
"XCITrimmerTitleStatusFailed": "Failed",
"XCITrimmerPotentialSavings": "Potential Savings",
"XCITrimmerActualSavings": "Actual Savings",
"XCITrimmerSavingsMb": "{0:n0} Mb",
"XCITrimmerSelectDisplayed": "Select Shown",
"XCITrimmerDeselectDisplayed": "Deselect Shown",
"XCITrimmerSortName": "Title",
"XCITrimmerSortSaved": "Space Savings",
"UpdateWindowUpdateAddedMessage": "{0} new update(s) added",
"UpdateWindowBundledContentNotice": "Bundled updates cannot be removed, only disabled.",
"CheatWindowHeading": "Cheats Available for {0} [{1}]",

View File

@ -43,6 +43,10 @@
</StackPanel>
</Border>
</Design.PreviewWith>
<Style Selector="DropDownButton">
<Setter Property="FontSize"
Value="12" />
</Style>
<Style Selector="Border.small">
<Setter Property="Width"
Value="100" />
@ -231,6 +235,14 @@
<Setter Property="MinWidth"
Value="80" />
</Style>
<Style Selector="ProgressBar:horizontal">
<Setter Property="MinWidth" Value="0"/>
<Setter Property="MinHeight" Value="0"/>
</Style>
<Style Selector="ProgressBar:vertical">
<Setter Property="MinWidth" Value="0"/>
<Setter Property="MinHeight" Value="0"/>
</Style>
<Style Selector="ProgressBar /template/ Border#ProgressBarTrack">
<Setter Property="IsVisible"
Value="False" />
@ -389,7 +401,7 @@
<x:Double x:Key="ControlContentThemeFontSize">13</x:Double>
<x:Double x:Key="MenuItemHeight">26</x:Double>
<x:Double x:Key="TabItemMinHeight">28</x:Double>
<x:Double x:Key="ContentDialogMaxWidth">600</x:Double>
<x:Double x:Key="ContentDialogMaxWidth">700</x:Double>
<x:Double x:Key="ContentDialogMaxHeight">756</x:Double>
</Styles.Resources>
</Styles>

View File

@ -1,24 +1,24 @@
using Avalonia.Threading;
using Ryujinx.Ava.UI.ViewModels;
namespace Ryujinx.Ava.Common
{
internal class XCIFileTrimmerLog : Ryujinx.Common.Logging.XCIFileTrimmerLog
internal class XCIFileTrimmerMainWindowLog : Ryujinx.Common.Logging.XCIFileTrimmerLog
{
private readonly MainWindowViewModel _viewModel;
public XCIFileTrimmerLog(MainWindowViewModel viewModel)
public XCIFileTrimmerMainWindowLog(MainWindowViewModel viewModel)
{
_viewModel = viewModel;
}
public override void Progress(long current, long total, string text, bool complete)
{
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
Dispatcher.UIThread.Post(() =>
{
_viewModel.StatusBarProgressMaximum = (int)(total);
_viewModel.StatusBarProgressValue = (int)(current);
});
}
}
}

View File

@ -0,0 +1,23 @@
using Avalonia.Threading;
using Ryujinx.Ava.UI.ViewModels;
namespace Ryujinx.Ava.Common
{
internal class XCIFileTrimmerWindowLog : Ryujinx.Common.Logging.XCIFileTrimmerLog
{
private readonly XCITrimmerViewModel _viewModel;
public XCIFileTrimmerWindowLog(XCITrimmerViewModel viewModel)
{
_viewModel = viewModel;
}
public override void Progress(long current, long total, string text, bool complete)
{
Dispatcher.UIThread.Post(() =>
{
_viewModel.SetProgress((int)(current), (int)(total));
});
}
}
}

View File

@ -0,0 +1,62 @@
using Avalonia.Collections;
using System.Collections.Generic;
namespace Ryujinx.Ava.UI.Helpers
{
public static class AvaloniaListExtensions
{
/// <summary>
/// Adds or Replaces an item in an AvaloniaList irrespective of whether the item already exists
/// </summary>
/// <typeparam name="T">The type of the element in the AvaoloniaList</typeparam>
/// <param name="list">The list containing the item to replace</param>
/// <param name="item">The item to replace</param>
/// <param name="addIfNotFound">True to add the item if its not found</param>
/// <returns>True if the item was found and replaced, false if it was addded</returns>
/// <remarks>
/// The indexes on the AvaloniaList will only replace if the item does not match,
/// this causes the items to not be replaced if the Equality is customised on the
/// items. This method will instead find, remove and add the item to ensure it is
/// replaced correctly.
/// </remarks>
public static bool ReplaceWith<T>(this AvaloniaList<T> list, T item, bool addIfNotFound = true)
{
var index = list.IndexOf(item);
if (index != -1)
{
list.RemoveAt(index);
list.Insert(index, item);
return true;
}
else
{
list.Add(item);
return false;
}
}
/// <summary>
/// Adds or Replaces items in an AvaloniaList from another list irrespective of whether the item already exists
/// </summary>
/// <typeparam name="T">The type of the element in the AvaoloniaList</typeparam>
/// <param name="list">The list containing the item to replace</param>
/// <param name="sourceList">The list of items to be actually added to `list`</param>
/// <param name="matchingList">The items to use as matching records to search for in the `sourceList', if not found this item will be added instead</params>
public static void AddOrReplaceMatching<T>(this AvaloniaList<T> list, IList<T> sourceList, IList<T> matchingList)
{
foreach (var match in matchingList)
{
var index = sourceList.IndexOf(match);
if (index != -1)
{
list.ReplaceWith(sourceList[index]);
}
else
{
list.ReplaceWith(match);
}
}
}
}
}

View File

@ -0,0 +1,48 @@
using Avalonia;
using Avalonia.Data;
using Avalonia.Data.Converters;
using Ryujinx.Ava.Common.Locale;
using Ryujinx.UI.Common.Models;
using System;
using System.Globalization;
namespace Ryujinx.Ava.UI.Helpers
{
internal class XCITrimmerFileSpaceSavingsConverter : IValueConverter
{
private const long _bytesPerMB = 1024 * 1024;
public static XCITrimmerFileSpaceSavingsConverter Instance = new();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is UnsetValueType)
{
return BindingOperations.DoNothing;
}
if (!targetType.IsAssignableFrom(typeof(string)))
{
return null;
}
if (value is not XCITrimmerFileModel app)
{
return null;
}
if (app.CurrentSavingsB < app.PotentialSavingsB)
{
return LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.TitleXCICanSaveLabel, (app.PotentialSavingsB - app.CurrentSavingsB) / _bytesPerMB);
}
else
{
return LocaleManager.Instance.UpdateAndGetDynamicValue(LocaleKeys.TitleXCISavingLabel, app.CurrentSavingsB / _bytesPerMB);
}
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotSupportedException();
}
}
}

View File

@ -0,0 +1,46 @@
using Avalonia;
using Avalonia.Data;
using Avalonia.Data.Converters;
using Ryujinx.Ava.Common.Locale;
using Ryujinx.UI.Common.Models;
using System;
using System.Globalization;
using static Ryujinx.Common.Utilities.XCIFileTrimmer;
namespace Ryujinx.Ava.UI.Helpers
{
internal class XCITrimmerFileStatusConverter : IValueConverter
{
public static XCITrimmerFileStatusConverter Instance = new();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is UnsetValueType)
{
return BindingOperations.DoNothing;
}
if (!targetType.IsAssignableFrom(typeof(string)))
{
return null;
}
if (value is not XCITrimmerFileModel app)
{
return null;
}
return app.PercentageProgress != null ? String.Empty :
app.ProcessingOutcome != OperationOutcome.Successful && app.ProcessingOutcome != OperationOutcome.Undetermined ? LocaleManager.Instance[LocaleKeys.TitleXCIStatusFailedLabel] :
app.Trimmable & app.Untrimmable ? LocaleManager.Instance[LocaleKeys.TitleXCIStatusPartialLabel] :
app.Trimmable ? LocaleManager.Instance[LocaleKeys.TitleXCIStatusTrimmableLabel] :
app.Untrimmable ? LocaleManager.Instance[LocaleKeys.TitleXCIStatusUntrimmableLabel] :
String.Empty;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotSupportedException();
}
}
}

View File

@ -0,0 +1,42 @@
using Avalonia;
using Avalonia.Data;
using Avalonia.Data.Converters;
using Ryujinx.UI.Common.Models;
using System;
using System.Globalization;
using static Ryujinx.Common.Utilities.XCIFileTrimmer;
namespace Ryujinx.Ava.UI.Helpers
{
internal class XCITrimmerFileStatusDetailConverter : IValueConverter
{
public static XCITrimmerFileStatusDetailConverter Instance = new();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is UnsetValueType)
{
return BindingOperations.DoNothing;
}
if (!targetType.IsAssignableFrom(typeof(string)))
{
return null;
}
if (value is not XCITrimmerFileModel app)
{
return null;
}
return app.PercentageProgress != null ? null :
app.ProcessingOutcome != OperationOutcome.Successful && app.ProcessingOutcome != OperationOutcome.Undetermined ? app.ProcessingOutcome.ToLocalisedText() :
null;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotSupportedException();
}
}
}

View File

@ -0,0 +1,36 @@
using Ryujinx.Ava.Common.Locale;
using static Ryujinx.Common.Utilities.XCIFileTrimmer;
namespace Ryujinx.Ava.UI.Helpers
{
public static class XCIFileTrimmerOperationOutcomeExtensions
{
public static string ToLocalisedText(this OperationOutcome operationOutcome)
{
switch (operationOutcome)
{
case OperationOutcome.NoTrimNecessary:
return LocaleManager.Instance[LocaleKeys.TrimXCIFileNoTrimNecessary];
case OperationOutcome.NoUntrimPossible:
return LocaleManager.Instance[LocaleKeys.TrimXCIFileNoUntrimPossible];
case OperationOutcome.ReadOnlyFileCannotFix:
return LocaleManager.Instance[LocaleKeys.TrimXCIFileReadOnlyFileCannotFix];
case OperationOutcome.FreeSpaceCheckFailed:
return LocaleManager.Instance[LocaleKeys.TrimXCIFileFreeSpaceCheckFailed];
case OperationOutcome.InvalidXCIFile:
return LocaleManager.Instance[LocaleKeys.TrimXCIFileInvalidXCIFile];
case OperationOutcome.FileIOWriteError:
return LocaleManager.Instance[LocaleKeys.TrimXCIFileFileIOWriteError];
case OperationOutcome.FileSizeChanged:
return LocaleManager.Instance[LocaleKeys.TrimXCIFileFileSizeChanged];
case OperationOutcome.Cancelled:
return LocaleManager.Instance[LocaleKeys.TrimXCIFileCancelled];
case OperationOutcome.Undetermined:
return LocaleManager.Instance[LocaleKeys.TrimXCIFileFileUndertermined];
case OperationOutcome.Successful:
default:
return null;
}
}
}
}

View File

@ -39,7 +39,6 @@ using SkiaSharp;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Globalization;
using System.IO;
using System.Linq;
@ -396,7 +395,7 @@ namespace Ryujinx.Ava.UI.ViewModels
public bool OpenDeviceSaveDirectoryEnabled => !SelectedApplication.ControlHolder.ByteSpan.IsZeros() && SelectedApplication.ControlHolder.Value.DeviceSaveDataSize > 0;
public bool TrimXCIEnabled => Ryujinx.Common.Utilities.XCIFileTrimmer.CanTrim(SelectedApplication.Path, new Common.XCIFileTrimmerLog(this));
public bool TrimXCIEnabled => Ryujinx.Common.Utilities.XCIFileTrimmer.CanTrim(SelectedApplication.Path, new Common.XCIFileTrimmerMainWindowLog(this));
public bool OpenBcatSaveDirectoryEnabled => !SelectedApplication.ControlHolder.ByteSpan.IsZeros() && SelectedApplication.ControlHolder.Value.BcatDeliveryCacheStorageSize > 0;
@ -1854,36 +1853,7 @@ namespace Ryujinx.Ava.UI.ViewModels
public async void ProcessTrimResult(String filename, Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome operationOutcome)
{
string notifyUser = null;
switch (operationOutcome)
{
case Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome.NoTrimNecessary:
notifyUser = LocaleManager.Instance[LocaleKeys.TrimXCIFileNoTrimNecessary];
break;
case Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome.ReadOnlyFileCannotFix:
notifyUser = LocaleManager.Instance[LocaleKeys.TrimXCIFileReadOnlyFileCannotFix];
break;
case Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome.FreeSpaceCheckFailed:
notifyUser = LocaleManager.Instance[LocaleKeys.TrimXCIFileFreeSpaceCheckFailed];
break;
case Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome.InvalidXCIFile:
notifyUser = LocaleManager.Instance[LocaleKeys.TrimXCIFileInvalidXCIFile];
break;
case Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome.FileIOWriteError:
notifyUser = LocaleManager.Instance[LocaleKeys.TrimXCIFileFileIOWriteError];
break;
case Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome.FileSizeChanged:
notifyUser = LocaleManager.Instance[LocaleKeys.TrimXCIFileFileSizeChanged];
break;
case Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome.Successful:
if (Avalonia.Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
if (desktop.MainWindow is MainWindow mainWindow)
mainWindow.LoadApplications();
}
break;
}
string notifyUser = operationOutcome.ToLocalisedText();
if (notifyUser != null)
{
@ -1892,6 +1862,19 @@ namespace Ryujinx.Ava.UI.ViewModels
notifyUser
);
}
else
{
switch (operationOutcome)
{
case Ryujinx.Common.Utilities.XCIFileTrimmer.OperationOutcome.Successful:
if (Avalonia.Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
if (desktop.MainWindow is MainWindow mainWindow)
mainWindow.LoadApplications();
}
break;
}
}
}
public async Task TrimXCIFile(string filename)
@ -1901,7 +1884,7 @@ namespace Ryujinx.Ava.UI.ViewModels
return;
}
var trimmer = new XCIFileTrimmer(filename, new Common.XCIFileTrimmerLog(this));
var trimmer = new XCIFileTrimmer(filename, new Common.XCIFileTrimmerMainWindowLog(this));
if (trimmer.CanBeTrimmed)
{
@ -1951,7 +1934,7 @@ namespace Ryujinx.Ava.UI.ViewModels
}
})
{
Name = "GUI.XCFileTrimmerThread",
Name = "GUI.XCIFileTrimmerThread",
IsBackground = true,
};
XCIFileTrimThread.Start();

View File

@ -0,0 +1,541 @@
using Avalonia.Collections;
using DynamicData;
using Gommon;
using Avalonia.Threading;
using Ryujinx.Ava.Common;
using Ryujinx.Ava.Common.Locale;
using Ryujinx.Ava.UI.Helpers;
using Ryujinx.Common.Utilities;
using Ryujinx.UI.App.Common;
using Ryujinx.UI.Common.Models;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using static Ryujinx.Common.Utilities.XCIFileTrimmer;
namespace Ryujinx.Ava.UI.ViewModels
{
public class XCITrimmerViewModel : BaseModel
{
private const long _bytesPerMB = 1024 * 1024;
private enum ProcessingMode
{
Trimming,
Untrimming
}
public enum SortField
{
Name,
Saved
}
private const string _FileExtXCI = "XCI";
private readonly Ryujinx.Common.Logging.XCIFileTrimmerLog _logger;
private readonly ApplicationLibrary _applicationLibrary;
private Optional<XCITrimmerFileModel> _processingApplication = null;
private AvaloniaList<XCITrimmerFileModel> _allXCIFiles = new();
private AvaloniaList<XCITrimmerFileModel> _selectedXCIFiles = new();
private AvaloniaList<XCITrimmerFileModel> _displayedXCIFiles = new();
private MainWindowViewModel _mainWindowViewModel;
private CancellationTokenSource _cancellationTokenSource;
private string _search;
private ProcessingMode _processingMode;
private SortField _sortField = SortField.Name;
private bool _sortAscending = true;
public XCITrimmerViewModel(MainWindowViewModel mainWindowViewModel)
{
_logger = new XCIFileTrimmerWindowLog(this);
_mainWindowViewModel = mainWindowViewModel;
_applicationLibrary = _mainWindowViewModel.ApplicationLibrary;
LoadXCIApplications();
}
private void LoadXCIApplications()
{
var apps = _applicationLibrary.Applications.Items
.Where(app => app.FileExtension == _FileExtXCI);
foreach (var xciApp in apps)
AddOrUpdateXCITrimmerFile(CreateXCITrimmerFile(xciApp.Path));
ApplicationsChanged();
}
private XCITrimmerFileModel CreateXCITrimmerFile(
string path,
OperationOutcome operationOutcome = OperationOutcome.Undetermined)
{
var xciApp = _applicationLibrary.Applications.Items.First(app => app.FileExtension == _FileExtXCI && app.Path == path);
return XCITrimmerFileModel.FromApplicationData(xciApp, _logger) with { ProcessingOutcome = operationOutcome };
}
private bool AddOrUpdateXCITrimmerFile(XCITrimmerFileModel xci, bool suppressChanged = true, bool autoSelect = true)
{
bool replaced = _allXCIFiles.ReplaceWith(xci);
_displayedXCIFiles.ReplaceWith(xci, Filter(xci));
_selectedXCIFiles.ReplaceWith(xci, xci.Trimmable && autoSelect);
if (!suppressChanged)
ApplicationsChanged();
return replaced;
}
private void FilteringChanged()
{
OnPropertyChanged(nameof(Search));
SortAndFilter();
}
private void SortingChanged()
{
OnPropertyChanged(nameof(IsSortedByName));
OnPropertyChanged(nameof(IsSortedBySaved));
OnPropertyChanged(nameof(SortingAscending));
OnPropertyChanged(nameof(SortingField));
OnPropertyChanged(nameof(SortingFieldName));
SortAndFilter();
}
private void DisplayedChanged()
{
OnPropertyChanged(nameof(Status));
OnPropertyChanged(nameof(DisplayedXCIFiles));
OnPropertyChanged(nameof(SelectedDisplayedXCIFiles));
}
private void ApplicationsChanged()
{
OnPropertyChanged(nameof(AllXCIFiles));
OnPropertyChanged(nameof(Status));
OnPropertyChanged(nameof(PotentialSavings));
OnPropertyChanged(nameof(ActualSavings));
OnPropertyChanged(nameof(CanTrim));
OnPropertyChanged(nameof(CanUntrim));
DisplayedChanged();
SortAndFilter();
}
private void SelectionChanged(bool displayedChanged = true)
{
OnPropertyChanged(nameof(Status));
OnPropertyChanged(nameof(CanTrim));
OnPropertyChanged(nameof(CanUntrim));
OnPropertyChanged(nameof(SelectedXCIFiles));
if (displayedChanged)
OnPropertyChanged(nameof(SelectedDisplayedXCIFiles));
}
private void ProcessingChanged()
{
OnPropertyChanged(nameof(Processing));
OnPropertyChanged(nameof(Cancel));
OnPropertyChanged(nameof(Status));
OnPropertyChanged(nameof(CanTrim));
OnPropertyChanged(nameof(CanUntrim));
}
private IEnumerable<XCITrimmerFileModel> GetSelectedDisplayedXCIFiles()
{
return _displayedXCIFiles.Where(xci => _selectedXCIFiles.Contains(xci));
}
private void PerformOperation(ProcessingMode processingMode)
{
if (Processing)
{
return;
}
_processingMode = processingMode;
Processing = true;
var cancellationToken = _cancellationTokenSource.Token;
Thread XCIFileTrimThread = new(() =>
{
var toProcess = Sort(SelectedXCIFiles
.Where(xci =>
(processingMode == ProcessingMode.Untrimming && xci.Untrimmable) ||
(processingMode == ProcessingMode.Trimming && xci.Trimmable)
)).ToList();
var viewsSaved = DisplayedXCIFiles.ToList();
Dispatcher.UIThread.Post(() =>
{
_selectedXCIFiles.Clear();
_displayedXCIFiles.Clear();
_displayedXCIFiles.AddRange(toProcess);
});
try
{
foreach (var xciApp in toProcess)
{
if (cancellationToken.IsCancellationRequested)
break;
var trimmer = new XCIFileTrimmer(xciApp.Path, _logger);
Dispatcher.UIThread.Post(() =>
{
ProcessingApplication = xciApp;
});
var outcome = OperationOutcome.Undetermined;
try
{
if (cancellationToken.IsCancellationRequested)
break;
switch (processingMode)
{
case ProcessingMode.Trimming:
outcome = trimmer.Trim(cancellationToken);
break;
case ProcessingMode.Untrimming:
outcome = trimmer.Untrim(cancellationToken);
break;
}
if (outcome == OperationOutcome.Cancelled)
outcome = OperationOutcome.Undetermined;
}
finally
{
Dispatcher.UIThread.Post(() =>
{
ProcessingApplication = CreateXCITrimmerFile(xciApp.Path);
AddOrUpdateXCITrimmerFile(ProcessingApplication, false, false);
ProcessingApplication = null;
});
}
}
}
finally
{
Dispatcher.UIThread.Post(() =>
{
_displayedXCIFiles.AddOrReplaceMatching(_allXCIFiles, viewsSaved);
_selectedXCIFiles.AddOrReplaceMatching(_allXCIFiles, toProcess);
Processing = false;
ApplicationsChanged();
});
}
})
{
Name = "GUI.XCIFilesTrimmerThread",
IsBackground = true,
};
XCIFileTrimThread.Start();
}
private bool Filter<T>(T arg)
{
if (arg is XCITrimmerFileModel content)
{
return string.IsNullOrWhiteSpace(_search)
|| content.Name.ToLower().Contains(_search.ToLower())
|| content.Path.ToLower().Contains(_search.ToLower());
}
return false;
}
private class CompareXCITrimmerFiles : IComparer<XCITrimmerFileModel>
{
private XCITrimmerViewModel _viewModel;
public CompareXCITrimmerFiles(XCITrimmerViewModel ViewModel)
{
_viewModel = ViewModel;
}
public int Compare(XCITrimmerFileModel x, XCITrimmerFileModel y)
{
int result = 0;
switch (_viewModel.SortingField)
{
case SortField.Name:
result = x.Name.CompareTo(y.Name);
break;
case SortField.Saved:
result = x.PotentialSavingsB.CompareTo(y.PotentialSavingsB);
break;
}
if (!_viewModel.SortingAscending)
result = -result;
if (result == 0)
result = x.Path.CompareTo(y.Path);
return result;
}
}
private IOrderedEnumerable<XCITrimmerFileModel> Sort(IEnumerable<XCITrimmerFileModel> list)
{
return list
.OrderBy(xci => xci, new CompareXCITrimmerFiles(this))
.ThenBy(it => it.Path);
}
public void TrimSelected()
{
PerformOperation(ProcessingMode.Trimming);
}
public void UntrimSelected()
{
PerformOperation(ProcessingMode.Untrimming);
}
public void SetProgress(int current, int maximum)
{
if (_processingApplication != null)
{
int percentageProgress = 100 * current / maximum;
if (!ProcessingApplication.HasValue || (ProcessingApplication.Value.PercentageProgress != percentageProgress))
ProcessingApplication = ProcessingApplication.Value with { PercentageProgress = percentageProgress };
}
}
public void SelectDisplayed()
{
SelectedXCIFiles.AddRange(DisplayedXCIFiles);
SelectionChanged();
}
public void DeselectDisplayed()
{
SelectedXCIFiles.RemoveMany(DisplayedXCIFiles);
SelectionChanged();
}
public void Select(XCITrimmerFileModel model)
{
bool selectionChanged = !SelectedXCIFiles.Contains(model);
bool displayedSelectionChanged = !SelectedDisplayedXCIFiles.Contains(model);
SelectedXCIFiles.ReplaceOrAdd(model, model);
if (selectionChanged)
SelectionChanged(displayedSelectionChanged);
}
public void Deselect(XCITrimmerFileModel model)
{
bool displayedSelectionChanged = !SelectedDisplayedXCIFiles.Contains(model);
if (SelectedXCIFiles.Remove(model))
SelectionChanged(displayedSelectionChanged);
}
public void SortAndFilter()
{
if (Processing)
return;
Sort(AllXCIFiles)
.AsObservableChangeSet()
.Filter(Filter)
.Bind(out var view).AsObservableList();
_displayedXCIFiles.Clear();
_displayedXCIFiles.AddRange(view);
DisplayedChanged();
}
public Optional<XCITrimmerFileModel> ProcessingApplication
{
get => _processingApplication;
set
{
if (!value.HasValue && _processingApplication.HasValue)
value = _processingApplication.Value with { PercentageProgress = null };
if (value.HasValue)
_displayedXCIFiles.ReplaceWith(value.Value);
_processingApplication = value;
OnPropertyChanged();
}
}
public bool Processing
{
get => _cancellationTokenSource != null;
private set
{
if (value && !Processing)
{
_cancellationTokenSource = new CancellationTokenSource();
}
else if (!value && Processing)
{
_cancellationTokenSource.Dispose();
_cancellationTokenSource = null;
}
ProcessingChanged();
}
}
public bool Cancel
{
get => _cancellationTokenSource != null && _cancellationTokenSource.IsCancellationRequested;
set
{
if (value)
{
if (!Processing)
return;
_cancellationTokenSource.Cancel();
}
ProcessingChanged();
}
}
public string Status
{
get
{
if (Processing)
{
return _processingMode switch
{
ProcessingMode.Trimming => string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerTitleStatusTrimming], DisplayedXCIFiles.Count),
ProcessingMode.Untrimming => string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerTitleStatusUntrimming], DisplayedXCIFiles.Count),
_ => string.Empty
};
}
else
{
return string.IsNullOrEmpty(Search) ?
string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerTitleStatusCount], SelectedXCIFiles.Count, AllXCIFiles.Count) :
string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerTitleStatusCountWithFilter], SelectedXCIFiles.Count, AllXCIFiles.Count, DisplayedXCIFiles.Count);
}
}
}
public string Search
{
get => _search;
set
{
_search = value;
FilteringChanged();
}
}
public SortField SortingField
{
get => _sortField;
set
{
_sortField = value;
SortingChanged();
}
}
public string SortingFieldName
{
get
{
return SortingField switch
{
SortField.Name => LocaleManager.Instance[LocaleKeys.XCITrimmerSortName],
SortField.Saved => LocaleManager.Instance[LocaleKeys.XCITrimmerSortSaved],
_ => string.Empty,
};
}
}
public bool SortingAscending
{
get => _sortAscending;
set
{
_sortAscending = value;
SortingChanged();
}
}
public bool IsSortedByName
{
get => _sortField == SortField.Name;
}
public bool IsSortedBySaved
{
get => _sortField == SortField.Saved;
}
public AvaloniaList<XCITrimmerFileModel> SelectedXCIFiles
{
get => _selectedXCIFiles;
set
{
_selectedXCIFiles = value;
SelectionChanged();
}
}
public AvaloniaList<XCITrimmerFileModel> AllXCIFiles
{
get => _allXCIFiles;
}
public AvaloniaList<XCITrimmerFileModel> DisplayedXCIFiles
{
get => _displayedXCIFiles;
}
public string PotentialSavings
{
get
{
return string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerSavingsMb], AllXCIFiles.Sum(xci => xci.PotentialSavingsB / _bytesPerMB));
}
}
public string ActualSavings
{
get
{
return string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerSavingsMb], AllXCIFiles.Sum(xci => xci.CurrentSavingsB / _bytesPerMB));
}
}
public IEnumerable<XCITrimmerFileModel> SelectedDisplayedXCIFiles
{
get
{
return GetSelectedDisplayedXCIFiles().ToList();
}
}
public bool CanTrim
{
get
{
return !Processing && _selectedXCIFiles.Any(xci => xci.Trimmable);
}
}
public bool CanUntrim
{
get
{
return !Processing && _selectedXCIFiles.Any(xci => xci.Untrimmable);
}
}
}
}

View File

@ -269,6 +269,8 @@
<MenuItem Header="{locale:Locale MenuBarToolsInstallFileTypes}" Click="InstallFileTypes_Click"/>
<MenuItem Header="{locale:Locale MenuBarToolsUninstallFileTypes}" Click="UninstallFileTypes_Click"/>
</MenuItem>
<Separator />
<MenuItem Header="{locale:Locale MenuBarToolsXCITrimmer}" Click="OpenXCITrimmerWindow" Icon="{icon:Icon fa-solid fa-scissors}" />
</MenuItem>
<MenuItem VerticalAlignment="Center" Header="{locale:Locale MenuBarView}">
<MenuItem VerticalAlignment="Center" Header="{locale:Locale MenuBarViewWindow}">

View File

@ -202,6 +202,8 @@ namespace Ryujinx.Ava.UI.Views.Main
await Updater.BeginParse(Window, true);
}
public async void OpenXCITrimmerWindow(object sender, RoutedEventArgs e) => await XCITrimmerWindow.Show(ViewModel);
public async void OpenAboutWindow(object sender, RoutedEventArgs e) => await AboutWindow.Show();
public void CloseWindow(object sender, RoutedEventArgs e) => Window.Close();

View File

@ -53,6 +53,7 @@
<TextBlock
Name="StatusBarProgressStatus"
Grid.Column="2"
MinWidth="200"
Margin="10,0,5,0"
VerticalAlignment="Center"
IsVisible="{Binding StatusBarProgressStatusVisible}"
@ -60,6 +61,7 @@
<ProgressBar
Name="LoadProgressBar"
Grid.Column="3"
MinWidth="200"
Height="6"
VerticalAlignment="Center"
Foreground="{DynamicResource SystemAccentColorLight2}"

View File

@ -0,0 +1,354 @@
<UserControl
x:Class="Ryujinx.Ava.UI.Windows.XCITrimmerWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:locale="clr-namespace:Ryujinx.Ava.Common.Locale"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="clr-namespace:Ryujinx.Ava.UI.ViewModels"
xmlns:models="clr-namespace:Ryujinx.UI.Common.Models;assembly=Ryujinx.UI.Common"
xmlns:ui="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:helpers="clr-namespace:Ryujinx.Ava.UI.Helpers"
Width="700"
Height="600"
x:DataType="viewModels:XCITrimmerViewModel"
Focusable="True"
mc:Ignorable="d">
<UserControl.Resources>
<helpers:XCITrimmerFileStatusConverter x:Key="StatusLabel" />
<helpers:XCITrimmerFileStatusDetailConverter x:Key="StatusDetailLabel" />
<helpers:XCITrimmerFileSpaceSavingsConverter x:Key="SpaceSavingsLabel" />
</UserControl.Resources>
<Grid Margin="20 0 20 0">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Panel
Margin="10 10 10 10"
Grid.Row="0">
<TextBlock Text="{Binding Status}" />
</Panel>
<Panel
Margin="0 0 10 10"
IsVisible="{Binding !Processing}"
Grid.Row="1">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel
Grid.Column="0"
Orientation="Horizontal">
<TextBlock
Margin="10,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Text="{locale:Locale CommonSort}" />
<DropDownButton
Width="150"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Content="{Binding SortingFieldName}">
<DropDownButton.Flyout>
<Flyout Placement="Bottom">
<StackPanel
Margin="0"
HorizontalAlignment="Stretch"
Orientation="Vertical">
<StackPanel>
<RadioButton
Checked="Sort_Checked"
Content="{locale:Locale XCITrimmerSortName}"
GroupName="Sort"
IsChecked="{Binding IsSortedByName, Mode=OneTime}"
Tag="Name" />
<RadioButton
Checked="Sort_Checked"
Content="{locale:Locale XCITrimmerSortSaved}"
GroupName="Sort"
IsChecked="{Binding IsSortedBySaved, Mode=OneTime}"
Tag="Saved" />
</StackPanel>
<Border
Width="60"
Height="2"
Margin="5"
HorizontalAlignment="Stretch"
BorderBrush="White"
BorderThickness="0,1,0,0">
<Separator Height="0" HorizontalAlignment="Stretch" />
</Border>
<RadioButton
Checked="Order_Checked"
Content="{locale:Locale OrderAscending}"
GroupName="Order"
IsChecked="{Binding SortingAscending, Mode=OneTime}"
Tag="Ascending" />
<RadioButton
Checked="Order_Checked"
Content="{locale:Locale OrderDescending}"
GroupName="Order"
IsChecked="{Binding !SortingAscending, Mode=OneTime}"
Tag="Descending" />
</StackPanel>
</Flyout>
</DropDownButton.Flyout>
</DropDownButton>
</StackPanel>
<TextBox
Grid.Column="1"
MinHeight="29"
MaxHeight="29"
Margin="5 0 5 0"
HorizontalAlignment="Stretch"
Watermark="{locale:Locale Search}"
Text="{Binding Search}" />
<StackPanel
Grid.Column="2"
Orientation="Horizontal">
<Button
Name="SelectDisplayedButton"
MinWidth="90"
Margin="5"
Command="{Binding SelectDisplayed}">
<TextBlock Text="{locale:Locale XCITrimmerSelectDisplayed}" />
</Button>
<Button
Name="DeselectDisplayedButton"
MinWidth="90"
Margin="5"
Command="{Binding DeselectDisplayed}">
<TextBlock Text="{locale:Locale XCITrimmerDeselectDisplayed}" />
</Button>
</StackPanel>
</Grid>
</Panel>
<Border
Grid.Row="2"
Margin="0 0 0 10"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
BorderBrush="{DynamicResource AppListHoverBackgroundColor}"
BorderThickness="1"
CornerRadius="5"
Padding="2.5">
<ListBox
AutoScrollToSelectedItem="{Binding Processing}"
SelectedItem="{Binding ProcessingApplication.Value}"
SelectionMode="Multiple, Toggle"
Background="Transparent"
SelectionChanged="OnSelectionChanged"
SelectedItems="{Binding SelectedDisplayedXCIFiles, Mode=OneWay}"
ItemsSource="{Binding DisplayedXCIFiles}"
IsEnabled="{Binding !Processing}">
<ListBox.DataTemplates>
<DataTemplate
DataType="models:XCITrimmerFileModel">
<Panel Margin="10">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="65*" />
<ColumnDefinition Width="35*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
Margin="10 0 10 0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
MaxLines="2"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
Text="{Binding Name}">
</TextBlock>
<Grid Grid.Column="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="45*" />
<ColumnDefinition Width="55*" />
</Grid.ColumnDefinitions>
<ProgressBar
Height="10"
Margin="10 0 10 0"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
CornerRadius="5"
IsVisible="{Binding $parent[UserControl].((viewModels:XCITrimmerViewModel)DataContext).Processing}"
Maximum="100"
Minimum="0"
Value="{Binding PercentageProgress}" />
<TextBlock
Grid.Column="0"
Margin="10 0 10 0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
MaxLines="1"
Text="{Binding ., Converter={StaticResource StatusLabel}}">
<ToolTip.Tip>
<StackPanel
IsVisible="{Binding IsFailed}">
<TextBlock
Classes="h1"
Text="{locale:Locale XCITrimmerTitleStatusFailed}" />
<TextBlock
Text="{Binding ., Converter={StaticResource StatusDetailLabel}}"
MaxLines="5"
MaxWidth="200"
MaxHeight="100"
TextTrimming="None"
TextWrapping="Wrap"/>
</StackPanel>
</ToolTip.Tip>
</TextBlock>
<TextBlock
Grid.Column="1"
Margin="10 0 10 0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
MaxLines="1"
Text="{Binding ., Converter={StaticResource SpaceSavingsLabel}}">>
</TextBlock>
</Grid>
</Grid>
</Panel>
</DataTemplate>
</ListBox.DataTemplates>
<ListBox.Styles>
<Style Selector="ListBoxItem">
<Setter Property="Background" Value="Transparent" />
</Style>
</ListBox.Styles>
</ListBox>
</Border>
<Border
Grid.Row="3"
Margin="0 0 0 10"
HorizontalAlignment="Stretch"
BorderBrush="{DynamicResource AppListHoverBackgroundColor}"
BorderThickness="1"
CornerRadius="5"
Padding="2.5">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock
Grid.Column="0"
Grid.Row="0"
Classes="h1"
Margin="5"
HorizontalAlignment="Right"
VerticalAlignment="Center"
MaxLines="1"
Text="{locale:Locale XCITrimmerPotentialSavings}" />
<TextBlock
Grid.Column="0"
Grid.Row="1"
Classes="h1"
Margin="5"
HorizontalAlignment="Right"
VerticalAlignment="Center"
MaxLines="1"
Text="{locale:Locale XCITrimmerActualSavings}" />
<TextBlock
Grid.Column="1"
Grid.Row="0"
Margin="5"
HorizontalAlignment="Left"
VerticalAlignment="Center"
MaxLines="1"
Text="{Binding PotentialSavings}" />
<TextBlock
Grid.Column="1"
Grid.Row="1"
Margin="5"
HorizontalAlignment="Left"
VerticalAlignment="Center"
MaxLines="1"
Text="{Binding ActualSavings}" />
</Grid>
</Border>
<Panel
Grid.Row="4"
HorizontalAlignment="Stretch">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel
Grid.Column="0"
Orientation="Horizontal"
Spacing="10"
HorizontalAlignment="Left">
<Button
Name="TrimButton"
MinWidth="90"
Margin="5"
Click="Trim"
IsEnabled="{Binding CanTrim}">
<TextBlock Text="Trim" />
</Button>
<Button
Name="UntrimButton"
MinWidth="90"
Margin="5"
Click="Untrim"
IsEnabled="{Binding CanUntrim}">
<TextBlock Text="Untrim" />
</Button>
</StackPanel>
<StackPanel
Grid.Column="1"
Orientation="Horizontal"
Spacing="10"
HorizontalAlignment="Right">
<Button
Name="CancellingButton"
MinWidth="90"
Margin="5"
Click="Cancel"
IsEnabled="False">
<Button.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<Binding Path="Processing" />
<Binding Path="Cancel" />
</MultiBinding>
</Button.IsVisible>
<TextBlock Text="{locale:Locale InputDialogCancelling}" />
</Button>
<Button
Name="CancelButton"
MinWidth="90"
Margin="5"
Click="Cancel">
<Button.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<Binding Path="Processing" />
<Binding Path="!Cancel" />
</MultiBinding>
</Button.IsVisible>
<TextBlock Text="{locale:Locale InputDialogCancel}" />
</Button>
<Button
Name="CloseButton"
MinWidth="90"
Margin="5"
Click="Close"
IsVisible="{Binding !Processing}">
<TextBlock Text="{locale:Locale InputDialogClose}" />
</Button>
</StackPanel>
</Grid>
</Panel>
</Grid>
</UserControl>

View File

@ -0,0 +1,101 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Styling;
using FluentAvalonia.UI.Controls;
using Ryujinx.Ava.Common.Locale;
using Ryujinx.Ava.UI.ViewModels;
using Ryujinx.UI.Common.Models;
using System;
using System.Threading.Tasks;
namespace Ryujinx.Ava.UI.Windows
{
public partial class XCITrimmerWindow : UserControl
{
public XCITrimmerViewModel ViewModel;
public XCITrimmerWindow()
{
DataContext = this;
InitializeComponent();
}
public XCITrimmerWindow(MainWindowViewModel mainWindowViewModel)
{
DataContext = ViewModel = new XCITrimmerViewModel(mainWindowViewModel);
InitializeComponent();
}
public static async Task Show(MainWindowViewModel mainWindowViewModel)
{
ContentDialog contentDialog = new()
{
PrimaryButtonText = "",
SecondaryButtonText = "",
CloseButtonText = "",
Content = new XCITrimmerWindow(mainWindowViewModel),
Title = string.Format(LocaleManager.Instance[LocaleKeys.XCITrimmerWindowTitle]),
};
Style bottomBorder = new(x => x.OfType<Grid>().Name("DialogSpace").Child().OfType<Border>());
bottomBorder.Setters.Add(new Setter(IsVisibleProperty, false));
contentDialog.Styles.Add(bottomBorder);
await contentDialog.ShowAsync();
}
private void Trim(object sender, RoutedEventArgs e)
{
ViewModel.TrimSelected();
}
private void Untrim(object sender, RoutedEventArgs e)
{
ViewModel.UntrimSelected();
}
private void Close(object sender, RoutedEventArgs e)
{
((ContentDialog)Parent).Hide();
}
private void Cancel(Object sender, RoutedEventArgs e)
{
ViewModel.Cancel = true;
}
public void Sort_Checked(object sender, RoutedEventArgs args)
{
if (sender is RadioButton { Tag: string sortField })
ViewModel.SortingField = Enum.Parse<XCITrimmerViewModel.SortField>(sortField);
}
public void Order_Checked(object sender, RoutedEventArgs args)
{
if (sender is RadioButton { Tag: string sortOrder })
ViewModel.SortingAscending = sortOrder is "Ascending";
}
private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
{
foreach (var content in e.AddedItems)
{
if (content is XCITrimmerFileModel applicationData)
{
ViewModel.Select(applicationData);
}
}
foreach (var content in e.RemovedItems)
{
if (content is XCITrimmerFileModel applicationData)
{
ViewModel.Deselect(applicationData);
}
}
}
}
}