status bar button + popup

This commit is contained in:
Asval 2026-06-07 18:53:20 +02:00
parent 05693db228
commit 86045e5026
10 changed files with 253 additions and 74 deletions

@ -1 +1 @@
Subproject commit 4c3332989787d0b57325d160137e9fa61d82a139
Subproject commit f44f491ff28d5dc79e998810aab9c5073ab8f33d

View File

@ -2,6 +2,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:FModel"
xmlns:viewModels="clr-namespace:FModel.ViewModels"
xmlns:controls="clr-namespace:FModel.Views.Resources.Controls"
xmlns:inputs="clr-namespace:FModel.Views.Resources.Controls.Inputs"
xmlns:converters="clr-namespace:FModel.Views.Resources.Converters"
@ -24,6 +25,28 @@
</TaskbarItemInfo.ProgressState>
</TaskbarItemInfo>
</Window.TaskbarItemInfo>
<Window.Resources>
<Canvas x:Key="ExportSessionIcon" x:Shared="False" Width="24" Height="24">
<Path Stroke="{DynamicResource {x:Static adonisUi:Brushes.AccentForegroundBrush}}"
StrokeThickness="2" StrokeStartLineCap="Round" StrokeEndLineCap="Round" StrokeLineJoin="Round"
Data="M12 21l-8 -4.5v-9l8 -4.5l8 4.5v4.5" />
<Path Stroke="{DynamicResource {x:Static adonisUi:Brushes.AccentForegroundBrush}}"
StrokeThickness="2" StrokeStartLineCap="Round" StrokeEndLineCap="Round" StrokeLineJoin="Round"
Data="M12 12l8 -4.5" />
<Path Stroke="{DynamicResource {x:Static adonisUi:Brushes.AccentForegroundBrush}}"
StrokeThickness="2" StrokeStartLineCap="Round" StrokeEndLineCap="Round" StrokeLineJoin="Round"
Data="M12 12v9" />
<Path Stroke="{DynamicResource {x:Static adonisUi:Brushes.AccentForegroundBrush}}"
StrokeThickness="2" StrokeStartLineCap="Round" StrokeEndLineCap="Round" StrokeLineJoin="Round"
Data="M12 12l-8 -4.5" />
<Path Stroke="{DynamicResource {x:Static adonisUi:Brushes.AccentForegroundBrush}}"
StrokeThickness="2" StrokeStartLineCap="Round" StrokeEndLineCap="Round" StrokeLineJoin="Round"
Data="M15 18h7" />
<Path Stroke="{DynamicResource {x:Static adonisUi:Brushes.AccentForegroundBrush}}"
StrokeThickness="2" StrokeStartLineCap="Round" StrokeEndLineCap="Round" StrokeLineJoin="Round"
Data="M19 15l3 3l-3 3" />
</Canvas>
</Window.Resources>
<adonisControls:AdonisWindow.Style>
<Style TargetType="adonisControls:AdonisWindow" BasedOn="{StaticResource {x:Type adonisControls:AdonisWindow}}" >
<Setter Property="Title" Value="{Binding DataContext.InitialWindowTitle, RelativeSource={RelativeSource Self}}" />
@ -152,6 +175,13 @@
</Viewbox>
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="Export Session">
<MenuItem.Icon>
<Viewbox Width="16" Height="16">
<StaticResource ResourceKey="ExportSessionIcon" />
</Viewbox>
</MenuItem.Icon>
</MenuItem>
</MenuItem>
<MenuItem Header="Settings" Command="{Binding MenuCommand}" CommandParameter="Settings" />
<MenuItem Header="Help" >
@ -211,7 +241,7 @@
<TextBlock Grid.Column="0" Text="Preview New Explorer System" VerticalAlignment="Center" />
<CheckBox Grid.Column="1" Margin="5 2 5 0" Unchecked="FeaturePreviewOnUnchecked" KeyboardNavigation.TabNavigation="None" KeyboardNavigation.ControlTabNavigation="None"
IsChecked="{Binding FeaturePreviewNewAssetExplorer, Source={x:Static local:Settings.UserSettings.Default}, Mode=TwoWay}"
IsChecked="{Binding FeaturePreviewNewAssetExplorer, Source={x:Static settings:UserSettings.Default}, Mode=TwoWay}"
Style="{DynamicResource {x:Static adonisUi:Styles.ToggleSwitch}}"/>
</Grid>
</Grid>
@ -698,23 +728,81 @@
</TextBlock>
</StatusBarItem>
<StatusBarItem Margin="0 0 5 0" HorizontalAlignment="Right">
<StatusBarItem Margin="0 0 5 0" Padding="0"
HorizontalAlignment="Right" VerticalAlignment="Stretch"
HorizontalContentAlignment="Stretch" VerticalContentAlignment="Stretch">
<StackPanel Orientation="Horizontal">
<StatusBarItem Margin="0 0 10 0" HorizontalContentAlignment="Stretch">
<Viewbox Width="16" Height="16">
<Canvas Width="24" Height="24">
<Path Fill="{DynamicResource {x:Static adonisUi:Brushes.AccentForegroundBrush}}" Data="{StaticResource StatusBarIcon}" />
</Canvas>
</Viewbox>
<StatusBarItem Margin="0 0 10 0" Padding="0" HorizontalContentAlignment="Stretch" VerticalContentAlignment="Stretch">
<Button x:Name="ExportSessionButton"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Center"
ToolTip="There are items waiting in the export queue, click to open the export session and process them!"
Background="{DynamicResource {x:Static adonisUi:Brushes.AlertBrush}}"
Style="{StaticResource StatusBarButton}"
Visibility="{Binding Session.TotalQueued, Source={x:Static viewModels:ExportSessionViewModel.Instance}, Converter={x:Static converters:IntGreaterThanZeroToVisibilityConverter.Instance}}">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Viewbox Width="16" Height="16">
<StaticResource ResourceKey="ExportSessionIcon" />
</Viewbox>
<TextBlock Margin="10 0 0 0" Text="{Binding Session.TotalQueued, Source={x:Static viewModels:ExportSessionViewModel.Instance}, Mode=OneWay, StringFormat=Queued Items: {0}}" />
</StackPanel>
</Button>
</StatusBarItem>
<StatusBarItem Margin="10 0 0 0">
<TextBlock Text="{Binding LastUpdateCheck, Source={x:Static local:Settings.UserSettings.Default}, Converter={x:Static converters:RelativeDateTimeConverter.Instance}, StringFormat=Last Refresh: {0}}" />
<StatusBarItem Margin="0">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Viewbox Width="16" Height="16">
<Canvas Width="24" Height="24">
<Path Fill="{DynamicResource {x:Static adonisUi:Brushes.AccentForegroundBrush}}" Data="{StaticResource StatusBarIcon}" />
</Canvas>
</Viewbox>
<TextBlock Margin="10 0 0 0" Text="{Binding LastUpdateCheck, Source={x:Static settings:UserSettings.Default}, Converter={x:Static converters:RelativeDateTimeConverter.Instance}, StringFormat=Last Refresh: {0}}" />
</StackPanel>
</StatusBarItem>
</StackPanel>
</StatusBarItem>
</StatusBar>
<controls:DropOverlay Grid.RowSpan="99"
Panel.ZIndex="1000" />
<controls:DropOverlay Grid.Row="0" Grid.RowSpan="99" Panel.ZIndex="1000" />
<Popup Grid.Row="0" Grid.RowSpan="99" Panel.ZIndex="999"
PlacementTarget="{Binding ElementName=ExportSessionButton}" Placement="Custom"
CustomPopupPlacementCallback="OnQueueToastCustomPopupPlacement"
IsOpen="{Binding ShowQueueToast, Source={x:Static viewModels:ExportSessionViewModel.Instance}}"
AllowsTransparency="True" IsHitTestVisible="False">
<Border CornerRadius="5" Padding="10"
Background="{DynamicResource {x:Static adonisUi:Brushes.Layer2BackgroundBrush}}"
BorderBrush="{DynamicResource {x:Static adonisUi:Brushes.Layer1BorderBrush}}"
BorderThickness="1">
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<Viewbox Width="28" Height="28" VerticalAlignment="Center" Margin="0 0 10 0">
<Canvas Width="24" Height="24">
<Path Stroke="{DynamicResource {x:Static adonisUi:Brushes.AccentBrush}}"
StrokeThickness="2" StrokeStartLineCap="Round" StrokeEndLineCap="Round" StrokeLineJoin="Round"
Data="M8 9h8" />
<Path Stroke="{DynamicResource {x:Static adonisUi:Brushes.AccentBrush}}"
StrokeThickness="2" StrokeStartLineCap="Round" StrokeEndLineCap="Round" StrokeLineJoin="Round"
Data="M8 13h6" />
<Path Stroke="{DynamicResource {x:Static adonisUi:Brushes.AccentBrush}}"
StrokeThickness="2" StrokeStartLineCap="Round" StrokeEndLineCap="Round" StrokeLineJoin="Round"
Data="M15 18h-2l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v5.5" />
<Path Stroke="{DynamicResource {x:Static adonisUi:Brushes.AccentBrush}}"
StrokeThickness="2" StrokeStartLineCap="Round" StrokeEndLineCap="Round" StrokeLineJoin="Round"
Data="M19 16v3" />
<Path Stroke="{DynamicResource {x:Static adonisUi:Brushes.AccentBrush}}"
StrokeThickness="2" StrokeStartLineCap="Round" StrokeEndLineCap="Round" StrokeLineJoin="Round"
Data="M19 22v.01" />
</Canvas>
</Viewbox>
<StackPanel VerticalAlignment="Center">
<TextBlock Text="Export Session" FontWeight="SemiBold"
Foreground="{DynamicResource {x:Static adonisUi:Brushes.ForegroundBrush}}" />
<TextBlock Text="{Binding ToolTip, ElementName=ExportSessionButton}"
MaxWidth="250" TextWrapping="Wrap" FontSize="11"
Foreground="{DynamicResource {x:Static adonisUi:Brushes.DisabledForegroundBrush}}" />
</StackPanel>
</StackPanel>
</Border>
</Popup>
</Grid>
</adonisControls:AdonisWindow>

