using System.Text; using Avalonia; using Avalonia.Controls; using Avalonia.Headless; using Avalonia.Media.Imaging; using Avalonia.Threading; using foodmarket.Pos.ViewModels; using foodmarket.Pos.Views; namespace foodmarket.Pos.Tooling; /// Headless-рендер экранов кассы в PNG (без дисплея, Skia). По порядку /// сценария, в 1920×1080 и 1366×768 → docs/pos-screenshots/ + index.html. /// Запуск: dotnet run -- --screenshots [outputDir] public static class ScreenshotRunner { private sealed record Shot(string Name, string Caption, Func Build); public static int Run(string[] args) { var outDir = args.SkipWhile(a => a != "--screenshots").Skip(1).FirstOrDefault(a => !a.StartsWith("--")) ?? Path.Combine(FindRepoRoot(), "docs", "pos-screenshots"); Directory.CreateDirectory(outDir); AppBuilder.Configure() .UseSkia() .UseHeadless(new AvaloniaHeadlessPlatformOptions { UseHeadlessDrawing = false }) .WithInterFont() .SetupWithoutStarting(); var shots = new List { new("01-login", "Вход кассира (адрес сервера, логин, пароль)", () => new LoginView { DataContext = LoginViewModel.Sample() }), new("01b-login-pin", "Быстрый вход по PIN (зарегистрированное устройство)", () => new LoginView { DataContext = LoginViewModel.SamplePin() }), new("02-shift-open", "Смена: Z-отчёт/состояние смены", () => new ShiftView { DataContext = ShiftViewModel.Sample() }), new("03-sale-empty", "Продажа: пустой чек, ожидание сканирования", () => new SaleView { DataContext = new SaleViewModel() }), new("04-sale-items", "Продажа: чек с товарами (вкл. весовой)", () => new SaleView { DataContext = SaleViewModel.Sample() }), new("05-payment-cash", "Оплата наличными со сдачей", () => new PaymentView { DataContext = PaymentViewModel.SampleCash() }), new("06-payment-mixed", "Оплата смешанная (нал+карта)", () => new PaymentView { DataContext = PaymentViewModel.SampleMixed() }), new("07-return", "Возврат по чеку (выбор позиций)", () => new ReturnView { DataContext = ReturnViewModel.Sample() }), new("08-shift-z", "Z-отчёт при закрытии (должно/факт/разница)", () => new ShiftView { DataContext = ShiftViewModel.SampleClosed() }), new("09-shell-offline", "Статус-бар: нет связи, чеки в очереди", () => ShellOffline()), new("10-shell-online", "Статус-бар: на связи, очередь пуста", () => ShellOnline()), }; var sizes = new (string suffix, int w, int h)[] { ("1920x1080", 1920, 1080), ("1366x768", 1366, 768) }; var produced = new List<(string file, string caption)>(); foreach (var shot in shots) foreach (var (suffix, w, h) in sizes) { var control = shot.Build(); // Если это уже окно (оболочка) — рендерим его напрямую; иначе кладём в окно. var window = control as Window ?? new Window { Content = control, SystemDecorations = SystemDecorations.None }; window.Width = w; window.Height = h; window.Show(); Dispatcher.UIThread.RunJobs(); // дать разметке/рендеру отработать for (int i = 0; i < 3; i++) { Dispatcher.UIThread.RunJobs(); } var frame = window.CaptureRenderedFrame(); var file = $"{shot.Name}-{suffix}.png"; frame?.Save(Path.Combine(outDir, file)); produced.Add((file, $"{shot.Caption} ({suffix})")); window.Close(); Console.WriteLine($"[shot] {file}"); } WriteGallery(outDir, produced); Console.WriteLine($"[done] {produced.Count} кадров → {outDir}"); return 0; } private static Control ShellOffline() { var vm = new MainWindowViewModel { IsOnline = false, QueueCount = 3, CashierName = "Пётр Кассиров", ShiftNumber = "С-2026-000001" }; return new MainWindow { DataContext = vm }; } private static Control ShellOnline() { var vm = new MainWindowViewModel { IsOnline = true, QueueCount = 0, CashierName = "Пётр Кассиров", ShiftNumber = "С-2026-000001" }; return new MainWindow { DataContext = vm }; } private static void WriteGallery(string dir, List<(string file, string caption)> shots) { var sb = new StringBuilder(); sb.Append("Касса food-market — скриншоты"); sb.Append(""); sb.Append($"

Касса food-market — скриншоты процессов

рендер headless (Avalonia/Skia, Linux) — 1920×1080 и 1366×768. Кадров: {shots.Count}
"); foreach (var (file, caption) in shots) sb.Append($"
{System.Net.WebUtility.HtmlEncode(caption)}
"); sb.Append("
"); // Раздел «Windows 11» — реальные скриншоты окна на Windows (если есть). var winDir = Path.Combine(dir, "windows"); if (Directory.Exists(winDir)) { var wins = Directory.GetFiles(winDir, "*.png").OrderBy(f => f).ToList(); if (wins.Count > 0) { sb.Append("

Windows 11 — реальное окно

" + "
Снято на Windows 11 (VM, " + "software-рендер — у VM нет GPU; консоль 1280×800). Подтверждает нативный " + "рендер: кириллица, формат чисел «3 362 ₸», кнопки без обрезки. Высокое " + "разрешение 1366/1920 — в headless-кадрах выше (тот же движок Avalonia/Skia/Inter).
"); sb.Append("
"); foreach (var w in wins) { var rel = "windows/" + Path.GetFileName(w); sb.Append($"
Windows 11 — экран продажи (реальное окно)
"); } sb.Append("
"); } } sb.Append(""); File.WriteAllText(Path.Combine(dir, "index.html"), sb.ToString()); } private static string FindRepoRoot() { var d = new DirectoryInfo(AppContext.BaseDirectory); while (d is not null && !File.Exists(Path.Combine(d.FullName, "food-market.sln"))) d = d.Parent; return d?.FullName ?? Directory.GetCurrentDirectory(); } }