Skip to content

Commit bf02789

Browse files
authored
Sprite preview and animation (#95)
* add basic animation for selection * add controls for animation speed * add zoom control, slightly better ux
1 parent 713ab6c commit bf02789

File tree

3 files changed

+157
-60
lines changed

3 files changed

+157
-60
lines changed

AvaGui/ViewModels/SubObjectTypes/ImageTableViewModel.cs

Lines changed: 116 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
using AvaGui.Models;
2+
using Avalonia.Controls.Selection;
23
using Avalonia.Media.Imaging;
4+
using Avalonia.Threading;
35
using OpenLoco.Dat;
46
using OpenLoco.Dat.Types;
57
using ReactiveUI;
@@ -13,6 +15,7 @@
1315
using System.Reactive.Linq;
1416
using System.Threading.Tasks;
1517
using System.Windows.Input;
18+
using Image = SixLabors.ImageSharp.Image;
1619

1720
namespace AvaGui.ViewModels
1821
{
@@ -36,65 +39,85 @@ public ImageTableViewModel(IHasG1Elements g1ElementProvider, IImageTableNameProv
3639
.Subscribe(_ => this.RaisePropertyChanged(nameof(Images)));
3740
_ = this.WhenAnyValue(o => o.SelectedImageIndex)
3841
.Subscribe(_ => this.RaisePropertyChanged(nameof(SelectedG1Element)));
39-
4042
_ = this.WhenAnyValue(o => o.Images)
4143
.Subscribe(_ => this.RaisePropertyChanged(nameof(Images)));
44+
_ = this.WhenAnyValue(o => o.AnimationSpeed)
45+
.Subscribe(_ => UpdateAnimationSpeed());
4246

4347
ImportImagesCommand = ReactiveCommand.Create(ImportImages);
4448
ExportImagesCommand = ReactiveCommand.Create(ExportImages);
49+
50+
SelectionModel = new SelectionModel<Bitmap>
51+
{
52+
SingleSelect = false
53+
};
54+
SelectionModel.SelectionChanged += SelectionChanged;
55+
56+
// Set up the animation timer
57+
animationTimer = new DispatcherTimer
58+
{
59+
Interval = TimeSpan.FromMilliseconds(25) // Adjust animation speed as needed
60+
};
61+
animationTimer.Tick += AnimationTimer_Tick;
62+
animationTimer.Start();
4563
}
4664

47-
public async Task ImportImages()
65+
readonly DispatcherTimer animationTimer;
66+
int currentFrameIndex;
67+
68+
public IList<Bitmap> SelectedBitmaps { get; set; }
69+
70+
[Reactive]
71+
public Bitmap SelectedBitmapPreview { get; set; }
72+
73+
[Reactive]
74+
public int AnimationWindowHeight { get; set; }
75+
76+
void SelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e)
4877
{
49-
var folders = await PlatformSpecific.OpenFolderPicker();
50-
var dir = folders.FirstOrDefault();
51-
if (dir == null)
78+
var sm = (SelectionModel<Bitmap>)sender;
79+
80+
if (sm.SelectedIndexes.Count > 0)
5281
{
53-
return;
82+
SelectedImageIndex = sm.SelectedIndex;
5483
}
5584

56-
var dirPath = dir.Path.LocalPath;
57-
if (Directory.Exists(dirPath) && Directory.EnumerateFiles(dirPath).Any())
58-
{
59-
var files = Directory.GetFiles(dirPath);
60-
var sorted = files.OrderBy(f => int.Parse(Path.GetFileNameWithoutExtension(f).Split('-')[0]));
85+
// ... handle selection changed
86+
SelectedBitmaps = sm.SelectedItems.Cast<Bitmap>().ToList();
87+
AnimationWindowHeight = (int)SelectedBitmaps.Max(x => x.Size.Height) * 2;
88+
}
6189

62-
var g1Elements = new List<G1Element32>();
63-
var i = 0;
64-
foreach (var file in sorted)
65-
{
66-
var img = Image.Load<Rgba32>(file);
67-
Images[i] = img;
68-
var currG1 = G1Provider.G1Elements[i++];
69-
currG1.ImageData = PaletteMap.ConvertRgba32ImageToG1Data(img, currG1.Flags); // simply overwrite existing pixel data
70-
}
90+
[Reactive]
91+
public int AnimationSpeed { get; set; } = 40;
92+
93+
void UpdateAnimationSpeed()
94+
{
95+
if (animationTimer == null)
96+
{
97+
return;
7198
}
7299

73-
this.RaisePropertyChanged(nameof(Bitmaps));
74-
//this.RaisePropertyChanged(nameof(Images));
100+
animationTimer.Interval = TimeSpan.FromMilliseconds(1000 / AnimationSpeed);
75101
}
76102

77-
public async Task ExportImages()
103+
private void AnimationTimer_Tick(object? sender, EventArgs e)
78104
{
79-
var folders = await PlatformSpecific.OpenFolderPicker();
80-
var dir = folders.FirstOrDefault();
81-
if (dir == null)
105+
if (SelectedBitmaps == null || SelectedBitmaps.Count == 0)
82106
{
83107
return;
84108
}
85109

86-
var dirPath = dir.Path.LocalPath;
87-
if (Directory.Exists(dirPath))
110+
if (currentFrameIndex >= SelectedBitmaps.Count)
88111
{
89-
var counter = 0;
90-
foreach (var image in Images)
91-
{
92-
var imageName = counter++.ToString(); // todo: use GetImageName from winforms project
93-
var path = Path.Combine(dir.Path.LocalPath, $"{imageName}.png");
94-
//logger.Debug($"Saving image to {path}");
95-
await image.SaveAsPngAsync(path);
96-
}
112+
currentFrameIndex = 0;
97113
}
114+
115+
// Update the displayed image
116+
SelectedBitmapPreview = SelectedBitmaps[currentFrameIndex];
117+
SelectedImageIndex = SelectionModel.SelectedIndexes[currentFrameIndex];
118+
119+
// Move to the next frame, looping back to the beginning if necessary
120+
currentFrameIndex = (currentFrameIndex + 1) % SelectedBitmaps.Count;
98121
}
99122

100123
[Reactive]
@@ -109,9 +132,11 @@ public async Task ExportImages()
109132
[Reactive]
110133
public int Zoom { get; set; } = 1;
111134

135+
// where the actual image data is stored
112136
[Reactive]
113137
public IList<Image<Rgba32>> Images { get; set; }
114138

139+
// what is displaying on the ui
115140
public IList<Bitmap?> Bitmaps
116141
{
117142
get
@@ -139,11 +164,66 @@ public IList<Bitmap?> Bitmaps
139164
[Reactive]
140165
public int SelectedImageIndex { get; set; } = -1;
141166

167+
[Reactive]
168+
public SelectionModel<Bitmap> SelectionModel { get; set; }
169+
142170
public UIG1Element32? SelectedG1Element
143171
=> SelectedImageIndex == -1 || G1Provider.G1Elements.Count == 0
144172
? null
145173
: new UIG1Element32(SelectedImageIndex, GetImageName(NameProvider, SelectedImageIndex), G1Provider.G1Elements[SelectedImageIndex]);
146174

175+
public async Task ImportImages()
176+
{
177+
var folders = await PlatformSpecific.OpenFolderPicker();
178+
var dir = folders.FirstOrDefault();
179+
if (dir == null)
180+
{
181+
return;
182+
}
183+
184+
var dirPath = dir.Path.LocalPath;
185+
if (Directory.Exists(dirPath) && Directory.EnumerateFiles(dirPath).Any())
186+
{
187+
var files = Directory.GetFiles(dirPath);
188+
var sorted = files.OrderBy(f => int.Parse(Path.GetFileNameWithoutExtension(f).Split('-')[0]));
189+
190+
var g1Elements = new List<G1Element32>();
191+
var i = 0;
192+
foreach (var file in sorted)
193+
{
194+
var img = Image.Load<Rgba32>(file);
195+
Images[i] = img;
196+
var currG1 = G1Provider.G1Elements[i++];
197+
currG1.ImageData = PaletteMap.ConvertRgba32ImageToG1Data(img, currG1.Flags); // simply overwrite existing pixel data
198+
}
199+
}
200+
201+
this.RaisePropertyChanged(nameof(Bitmaps));
202+
}
203+
204+
public async Task ExportImages()
205+
{
206+
var folders = await PlatformSpecific.OpenFolderPicker();
207+
var dir = folders.FirstOrDefault();
208+
if (dir == null)
209+
{
210+
return;
211+
}
212+
213+
var dirPath = dir.Path.LocalPath;
214+
if (Directory.Exists(dirPath))
215+
{
216+
var counter = 0;
217+
foreach (var image in Images)
218+
{
219+
var imageName = counter++.ToString(); // todo: use GetImageName from winforms project
220+
var path = Path.Combine(dir.Path.LocalPath, $"{imageName}.png");
221+
//logger.Debug($"Saving image to {path}");
222+
await image.SaveAsPngAsync(path);
223+
}
224+
}
225+
}
226+
147227
public static string GetImageName(IImageTableNameProvider nameProvider, int counter)
148228
=> nameProvider.TryGetImageName(counter, out var value) && !string.IsNullOrEmpty(value)
149229
? value

AvaGui/Views/MainWindow.axaml

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,7 @@
354354
</DataTemplate>
355355

356356
<DataTemplate DataType="vm:ImageTableViewModel">
357+
357358
<DockPanel>
358359
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal">
359360
<Button Command="{Binding ImportImagesCommand}" HorizontalAlignment="Stretch" Margin="2" Padding="2" ToolTip.Tip="Import from Directory">
@@ -382,31 +383,46 @@
382383
</Button.Flyout>
383384
</Button>
384385
</StackPanel>
385-
<Border BorderThickness="1" DockPanel.Dock="Right">
386-
<StackPanel>
387-
<pgc:PropertyGrid x:Name="propertyGrid_imageProps" MinWidth="256" Margin="2" IsEnabled="False" DataContext="{Binding SelectedG1Element, Mode=OneWay}" DockPanel.Dock="Right" ShowTitle="False" AllowFilter="False" AllowQuickFilter="False" ShowStyle="Builtin"></pgc:PropertyGrid>
388-
</StackPanel>
389-
</Border>
390-
<ScrollViewer>
391-
<Border BorderThickness="1">
392-
<ListBox ItemsSource="{Binding Bitmaps}" SelectedIndex="{Binding SelectedImageIndex}" Background="{Binding #ImageBackgroundColorView.Color, ConverterParameter={x:Static Brushes.Transparent}, Converter={StaticResource ColorToBrushConverter}}">
393-
<ListBox.ItemsPanel>
394-
<ItemsPanelTemplate>
395-
<WrapPanel/>
396-
</ItemsPanelTemplate>
397-
</ListBox.ItemsPanel>
398-
<ListBox.ItemTemplate>
399-
<DataTemplate>
400-
<Image Source="{Binding}" Stretch="None" Margin="0">
401-
<!--<Image.RenderTransform>
402-
<ScaleTransform ScaleX="{Binding Zoom}" ScaleY="{Binding Zoom}" />
403-
</Image.RenderTransform>-->
386+
<Grid ColumnDefinitions="*, Auto, 256">
387+
<ScrollViewer>
388+
<Border BorderThickness="1">
389+
<ListBox ItemsSource="{Binding Bitmaps}" SelectionMode="Multiple" Selection="{Binding SelectionModel}" Background="{Binding #ImageBackgroundColorView.Color, ConverterParameter={x:Static Brushes.Transparent}, Converter={StaticResource ColorToBrushConverter}}">
390+
<ListBox.ItemsPanel>
391+
<ItemsPanelTemplate>
392+
<WrapPanel/>
393+
</ItemsPanelTemplate>
394+
</ListBox.ItemsPanel>
395+
<ListBox.ItemTemplate>
396+
<DataTemplate>
397+
<Image Source="{Binding}" Stretch="None" Margin="0" />
398+
</DataTemplate>
399+
</ListBox.ItemTemplate>
400+
</ListBox>
401+
</Border>
402+
</ScrollViewer>
403+
<GridSplitter Grid.Column="1" />
404+
<Border BorderThickness="1" Grid.Column="2">
405+
<DockPanel>
406+
<Border BorderThickness="1" DockPanel.Dock="Top">
407+
<StackPanel Orientation="Vertical" MaxHeight="512">
408+
<Image Name="AnimationPreviewPP" Source="{Binding SelectedBitmapPreview}" Stretch="None" Margin="4" MaxHeight="1024" MinHeight="64" Height="{Binding AnimationWindowHeight}" Width="256">
409+
<Image.RenderTransform>
410+
<ScaleTransform ScaleX="{Binding Zoom}" ScaleY="{Binding Zoom}" />
411+
</Image.RenderTransform>
412+
404413
</Image>
405-
</DataTemplate>
406-
</ListBox.ItemTemplate>
407-
</ListBox>
414+
<TextBlock HorizontalAlignment="Center" Text="{Binding AnimationSpeed, StringFormat='Animation FPS: {0}'}"></TextBlock>
415+
<Slider Minimum="1" Maximum="40" Value="{Binding AnimationSpeed}" />
416+
<TextBlock HorizontalAlignment="Center" Text="{Binding Zoom, StringFormat='Zoom: {0}x'}"></TextBlock>
417+
<Slider Minimum="1" Maximum="10" Value="{Binding Zoom}" />
418+
</StackPanel>
419+
</Border>
420+
<ScrollViewer>
421+
<pgc:PropertyGrid x:Name="propertyGrid_imageProps" Margin="8" MinWidth="256" IsEnabled="False" DataContext="{Binding SelectedG1Element, Mode=OneWay}" DockPanel.Dock="Right" ShowTitle="False" AllowFilter="False" AllowQuickFilter="False" ShowStyle="Builtin"></pgc:PropertyGrid>
422+
</ScrollViewer>
423+
</DockPanel>
408424
</Border>
409-
</ScrollViewer>
425+
</Grid>
410426
</DockPanel>
411427
</DataTemplate>
412428

AvaGui/Views/MainWindow.axaml.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ namespace AvaGui.Views
44
{
55
public partial class MainWindow : Window
66
{
7-
public MainWindow() => InitializeComponent();
7+
public MainWindow()
8+
=> InitializeComponent();
89
}
910
}

0 commit comments

Comments
 (0)