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(); + } +}