View File

@ -376,4 +376,15 @@ public partial class MainWindow
childFolder.IsExpanded = true;
childFolder.IsSelected = true;
}
private CustomPopupPlacement[] OnQueueToastCustomPopupPlacement(Size popupSize, Size targetSize, Point offset)
{
return
[
new CustomPopupPlacement(
new Point((targetSize.Width - popupSize.Width) / 2, -popupSize.Height - 10),
PopupPrimaryAxis.Horizontal
)
];
}
}

View File

@ -36,6 +36,7 @@ namespace FModel.Settings
if (!_bSave || Default == null) return;
Default.PerDirectory[Default.CurrentDir.GameDirectory] = Default.CurrentDir;
File.WriteAllText(FilePath, JsonConvert.SerializeObject(Default, Formatting.Indented));
ExportSessionViewModel.Instance.Invalidate();
}
public static void Delete()

View File

@ -63,6 +63,7 @@ using CUE4Parse.UE4.Versions;
using CUE4Parse.UE4.Wwise;
using CUE4Parse.Utils;
using CUE4Parse_Conversion;
using CUE4Parse_Conversion.Exporters;
using CUE4Parse_Conversion.Sounds;
using EpicManifestParser;
using EpicManifestParser.UE;
@ -613,7 +614,7 @@ public class CUE4ParseViewModel : ViewModel
Parallel.ForEach(folder.AssetsList.Assets, entry =>
{
cancellationToken.ThrowIfCancellationRequested();
ExportData(entry.Asset, false);
ExportData(entry.Asset);
});
foreach (var f in folder.Folders) ExportFolder(cancellationToken, f);
@ -1516,10 +1517,11 @@ public class CUE4ParseViewModel : ViewModel
case UStaticMesh when HasFlag(bulk, EBulkType.Meshes):
case USkeletalMesh when HasFlag(bulk, EBulkType.Meshes):
case USkeleton when UserSettings.Default.SaveSkeletonAsMesh && HasFlag(bulk, EBulkType.Meshes):
// case UMaterialInstance when HasFlag(bulk, EBulkType.Materials): // read the fucking json
case UAnimSequenceBase when HasFlag(bulk, EBulkType.Animations):
// case UMaterialInterface when HasFlag(bulk, EBulkType.Materials): // read the fucking json
case UAnimationAsset when HasFlag(bulk, EBulkType.Animations):
// case UWorld when HasFlag(bulk, EBulkType.Worlds):
{
SaveExport(pointer.Object.Value, updateUi);
SaveExport(pointer.Object.Value);
return true;
}
default:
@ -1676,66 +1678,27 @@ public class CUE4ParseViewModel : ViewModel
});
}
private void SaveExport(UObject export, bool updateUi = true)
private void SaveExport(UObject export)
{
// TODO: export session
// var toSave = new Exporter(export, UserSettings.Default.ExportOptions);
// var toSaveDirectory = new DirectoryInfo(UserSettings.Default.ModelDirectory);
// if (toSave.TryWriteToDir(toSaveDirectory, out var label, out var savedFilePath))
// {
// Interlocked.Increment(ref ExportedCount);
// Log.Information("Successfully saved {FilePath}", savedFilePath);
// if (updateUi)
// {
// FLogger.Append(ELog.Information, () =>
// {
// FLogger.Text("Successfully saved ", Constants.WHITE);
// FLogger.Link(label, savedFilePath, true);
// });
// }
// }
// else
// {
// Interlocked.Increment(ref FailedExportCount);
// Log.Error("{FileName} could not be saved", export.Name);
// FLogger.Append(ELog.Error, () => FLogger.Text($"Could not save '{export.Name}'", Constants.WHITE, true));
// }
try
{
ExportSessionViewModel.Instance.Session.Add(export);
}
catch (Exception e)
{
Log.Error(e, "Could not add to export session");
}
}
private readonly object _rawData = new ();
public void ExportData(GameFile entry, bool updateUi = true)
public void ExportData(GameFile entry)
{
// TODO: export session
if (Provider.TrySavePackage(entry, out var assets))
try
{
string path = UserSettings.Default.RawDataDirectory;
Parallel.ForEach(assets, kvp =>
{
lock (_rawData)
{
path = Path.Combine(UserSettings.Default.RawDataDirectory, UserSettings.Default.KeepDirectoryStructure ? kvp.Key : kvp.Key.SubstringAfterLast('/')).Replace('\\', '/');
Directory.CreateDirectory(path.SubstringBeforeLast('/'));
File.WriteAllBytes(path, kvp.Value);
}
});
Interlocked.Increment(ref ExportedCount);
Log.Information("{FileName} successfully exported", entry.Name);
if (updateUi)
{
FLogger.Append(ELog.Information, () =>
{
FLogger.Text("Successfully exported ", Constants.WHITE);
FLogger.Link(entry.Name, path, true);
});
}
ExportSessionViewModel.Instance.Session.Add(new RawDataExporter(entry, Provider));
}
else
catch (Exception e)
{
Interlocked.Increment(ref FailedExportCount);
Log.Error("{FileName} could not be exported", entry.Name);
if (updateUi)
FLogger.Append(ELog.Error, () => FLogger.Text($"Could not export '{entry.Name}'", Constants.WHITE, true));
Log.Error(e, "Could not add to export session");
}
}

