Lab 16
Console UI
Spectre.Console: таблиці, панелі, дерево, меню
Лабораторна робота №16. Console UI — Spectre.Console
Мета
Навчитися підключати сторонні NuGet-пакети і відокремлювати UI-логіку від бізнес-логіки. Замінити ручне малювання текстового інтерфейсу на готову бібліотеку Spectre.Console: інтерактивні меню, таблиці, панелі, дерева.
Гілка
feature/console-uiЯка проблема зараз
Відкрийте src/Program.cs. Знайдіть будь-яку функцію меню — наприклад PatientsMenu. Порахуйте скільки разів там є Console.WriteLine. Кожен рядок меню виводиться вручну. Кожен список пацієнтів — це for-цикл із Console.WriteLine.
Дві проблеми:
Дублювання. Логіка показу списку пацієнтів є в
PatientsMenu, вWaitingRoomMenu, вAppointmentsMenu— і скрізь однаково. Якщо треба додати нову колонку — шукаємо по всьому файлу.Змішування.
Program.csодночасно: малює UI, читає ввід, викликає бізнес-методи. Це три різні обов'язки в одному місці. Будь-яка зміна у відображенні зачіпає логіку.
Рішення: виділити UI в окремий клас. Program.cs вирішує що показати — ClinicRenderer вирішує як.
Що таке сторонній пакет
До цього лаби ви використовували тільки те що вбудовано в .NET: Console, File, List<T>, LINQ. Але .NET дозволяє підключати бібліотеки від інших розробників через NuGet — менеджер пакетів .NET.
Spectre.Console — бібліотека для красивого консольного інтерфейсу: кольори через ANSI-коди, таблиці з рамками, інтерактивні меню зі стрілками, прогрес-бари, ієрархічні дерева.
Встановлення — одна команда:
dotnet add package Spectre.ConsoleПісля цього у .csproj з'являється рядок <PackageReference>. NuGet завантажує бібліотеку і C# компілятор "бачить" її класи так само як вбудовані.
Ключова ідея: фасад над бібліотекою
Можна одразу писати AnsiConsole.Write(...) в Program.cs. Але тоді Program.cs стане залежним від Spectre.Console напряму — завтра захочете замінити бібліотеку і доведеться переписувати все.
Правильний підхід: створити ClinicRenderer — статичний клас-фасад між Program.cs і Spectre.Console.
Program.cs
↓ викликає
ClinicRenderer
↓ використовує
Spectre.ConsoleProgram.cs не знає що всередині використовується Spectre.Console. Він просто просить: "покажи пацієнтів". А як саме — справа рендерера.
Це той самий принцип єдиної відповідальності (SRP) якого ми дотримуємось починаючи з Lab 03: кожен клас відповідає за одне.
Завдання 1. Встановити Spectre.Console та базові утиліти ⭐⭐
Що вводить Spectre.Console для простого тексту
Console.WriteLine("Помилка: ...") — текст без стилю.
AnsiConsole.MarkupLine("[red]✗ Помилка[/]") — той самий текст, але Spectre.Console розбирає [red]...[/] як розмітку і виводить червоним. Це схоже на HTML-теги але для консолі.
Markup.Escape(text) — обов'язковий захист: якщо в text є символи [ або ] — вони стануть розміткою і зламають відображення. Markup.Escape перетворює їх у безпечні послідовності.
new Rule("Заголовок") — горизонтальна лінія з текстом посередині або ліворуч. Замінює рядки "──── Пацієнти ────".
Де створити клас
Створіть папку src/UI/ і в ній файл src/UI/ClinicRenderer.cs.
Простір імен — ClinicApp.UI. Клас — public static class ClinicRenderer.
Що реалізувати
П'ять базових методів:
| Метод | Що робить |
|---|---|
PrintHeader(string title) |
Горизонтальна лінія Rule із назвою розділу, ліворуч |
PrintSuccess(string message) |
Зелений текст із префіксом ✓ |
PrintError(string message) |
Червоний текст із префіксом ✗ |
PrintWarning(string message) |
Жовтий текст із префіксом ⚠ |
PrintInfo(string message) |
Приглушений ([dim]) сірий текст |
Для PrintHeader підказка:
AnsiConsole.Write(new Rule($"[bold steelblue1]{Markup.Escape(title)}[/]")
{
Justification = Justify.Left
});Де застосувати
Знайдіть у Program.cs всі Console.WriteLine("── Назва ──") — замініть на ClinicRenderer.PrintHeader(...).
Знайдіть всі Console.WriteLine("Помилка: ...") у catch-блоках — замініть на ClinicRenderer.PrintError(...).
Знайдіть повідомлення про успіх — замініть на ClinicRenderer.PrintSuccess(...).
Ключові питання
- Чому використовується
Markup.Escape(text)перед вставкою тексту в розмітку? - Що станеться якщо прізвище пацієнта містить
[— наприклад"Іван [молодший]"? - Чим відрізняється
AnsiConsole.MarkupLine(...)відAnsiConsole.WriteLine(...)?
Завдання 2. Інтерактивні меню та введення даних ⭐⭐
Проблема поточного введення
Зараз кожне меню виглядає так:
1. Показати всіх
2. Додати пацієнта
0. Назад
Оберіть:Користувач бачить числа і вводить 1. Якщо набрав 11 — default-гілка.
SelectionPrompt змінює парадигму: меню відображається списком, користувач рухається стрілками і вибирає Enter. Жодного введення чисел — жодних невалідних команд.
SelectionPrompt — як влаштований
string choice = AnsiConsole.Prompt(
new SelectionPrompt<string>()
.Title("Оберіть дію")
.AddChoices("Показати всіх", "Додати", "← Назад"));Повертає рядок — той самий що був у .AddChoices(...). switch (choice) тепер перемикається по рядку, а не по числу.
Що реалізувати в ClinicRenderer
public static string SelectMenu(string title, string[] options)Всередині: SelectionPrompt<string> з переданим заголовком і варіантами.
Підказка щодо стилю виділення:
.HighlightStyle(new Style(Color.SteelBlue1, decoration: Decoration.Bold))TextPrompt — типізоване введення
TextPrompt<int> робить дві речі за вас:
- Виводить підказку
- Якщо введено не число — автоматично просить ввести знову з повідомленням про помилку
int id = AnsiConsole.Prompt(
new TextPrompt<int>("ID пацієнта:")
.ValidationErrorMessage("[red]Введіть ціле число[/]"));TextPrompt<string> для необов'язкового тексту потребує .AllowEmpty() (без параметрів).
ConfirmationPrompt — для запитань yes/no:
bool confirmed = AnsiConsole.Prompt(new ConfirmationPrompt("Зберегти?"));Що реалізувати в ClinicRenderer
| Метод | Що повертає |
|---|---|
PromptInt(string label) |
int — з авто-повтором при невалідному вводі |
PromptString(string label, bool allowEmpty = false) |
string |
PromptDecimal(string label) |
decimal |
PromptConfirm(string question) |
bool — через ConfirmationPrompt |
Де застосувати в Program.cs
Замініть усі патерни Console.Write("..."); int.TryParse(Console.ReadLine(), out int x) на int x = ClinicRenderer.PromptInt("...").
Замініть Console.Write("y/n") → ClinicRenderer.PromptConfirm(...).
Замініть числові меню на ClinicRenderer.SelectMenu(...). Для "назад" використовуйте опцію "← Назад" і перевіряйте if (cmd == "← Назад") { inMenu = false; break; }.
Ключові питання
- Чому
TextPrompt<int>краще заint.TryParse(Console.ReadLine())? SelectionPromptповертає рядок — томуswitchтепер порівнює рядки замість чисел. Що в цьому є перевагою, а що недоліком?"← Назад"як рядкова константа повторюється у кожному меню. Як це можна покращити?
Завдання 3. Таблиці для списків ⭐⭐
Проблема поточного відображення списків
for (int i = 0; i < patients.Length; i++)
Console.WriteLine(patients[i]);Виводить рядки один під одним без вирівнювання. Якщо ім'я коротке — колонки "розповзаються". Немає заголовків колонок. Немає підрахунку.
Що таке Table у Spectre.Console
Table сам обчислює ширину кожної колонки за найдовшим вмістом і малює рамку:
┌────┬──────────────────┬─────┐
│ ID │ Ім'я │ Вік │
├────┼──────────────────┼─────┤
│ 1 │ Іван Петренко │ 41 │
│ 2 │ Олена Коваль │ 33 │
└────┴──────────────────┴─────┘Базова структура:
var table = new Table()
.Border(TableBorder.Rounded)
.AddColumn("[bold]ID[/]")
.AddColumn("[bold]Ім'я[/]");
table.AddRow("1", "Іван Петренко");
// ...
AnsiConsole.Write(table);Для вирівнювання колонки по центру або правому краю:
.AddColumn(new TableColumn("[bold]ID[/]").Centered())
.AddColumn(new TableColumn("[bold]Вартість[/]").RightAligned())Увага: у AddRow кожна комірка — це рядок, але він може містити розмітку. Для значень що прийшли від користувача — обгортайте Markup.Escape(...).
Що реалізувати
Три методи в ClinicRenderer:
RenderPatients(IEnumerable<Patient> patients) — колонки: ID, Ім'я, Вік, Група крові, Телефон. Неповнолітні: вік виводити [yellow]жовтим[/].
RenderDoctors(IEnumerable<Doctor> doctors) — колонки: ID, Ім'я, Спеціальність, Доступний, Розклад. Доступний зараз: [green]Так[/], недоступний: [dim]Ні[/].
RenderAppointments(IEnumerable<Appointment> appointments) — колонки: ID, Тип, Пацієнт, Лікар, Дата/час, Вартість, Статус. Логіка кольорів:
| Стан | Колір |
|---|---|
| Скасовано | [red] |
| Оплачено | [green] |
| Прострочено | [dim] |
| Заплановано | [yellow] |
| Тип Urgent | [bold red] |
Для перевірки типу запису: a switch { UrgentAppointment => "...", SpecialistAppointment => "...", _ => "..." }.
Де застосувати в Program.cs
Замініть всі виклики clinic.Patients.DisplayAll() → ClinicRenderer.RenderPatients(clinic.Patients.GetAll()).
Аналогічно для лікарів і записів.
Ключові питання
DisplayAll()у менеджерах також виводить список. Тепер його роль виконує рендерер. Чи варто залишитиDisplayAll()у менеджерах?TableотримуєIEnumerable<Patient>, а неPatient[]. Яка перевага від прийомуIEnumerable<T>замість масиву?- Що трапиться якщо ім'я пацієнта містить символ
]?
Завдання 4. Картки деталей і ієрархія ⭐⭐⭐
Panel — рамка навколо тексту
Таблиця добре показує список. Але для детальної картки однієї сутності — краще Panel:
╭─── Пацієнт: Іван Петренко ──────────╮
│ ID: 1 │
│ Дата народження: 15.03.1985 │
│ Вік: 41 рр. (Повнолітній) │
│ Група крові: APositive │
╰──────────────────────────────────────╯Вміст — це рядок з розміткою. Заголовок — PanelHeader.
Підказка до структури:
var panel = new Panel(content)
{
Header = new PanelHeader("[bold] Заголовок [/]"),
Border = BoxBorder.Rounded,
Padding = new Padding(1, 0)
};
AnsiConsole.Write(panel);Tree — ієрархічне відображення
Tree ідеально підходить для медичної картки де є три типи записів:
Медична картка: Іван Петренко
├── Діагнози (2)
│ ├── I10 — Гіпертонічна хвороба ⟳ хронічне
│ └── J06.9 — Гострий ринофарингіт
├── Аналізи (2)
│ ├── ✓ Гемоглобін: 145 г/л (норма: 120–160)
│ └── ⚠ Холестерин: 6.2 ммоль/л (норма: < 5.2)
└── Рецепти (1)
└── Лізиноприл 10 мг × 30 днів [активний]Кожна гілка — root.AddNode(...), кожен елемент у гілці — branch.AddNode(...).
Щоб розділити записи на типи: .OfType<Diagnosis>(), .OfType<LabResult>(), .OfType<Prescription>() — це LINQ-методи що ви вже знаєте.
BarChart — для звітів
var chart = new BarChart()
.Width(60)
.Label("[bold]Виручка по місяцях[/]")
.CenterLabel();
chart.AddItem("2026/05", 1350.0, Color.SteelBlue1);Корисно для методу GetMonthlyRevenue() з Lab 14.
Що реалізувати
RenderPatientCard(Patient patient) — Panel з полями: ID, дата народження, вік (з кольоровою міткою повнолітній/ні), група крові, телефон.
RenderDoctorCard(Doctor doctor) — Panel з полями: ID, спеціальність, ліцензія, телефон, розклад, статус доступності.
RenderMedicalRecord(Patient patient, IEnumerable<MedicalRecord> records) — Tree з трьома гілками: Діагнози, Аналізи, Рецепти. Якщо гілка порожня — показує [dim]немає[/].
RenderSpecialityStats(IEnumerable<SpecialityReport> reports) — Table.
RenderMonthlyRevenue(IEnumerable<(int Year, int Month, decimal Total)> data) — BarChart.
WithSpinner(string message, Action action) — загортає будь-яку операцію у спіннер:
AnsiConsole.Status()
.Spinner(Spinner.Known.Dots)
.Start(message, _ => action());Де застосувати
У MedicalRecordsMenu, пункт "Картка пацієнта": знайти пацієнта → ClinicRenderer.RenderMedicalRecord(patient, records).
У WaitingRoomMenu, пункт "Хто перший?": ClinicRenderer.RenderPatientCard(first) замість Console.WriteLine(first).
У FilesMenu, "Експортувати всі звіти": обгорніть виклик у ClinicRenderer.WithSpinner("Експортую...", () => { ... }).
У ReportsMenu: RenderSpecialityStats для спеціальностей, RenderMonthlyRevenue для місячної виручки.
Ключові питання
- Чому
Panelкраще за звичайнийConsole.WriteLineдля деталей однієї сутності? TreeотримуєIEnumerable<MedicalRecord>і сам розкладає по типах через.OfType<T>(). Де ще в проєкті ми бачили такий підхід?WithSpinnerприймаєAction— тобто будь-яку дію. Це той самий механізм що ви вивчали в Lab 15. Як це пов'язано зAction<T>?
Перевірка
dotnet run --project srcПісля запуску:
- Головне меню — стрілки замість цифр
- Пункт "Пацієнти → Показати всіх" — таблиця з рамкою і кольоровим віком
- Пункт "Записи → Майбутні" — таблиця з кольоровими статусами
- Пункт "Медична картка → Картка пацієнта (Tree)" — ієрархічне дерево
- Пункт "Черга → Хто перший?" — Panel із деталями пацієнта
- Пункт "Файли → Експорт" — видно спіннер під час операції
- Пункт "Звіти → Виручка по місяцях" — стовпчаста діаграма
- Помилки — червоним, успіх — зеленим, заголовки — горизонтальна лінія
Питання для самоперевірки
- Що таке NuGet-пакет? Чим він відрізняється від стандартних бібліотек .NET?
- Навіщо
ClinicRendererякщо можна писатиAnsiConsole.Write(...)безпосередньо вProgram.cs? Markup.Escape(text)— обов'язковий для всіх рядків від користувача. Чому? Що це за атака?SelectionPrompt<string>повертає рядок зAddChoices. Томуswitchпорівнює рядки, а не числа. Що відбудеться якщо опціонально змінити текст одного пункту — скільки місць у коді треба оновити?- Чим
TextPrompt<int>кращий заint.TryParse(Console.ReadLine())? Що ще він дає? WithSpinnerприймаєAction. Як це пов'язано з патерном з Lab 15?