diff --git a/GSS2/App.axaml b/GSS2/App.axaml
new file mode 100644
index 0000000..6730e20
--- /dev/null
+++ b/GSS2/App.axaml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
diff --git a/GSS2/App.axaml.cs b/GSS2/App.axaml.cs
new file mode 100644
index 0000000..1e61cf4
--- /dev/null
+++ b/GSS2/App.axaml.cs
@@ -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 _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();
+ Avalonia.Logging.Logger.Sink = new AvaloniaLogSink(loggerFactory);
+
+ // Создаём DependencyInjectionViewLocator
+ _logger = _services.GetRequiredService>();
+ _viewLocator = _services.GetRequiredService();
+ }
+
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+
+ // Добавляем наш DependencyInjectionViewLocator в DataTemplates приложения
+ DataTemplates.Add(_viewLocator);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ // Получаем lifetime хоста
+ var hostLifetime = _services.GetRequiredService();
+
+ // Регистрируем обработчик начатия 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();
+
+ // Создаём начальный элемент управления и переходим к нему
+ var mainView = new MainView()
+ {
+ DataContext = _services.GetRequiredService()
+ };
+
+ // Создаём главный элемент управления
+ // на основании типа 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();
+ }
+}
diff --git a/GSS2/Commands/UI.cs b/GSS2/Commands/UI.cs
index ef5b0d3..d5b4b94 100644
--- a/GSS2/Commands/UI.cs
+++ b/GSS2/Commands/UI.cs
@@ -1,5 +1,17 @@
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;
public static class UI
@@ -31,7 +43,6 @@ public static class UI
RenderingMode = new("--mode", ["--rendering-mode", "-m"])
{
Description = "Режим рендеринга",
-
DefaultValueFactory = (_) => RenderingModes.X11
};
HideConsole = new("--hide-console", ["--no-console", "-c"])
@@ -53,7 +64,6 @@ public static class UI
Command.Add(RenderingMode);
Command.Add(HideConsole);
Command.SetAction(CommandAction);
-
}
///
@@ -69,6 +79,122 @@ public static class UI
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();
+
+ // Добавляем сервисы баз данных
+ 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();
+
+ // Применяем миграции баз данных если их схема данных изменялась
+ using (var scope = host.Services.CreateScope())
+ {
+ var context = scope.ServiceProvider.GetRequiredService();
+ context.Database.Migrate();
+ }
+ using (var scope = host.Services.CreateScope())
+ {
+ var context = scope.ServiceProvider.GetRequiredService();
+ 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
+ });
}
diff --git a/GSS2/DependencyInjectionHelper.cs b/GSS2/DependencyInjectionHelper.cs
new file mode 100644
index 0000000..8354676
--- /dev/null
+++ b/GSS2/DependencyInjectionHelper.cs
@@ -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((services) =>
+ new DependencyInjectionViewLocator(
+ services.GetRequiredService>(),
+ services
+ )
+ )
+ .AddSingleton((_) => new NavigationService(Application.Current?.ApplicationLifetime))
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton()
+ .AddSingleton();
+}
diff --git a/GSS2/DependencyInjectionViewLocator.cs b/GSS2/DependencyInjectionViewLocator.cs
new file mode 100644
index 0000000..d2dc3a9
--- /dev/null
+++ b/GSS2/DependencyInjectionViewLocator.cs
@@ -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;
+
+///
+/// - интерфейс между Avalonia и механизмом DI (Dependency Injection)
+/// из Microsoft.Extensions.Hosting.
+/// Создаёт View для переданной ViewModel.
+/// Обязательным условием его работы является, то что:
+///
+/// - Класс View находится в пространстве имён SomeNameSpace.Views и имеет имя SomeClassView
+/// - Класс ViewModel находится в пространстве имён SomeNameSpace.ViewModels и имеет имя SomeClassViewModel
+/// - Класс ViewModel наследован от
+/// - Класс View наследован от
+///
+///
+public class DependencyInjectionViewLocator : IDataTemplate
+{
+ private readonly ILogger _logger;
+ private readonly IServiceProvider _services;
+
+ public DependencyInjectionViewLocator(ILogger 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;
+ }
+}
diff --git a/GSS2/Program.cs b/GSS2/Program.cs
index 41300a0..c8795a0 100644
--- a/GSS2/Program.cs
+++ b/GSS2/Program.cs
@@ -2,8 +2,17 @@
public partial class Program
{
- // Список аргументов командной строки
+ // Аргументы командной строки
private static List _args = new List();
+ public static string[] Args
+ {
+ get
+ {
+ var copy = new string[_args.Count()];
+ _args.CopyTo(copy);
+ return copy;
+ }
+ }
///
/// Точка входа
diff --git a/GSS2/ViewModels/MainViewModel.cs b/GSS2/ViewModels/MainViewModel.cs
new file mode 100644
index 0000000..655b303
--- /dev/null
+++ b/GSS2/ViewModels/MainViewModel.cs
@@ -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() { }
+}
diff --git a/GSS2/Views/MainView.axaml b/GSS2/Views/MainView.axaml
new file mode 100644
index 0000000..1d0c24d
--- /dev/null
+++ b/GSS2/Views/MainView.axaml
@@ -0,0 +1,59 @@
+
+
+
+
+
+
+ 20
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GSS2/Views/MainView.axaml.cs b/GSS2/Views/MainView.axaml.cs
new file mode 100644
index 0000000..2b81cbf
--- /dev/null
+++ b/GSS2/Views/MainView.axaml.cs
@@ -0,0 +1,11 @@
+using Avalonia.Controls;
+
+namespace GSS2.Views;
+
+public partial class MainView : UserControl
+{
+ public MainView()
+ {
+ InitializeComponent();
+ }
+}