View File

@ -134,8 +134,8 @@ public class RightClickMenuCommand : ViewModelCommand<ApplicationViewModel>
Action<GameFile, EBulkType, bool> fileAction = bulktype switch
{
EBulkType.Raw => (entry, _, update) => contextViewModel.CUE4Parse.ExportData(entry, !update),
_ => (entry, bulk, update) => contextViewModel.CUE4Parse.Extract(cancellationToken, entry, false, bulk),
EBulkType.Raw => (entry, _, _) => contextViewModel.CUE4Parse.ExportData(entry),
_ => (entry, bulk, _) => contextViewModel.CUE4Parse.Extract(cancellationToken, entry, false, bulk),
};
foreach (var group in assetsGroups)

View File

@ -0,0 +1,85 @@
using System;
using System.ComponentModel;
using System.Windows;
using System.Windows.Threading;
using CUE4Parse_Conversion;
using FModel.Framework;
using FModel.Settings;
namespace FModel.ViewModels;
public class ExportSessionViewModel : ViewModel
{
public static ExportSessionViewModel Instance { get; } = new();
private int _previousCount;
private DispatcherTimer? _toastTimer;
public bool ShowQueueToast
{
get;
set => SetProperty(ref field, value);
}
private ExportSession? _session;
public ExportSession Session
{
get
{
if (_session != null) return _session;
_session = new ExportSession(UserSettings.Default.OutputDirectory, UserSettings.GetExportOptions());
_session.PropertyChanged += OnSessionPropertyChanged;
return _session;
}
}
private ExportSessionViewModel()
{
}
private void OnSessionPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(ExportSession.TotalQueued)) return;
var count = _session?.TotalQueued ?? 0;
Application.Current?.Dispatcher.InvokeAsync(() =>
{
switch (count)
{
case 1 when _previousCount == 0:
ShowToast();
break;
case 0:
HideToast();
break;
}
_previousCount = count;
});
}
private void ShowToast()
{
ShowQueueToast = true;
if (_toastTimer == null)
{
_toastTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(7.5) };
_toastTimer.Tick += (_, _) => HideToast();
}
_toastTimer.Stop();
_toastTimer.Start();
}
private void HideToast()
{
ShowQueueToast = false;
_toastTimer?.Stop();
}
public void Invalidate()
{
_session?.PropertyChanged -= OnSessionPropertyChanged;
_session = null;
Application.Current?.Dispatcher.InvokeAsync(HideToast);
}
}

