feat: MainView

This commit is contained in:
2026-04-13 09:29:03 +03:00
parent 8cf1c89863
commit 16c5b5d42f
9 changed files with 518 additions and 3 deletions
+12
View File
@@ -0,0 +1,12 @@
<Application
x:Class="GSS2.Application"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:GSS2"
RequestedThemeVariant="Dark">
<Application.Styles>
<FluentTheme />
</Application.Styles>
</Application>
+152
View File
@@ -0,0 +1,152 @@
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using GSS2.ViewModels;
using GSS2.Views;
using GSS2.UI.Core;
using GSS2.UI.Core.Services;
using GSS2.UI.Core.ViewModels;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace GSS2;
public partial class Application : Avalonia.Application
{
private readonly IServiceProvider _services;
private readonly bool _fullscreen;
private readonly ILogger<Application> _logger;
private readonly DependencyInjectionViewLocator _viewLocator;
private bool _allShutdownSequencesRunning;
public Application(IServiceProvider services, bool fullscreen = false)
{
_services = services;
_fullscreen = fullscreen;
// Создаём AvaloniaLogSink, который перенаправляет логи из Avalonia в хост
var loggerFactory = _services.GetRequiredService<ILoggerFactory>();
Avalonia.Logging.Logger.Sink = new AvaloniaLogSink(loggerFactory);
// Создаём DependencyInjectionViewLocator
_logger = _services.GetRequiredService<ILogger<Application>>();
_viewLocator = _services.GetRequiredService<DependencyInjectionViewLocator>();
}
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
// Добавляем наш DependencyInjectionViewLocator в DataTemplates приложения
DataTemplates.Add(_viewLocator);
}
public override void OnFrameworkInitializationCompleted()
{
// Получаем lifetime хоста
var hostLifetime = _services.GetRequiredService<IHostApplicationLifetime>();
// Регистрируем обработчик начатия Ctrl+C
Console.CancelKeyPress += (_, _) =>
{
_logger.LogInformation("Закрытие приложения вызвано из консоли");
hostLifetime.StopApplication();
};
// Создаём обработчик события закрытия хоста
hostLifetime.ApplicationStopped.Register(() =>
{
// Если всё что нужно для закрытия приложения уже вызвано, то ничего не делаем
if (_allShutdownSequencesRunning)
return;
// Иначе это событие вызвано не из интерфейса и графический интерфейс нужно закрыть
Dispatcher.InvokeShutdown();
_allShutdownSequencesRunning = true;
});
// Создаём обработчик события закрытия графического интерфейса
Dispatcher.ShutdownStarted += (_, _) =>
{
// Если всё что нужно для закрытия приложения уже вызвано, то ничего не делаем
if (_allShutdownSequencesRunning)
return;
// Иначе это событие вызвано из интерфейса и хост нужно закрыть
_logger.LogInformation("Закрытие приложения вызвано из интерфейса");
hostLifetime.StopApplication();
_allShutdownSequencesRunning = true;
};
// ApplicationLifetime == null, то невозможно определить в какой среде мы запускаемся,
// но такого быть не должно
if (ApplicationLifetime is null)
{
_logger.LogCritical("Приложение не может быть полностью инициализировано, ApplicationLifetime == null");
// Я не уверен, будет ли правильно в данном классе вместо выбрасывания исключений,
// использовать Environment.Exit,
// но если использовать исключения, то их может поймать какой-нибудь обработчик,
// а Environment.Exit гарантированно завершит текущий процесс
hostLifetime.StopApplication();
Environment.Exit(1);
}
// Создаём сервис навигации между элементами управления
var navigationService = _services.GetRequiredService<NavigationService>();
// Создаём начальный элемент управления и переходим к нему
var mainView = new MainView()
{
DataContext = _services.GetRequiredService<MainViewModel>()
};
// Создаём главный элемент управления
// на основании типа ApplicationLifetime, который зависит от того как мы запустили приложение
// StartWithClassicDesktopLifetime IClassicDesktopStyleApplicationLifetime
// StartLinuxDrm ISingleViewApplicationLifetime
switch (ApplicationLifetime)
{
case IClassicDesktopStyleApplicationLifetime desktop:
_logger.LogInformation("Приложение запускается в классическом режиме");
_logger.LogInformation("Приложение запускается в {} режиме", _fullscreen ? "полноэкранном" : "оконном");
// Создаём главное окно
var mainWindow = new MainWindow()
{
DataContext = new MainWindowViewModel()
{
WindowDecorations = _fullscreen ? Avalonia.Controls.WindowDecorations.None : Avalonia.Controls.WindowDecorations.Full,
Topmost = _fullscreen,
WindowState = _fullscreen ? Avalonia.Controls.WindowState.FullScreen : Avalonia.Controls.WindowState.Normal
},
};
desktop.MainWindow = mainWindow;
// Переключаемся на начальный элемент управления
navigationService.NavigateTo(mainView);
break;
case ISingleViewApplicationLifetime singleView:
_logger.LogInformation("Приложение запускается в режиме отображение единой формы");
// Просто устанавливаем главным элементом управления начальный элемент
singleView.MainView = mainView;
break;
default:
_logger.LogCritical("Приложение не может быть полностью инициализировано, ApplicationLifetime имеет неизвестный тип, наследованный от: {}", string.Join(", ", ApplicationLifetime.GetType().GetInterfaces().Select(i => i.FullName)));
hostLifetime.StopApplication();
Environment.Exit(1);
break;
}
base.OnFrameworkInitializationCompleted();
}
}
+128 -2
View File
@@ -1,5 +1,17 @@
using System.CommandLine; using System.CommandLine;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Avalonia;
using Avalonia.OpenGL.Egl;
using GSS2.Core.Logging;
using GSS2.Core;
using GSS2.Core.Analysis.AmineContent.Database.Calibration;
using GSS2.Core.Analysis.AmineContent.Database.Results;
namespace GSS2.Commands; namespace GSS2.Commands;
public static class UI public static class UI
@@ -31,7 +43,6 @@ public static class UI
RenderingMode = new("--mode", ["--rendering-mode", "-m"]) RenderingMode = new("--mode", ["--rendering-mode", "-m"])
{ {
Description = "Режим рендеринга", Description = "Режим рендеринга",
DefaultValueFactory = (_) => RenderingModes.X11 DefaultValueFactory = (_) => RenderingModes.X11
}; };
HideConsole = new("--hide-console", ["--no-console", "-c"]) HideConsole = new("--hide-console", ["--no-console", "-c"])
@@ -53,7 +64,6 @@ public static class UI
Command.Add(RenderingMode); Command.Add(RenderingMode);
Command.Add(HideConsole); Command.Add(HideConsole);
Command.SetAction(CommandAction); Command.SetAction(CommandAction);
} }
/// <summary> /// <summary>
@@ -69,6 +79,122 @@ public static class UI
private static void CommandAction(ParseResult result) private static void CommandAction(ParseResult result)
{ {
/// Получаем значения аргументов командной строки
var fullscreen = result.GetValue(Fullscreen);
var renderingMode = result.GetValue(RenderingMode);
var hideConsole = result.GetValue(HideConsole);
// Если выбран режим рендеринга DRM, то приложение будет рисоваться вместе с консолью,
// поэтому если не перехватить консоль, то вывод в неё будет рисоваться вместе с интерфейсом
if (hideConsole)
SilenceConsole();
// Собираем хост
var host = BuildHost();
// Запускаем хост асинхронно без блокировки потока
_ = host.RunAsync();
// Собираем графическое приложение
var ui = BuildApplication(host, fullscreen);
switch (renderingMode)
{
case RenderingModes.X11:
// Если мы запускаемся под X11,
// то очевидно у нас есть полноценный рабочий стол,
// поэтому запускаем графический интерфейс в классическом режиме с главным окном
ui.StartWithClassicDesktopLifetime(Program.Args);
break;
case RenderingModes.DRM:
// Если мы запускаемся в DRM (Direct Rendering Mode),
// то у нас нет рабочего стола и окон,
// потому что отрисовка (вроде как) идёт напрямую в кадровый буфер,
// поэтому запускаем графический интерфейс в DRM.
// Если работает X11, или любая другая графическая система,
// то запустить приложение в DRM не получится,
// потому что DRI (Direct Rendering Interface) занят этой графической системой
// TODO: Подумать над тем, чтобы брать DRI и scale для DRM рендеринга из переменных среды
// TODO: Подумать над тем, чтобы при запуске в DRM убивать приложения, которые занимают DRI
ui.StartLinuxDrm(Program.Args, "/dev/dri/card1", 1.0);
break;
} }
} }
private static void SilenceConsole()
{
var start = new ThreadStart(() =>
{
Console.CursorVisible = false;
while (true)
Console.ReadKey(true);
});
var thread = new Thread(start)
{
IsBackground = true
};
thread.Start();
}
private static IHost BuildHost()
{
// Создаём хост
var builder = Host.CreateApplicationBuilder();
// Добавляем конфигурацию из appsettings.json
builder.Configuration.AddAppSettingsJson();
// Конфигурируем логгер
builder.Logging.ConfigureLogging();
builder.Services.AddSingleton<LibCameraLogSink>();
// Добавляем сервисы баз данных
builder.Services.AddAmineContentCalibrationContext(builder.Configuration);
builder.Services.AddAmineContentResultsContext(builder.Configuration);
// Добавляем сервисы аппаратной части и прочее
builder.Services.AddLightsService("Hardware:Lights");
builder.Services.AddTemperatureHumidityService("Hardware:TemperatureHumidity");
builder.Services.AddIlluminatorService("Hardware:Illuminator");
builder.Services.AddCameraService("Hardware:Camera", true);
builder.Services.AddComputeResourcesService();
builder.Services.AddImageStorageService(new DirectoryInfo("images"));
// Добавляем элементы интерфейса как сервисы
builder.Services.AddViewsAndModels();
// Собираем хост
var host = builder.Build();
// Поскольку LibCameraLogSink, который перенаправляет логи из libcamera в хост,
// не будет создан ни одним из других сервисов, его необходимо создать превентивно
host.Services.GetRequiredService<LibCameraLogSink>();
// Применяем миграции баз данных если их схема данных изменялась
using (var scope = host.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AmineContentCalibrationContext>();
context.Database.Migrate();
}
using (var scope = host.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AmineContentResultsContext>();
context.Database.Migrate();
}
return host;
}
private static AppBuilder BuildApplication(IHost host, bool fullscreen) =>
AppBuilder.Configure(() => new Application(host.Services, fullscreen))
.UsePlatformDetect()
.UseSkia()
.LogToTrace()
.With(new X11PlatformOptions
{
RenderingMode = [X11RenderingMode.Egl]
})
.With(new EglDisplayOptions
{
SupportsContextSharing = true
});
}
+27
View File
@@ -0,0 +1,27 @@
using Microsoft.Extensions.DependencyInjection;
using GSS2.UI.Core.ViewModels;
using GSS2.UI.Core.Services;
using GSS2.Views;
using GSS2.ViewModels;
using Microsoft.Extensions.Logging;
namespace GSS2;
public static class DependencyInjectionHelper
{
public static IServiceCollection AddViewsAndModels(this IServiceCollection services) =>
services
.AddSingleton<DependencyInjectionViewLocator>((services) =>
new DependencyInjectionViewLocator(
services.GetRequiredService<ILogger<DependencyInjectionViewLocator>>(),
services
)
)
.AddSingleton<NavigationService>((_) => new NavigationService(Application.Current?.ApplicationLifetime))
.AddSingleton<MainWindowViewModel>()
.AddSingleton<MainView>()
.AddSingleton<MainViewModel>()
.AddSingleton<AmineContentView>()
.AddSingleton<AmineContentViewModel>();
}
+76
View File
@@ -0,0 +1,76 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using GSS2.UI.Core.ViewModels;
namespace GSS2;
/// <summary>
/// <see cref="DependencyInjectionViewLocator"/> - интерфейс между Avalonia и механизмом DI (Dependency Injection)
/// из Microsoft.Extensions.Hosting.<br/>
/// Создаёт View для переданной ViewModel.<br/>
/// Обязательным условием его работы является, то что:
/// <list type="bullet">
/// <item>Класс <i>View</i> находится в пространстве имён <i>SomeNameSpace.Views</i> и имеет имя <i>SomeClassView</i></item>
/// <item>Класс <i>ViewModel</i> находится в пространстве имён <i>SomeNameSpace.ViewModels</i> и имеет имя <i>SomeClassViewModel</i></item>
/// <item>Класс <i>ViewModel</i> наследован от <see cref="ViewModelBase"/></item>
/// <item>Класс <i>View</i> наследован от <see cref="Control"/></item>
/// </list>
/// </summary>
public class DependencyInjectionViewLocator : IDataTemplate
{
private readonly ILogger<DependencyInjectionViewLocator> _logger;
private readonly IServiceProvider _services;
public DependencyInjectionViewLocator(ILogger<DependencyInjectionViewLocator> logger, IServiceProvider services)
{
_logger = logger;
_services = services;
}
public bool Match(object? data) => data is ViewModelBase;
public Control? Build(object? data)
{
// Если data - null или каким-то образом не ViewModelBase, то возвращаем null
if (data is not ViewModelBase viewModel)
return null;
// Получаем имя класса ViewModel с полным описанием сборки
var viewModelName = viewModel.GetType().AssemblyQualifiedName;
if (viewModelName is null)
{
_logger.LogError("Не удалось получить имя класса ViewModel: {}", viewModel);
return new Control();
}
// Заменяем ".ViewModel" на ".View" в имени класса ViewModel и получаем имя класса View и его тип
var viewName = viewModelName.Replace("ViewModel", "View");
var viewType = Type.GetType(viewName);
if (viewType is null)
{
_logger.LogError("Не удалось найти класс View \"{}\"", viewName);
return new Control();
}
// Ищем тип View в сервисах
var view = _services.GetService(viewType);
if (view is null)
{
_logger.LogError("Класс View \"{}\" не зарегистрирован", viewName);
return new Control();
}
if (view is not Control control)
{
_logger.LogError("Класс View \"{}\" не наследован от Control", viewName);
return new Control();
}
control.DataContext = viewModel;
return control;
}
}
+10 -1
View File
@@ -2,8 +2,17 @@
public partial class Program public partial class Program
{ {
// Список аргументов командной строки // Аргументы командной строки
private static List<string> _args = new List<string>(); private static List<string> _args = new List<string>();
public static string[] Args
{
get
{
var copy = new string[_args.Count()];
_args.CopyTo(copy);
return copy;
}
}
/// <summary> /// <summary>
/// Точка входа /// Точка входа
+43
View File
@@ -0,0 +1,43 @@
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using GSS2.UI.Core.Services;
using GSS2.UI.Core.ViewModels;
namespace GSS2.ViewModels;
public partial class MainViewModel : ViewModelBase
{
private Control? _amineContentAnalysisView = null;
private readonly NavigationService _navigationService;
private readonly DependencyInjectionViewLocator _viewLocator;
[ObservableProperty] public partial AmineContentViewModel AmineContentAnalysisViewModel { get; set; }
public MainViewModel(
NavigationService navigationService,
DependencyInjectionViewLocator viewLocator,
AmineContentViewModel amineContentAnalysisViewModel
)
{
_navigationService = navigationService;
_viewLocator = viewLocator;
AmineContentAnalysisViewModel = amineContentAnalysisViewModel;
}
[RelayCommand]
public void NavigateToAmineContentAnalysis()
{
if (_amineContentAnalysisView is null)
_amineContentAnalysisView = _viewLocator.Build(AmineContentAnalysisViewModel);
if (_amineContentAnalysisView is not null)
_navigationService.NavigateTo(_amineContentAnalysisView);
}
[RelayCommand] public void NavigateToColorimetricAnalysis() { }
[RelayCommand] public void NavigateToGranulometricAnalysis() { }
}
+59
View File
@@ -0,0 +1,59 @@
<UserControl
x:Class="GSS2.Views.MainView"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:GSS2.ViewModels"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
x:DataType="vm:MainViewModel">
<UserControl.Resources>
<SolidColorBrush x:Key="MainBackground" Color="#001e46" />
<SolidColorBrush x:Key="ButtonBackground" Color="#0865b2" />
<SolidColorBrush x:Key="ButtonForeground" Color="#ffffff" />
<x:Double x:Key="ButtonFontSize">20</x:Double>
</UserControl.Resources>
<UserControl.Styles>
<Style Selector="Button">
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="HorizontalAlignment" Value="Stretch" />
<Setter Property="VerticalAlignment" Value="Stretch"/>
<Setter Property="Background" Value="{StaticResource ButtonBackground}" />
<Setter Property="Foreground" Value="{StaticResource ButtonForeground}" />
</Style>
<Style Selector="TextBlock">
<Setter Property="TextWrapping" Value="Wrap"/>
<Setter Property="TextAlignment" Value="Center"/>
<Setter Property="FontSize" Value="{StaticResource ButtonFontSize}"/>
</Style>
</UserControl.Styles>
<Border Background="{StaticResource MainBackground}" BorderThickness="0">
<Grid ColumnSpacing="5" Margin="5">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<Button Grid.Column="0" Command="{Binding NavigateToAmineContentAnalysisCommand}">
<TextBlock Text="Анализ на кондиционирующие добавки"/>
</Button>
<Button Grid.Column="1" Command="{Binding NavigateToColorimetricAnalysisCommand}" IsEnabled="False">
<TextBlock Text="Анализ цветности"/>
</Button>
<Button Grid.Column="2" Command="{Binding NavigateToGranulometricAnalysisCommand}" IsEnabled="False">
<TextBlock Text="Анализ гранулометрического состава"/>
</Button>
</Grid>
</Border>
</UserControl>
+11
View File
@@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace GSS2.Views;
public partial class MainView : UserControl
{
public MainView()
{
InitializeComponent();
}
}