View File

@ -63,7 +63,7 @@ public partial class UpdateViewModel : ViewModel
var coAuthorMap = new Dictionary<GitHubCommit, HashSet<string>>();
foreach (var commit in Commits)
{
if (!commit.Commit.Message.Contains("Co-authored-by"))
if (!commit.Commit.Message.Contains("Co-authored-by", StringComparison.OrdinalIgnoreCase))
continue;
var regex = GetCoAuthorRegex();

View File

@ -0,0 +1,17 @@
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace FModel.Views.Resources.Converters;
public class IntGreaterThanZeroToVisibilityConverter : IValueConverter
{
public static readonly IntGreaterThanZeroToVisibilityConverter Instance = new();
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
=> value is int n && n > 0 ? Visibility.Visible : Visibility.Collapsed;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@ -1356,7 +1356,7 @@
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
IsChecked="{Binding IsExpanded, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"
Style="{StaticResource CustomExpanderDownHeaderStyle}"/>
Style="{DynamicResource CustomExpanderDownHeaderStyle}"/>
<Border x:Name="ExpandSiteContainerWrapper"
DockPanel.Dock="Bottom"
@ -1818,6 +1818,20 @@
</Style.Triggers>
</Style>
<Style x:Key="StatusBarButton" TargetType="Button" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=StatusBar}}"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="VerticalContentAlignment" Value="Stretch"/>
<Setter Property="BorderThickness" Value="0"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="adonisExtensions:CursorSpotlightExtension.BackgroundBrush" Value="#33FFFFFF"/>
</Trigger>
</Style.Triggers>
</Style>
<Style x:Key="PlayPauseToolbarButton" TargetType="{x:Type Button}" BasedOn="{StaticResource {x:Type Button}}">
<Setter Property="Content">
<Setter.Value>