OOP Course
Сьогодні

Lab 16

Console UI

Spectre.Console: таблиці, панелі, дерево, меню

Лаба 16 — Console UI (Spectre.Console)

Мета

Навчитися підключати сторонні NuGet-пакети і відокремлювати UI-логіку від бізнес-логіки. Замінити ручне малювання текстового інтерфейсу на готову бібліотеку Spectre.Console: інтерактивні меню, таблиці, панелі, дерева.

Контекст

Відкрийте ClinicApp/Program.cs і знайдіть будь-яку функцію меню — наприклад, PatientsMenu. Порахуйте, скільки там Console.WriteLine: кожен рядок меню виводиться вручну, кожен список — це цикл із Console.WriteLine. Звідси дві проблеми:

  1. Дублювання. Показ списку пацієнтів є в кількох меню — і скрізь однаковий. Нова колонка — шукаємо по всьому файлу.
  2. Змішування. Program.cs одночасно малює UI, читає ввід і викликає бізнес-методи. Будь-яка зміна у відображенні зачіпає логіку.

Рішення: виділити UI в окремий клас. Program.cs вирішує, що показати, а ClinicRenderer — як.

Структура проєкту на початку лаби

Це результат Лаби 15 — стан main після її злиття:

oop-course/                           ← гілка main (після злиття Лаби 15)
├── .gitignore
├── oop-course.slnx
└── ClinicApp/
    ├── ClinicApp.csproj
    ├── Program.cs
    ├── Clinic.cs
    ├── Enums/  (4 файли)
    ├── Models/  (16 файлів)
    ├── Managers/  (13 файлів)
    ├── Utils/  (12 файлів)
    ├── Interfaces/  (4 файли)
    ├── Comparators/  (4 файли)
    ├── Attributes/  (3 файли)
    ├── Events/  (4 файли)
    └── Extensions/  (3 файли)

Структуру наприкінці лаби (з позначками, що створюється і змінюється в кожній задачі) наведено в розділі «Структура проєкту наприкінці лаби» перед перевіркою.

Сторонній пакет і фасад над ним

Досі ви використовували тільки вбудоване в .NET: Console, File, List<T>, LINQ. NuGet — менеджер пакетів .NET — дозволяє підключати бібліотеки інших розробників. Spectre.Console — бібліотека для консольного інтерфейсу: кольори, таблиці з рамками, меню зі стрілками, дерева, діаграми. Після dotnet add package у .csproj з'являється рядок <PackageReference>, і компілятор «бачить» класи бібліотеки так само, як вбудовані.

Можна одразу писати AnsiConsole.Write(...) у Program.cs, але тоді Program.cs залежить від Spectre.Console напряму — заміна бібліотеки означатиме переписати все. Правильно — статичний клас-фасад ClinicRenderer між ними:

Program.cs  →  ClinicRenderer  →  Spectre.Console

Program.cs просить «покажи пацієнтів», а як саме — справа рендерера. Це той самий принцип єдиної відповідальності, якого ми дотримуємось з Лаби 03.

Що нового дозволено (і тільки воно)

  • NuGet-пакет Spectre.Console і його класи (AnsiConsole, Markup, Rule, SelectionPrompt, TextPrompt, ConfirmationPrompt, Table, Panel, Tree, BarChart, Status);
  • switch за рядком вибраного пункту меню.

Крок 1. Гілка

Робочий процес (повністю — Git Воркшоп): лаба = гілка Lab-XX від main, коміт на кожне завдання (LabXX TaskYY), у кінці — злиття в main.

Проєкт ClinicApp/ уже існує. Тут лише нова гілка від main:

git checkout main
git checkout -b Lab-16

Коміт — на кожне завдання (Lab16 TaskNN).

Ваш домен

За замовчуванням виконуйте завдання як написано (домен «клініка»). Для власного домену дивіться таблицю «Адаптація до вашого домену» в кінці кожного завдання.

Як користуватися підказками

Підказки — напрям думки, не готовий код. «Що реалізувати» і «Специфікація» кажуть що; підказки — як міркувати; блок 📖 Документація — де прочитати синтаксис. Спершу документація і власна спроба.


Задача 1. Підключити Spectre.Console і базові повідомлення ⭐⭐

Умова

Підключіть бібліотеку і створіть рендерер із базовими методами виводу. Замініть ними заголовки розділів, повідомлення про помилки й успіх у Program.cs.

Що реалізувати:

  1. Додати пакет у проєкт: dotnet add ClinicApp package Spectre.Console.
  2. Створити теку ClinicApp/UI/ і статичний клас ClinicRenderer (простір імен ClinicApp.UI) з п'ятьма методами зі специфікації.
  3. У Program.cs замінити рядки-заголовки "── Назва ──" на PrintHeader, повідомлення в catch — на PrintError, повідомлення про успіх — на PrintSuccess.

Специфікація

Метод Що робить
PrintHeader(string title) горизонтальна лінія Rule з назвою розділу, вирівняною ліворуч
PrintSuccess(string message) зелений текст із префіксом ✓
PrintError(string message) червоний текст із префіксом ✗
PrintWarning(string message) жовтий текст із префіксом ⚠
PrintInfo(string message) приглушений ([dim]) текст

Приклад

AnsiConsole.MarkupLine("[red]✗ Помилка[/]");   // розмітка [колір]…[/] — як теги в HTML
── Пацієнти ────────────────────────────────
✓ Пацієнта [6] Марія Ткач додано.
✗ Телефон має містити рівно 10 цифр.

Підказки

  1. AnsiConsole.MarkupLine розбирає [red]…[/] як розмітку; звичайний WriteLine — ні.
  2. Markup.Escape(text) — обов'язковий для будь-якого тексту з даних: якщо в імені є [ або ], вони стануть розміткою і зламають вивід (або програму). Екрануйте текст перед вставкою в розмітку.
  3. Rule має властивість вирівнювання заголовка — подивіться в документації.

📖 Документація:

Адаптація до вашого домену

Клініка Готель Ресторан Університет Прокат авто Бібліотека Спортзал
ClinicRenderer HotelRenderer RestaurantRenderer UniversityRenderer RentalRenderer LibraryRenderer GymRenderer

Коміт

git add ClinicApp/ClinicApp.csproj ClinicApp/UI/ClinicRenderer.cs ClinicApp/Program.cs
git commit -m "Lab16 Task01"

Задача 2. Меню зі стрілками і типізоване введення ⭐⭐

Умова

Зараз користувач бачить цифри і вводить 1; набрав 11 — потрапив у default. Замініть числові меню на вибір стрілками, а ручний розбір введення — на типізовані запити, що самі повторюють запит при помилці.

Що реалізувати:

  1. У ClinicRenderer додати методи зі специфікації.
  2. У Program.cs замінити числові меню на SelectMenu; кожне підменю має пункт "← Назад".
  3. Замінити Console.Write(...) + int.TryParse(Console.ReadLine(), …) на PromptInt (і аналоги для рядків і чисел).
  4. Замінити запити y/n на PromptConfirm.

Специфікація

Метод Повертає На основі
SelectMenu(string title, string[] options) вибраний рядок SelectionPrompt<string>
PromptInt(string label) int, повторює запит при невалідному вводі TextPrompt<int>
PromptString(string label, bool allowEmpty = false) string TextPrompt<string>
PromptDecimal(string label) decimal TextPrompt<decimal>
PromptConfirm(string question) bool ConfirmationPrompt

Приклад

Пацієнти
> Показати всіх
  Додати пацієнта
  Знайти за ім'ям
  ← Назад

Підказки

  1. SelectionPrompt повертає той самий рядок, що був у варіантах, — тому switch тепер перемикається за рядком.
  2. Колір виділеного пункту задає HighlightStyle.
  3. TextPrompt<int> сам показує повідомлення про помилку і повторює запит — текст повідомлення задає ValidationErrorMessage.
  4. Необов'язковий рядок: TextPrompt<string> з AllowEmpty().
  5. "← Назад" повторюється в кожному меню — винесіть його в константу.

📖 Документація:

Адаптація до вашого домену

Клініка Готель Ресторан Університет Прокат авто Бібліотека Спортзал
усі меню клініки усі меню готелю усі меню ресторану усі меню університету усі меню прокату усі меню бібліотеки усі меню спортзалу

Коміт

git add ClinicApp/UI/ClinicRenderer.cs ClinicApp/Program.cs
git commit -m "Lab16 Task02"

Задача 3. Таблиці для списків ⭐⭐

Умова

Списки виводяться рядками без вирівнювання: колонки «розповзаються», немає заголовків. Замініть їх таблицями, що самі вирівнюють колонки і малюють рамку.

Що реалізувати:

  1. У ClinicRenderer додати три методи зі специфікації.
  2. У Program.cs замінити clinic.Patients.DisplayAll() на ClinicRenderer.RenderPatients(clinic.Patients.GetAll()); так само для лікарів і записів.

Специфікація

Метод Колонки Кольори
RenderPatients(IEnumerable<Patient>) ID, Ім'я, Вік, Група крові, Телефон вік неповнолітнього — жовтим
RenderDoctors(IEnumerable<Doctor>) ID, Ім'я, Спеціальність, Доступний, Розклад доступний — зелене «Так», ні — приглушене «Ні»
RenderAppointments(IEnumerable<Appointment>) ID, Тип, Пацієнт, Лікар, Дата/час, Вартість, Статус скасовано — червоним; оплачено — зеленим; прострочено — приглушено; заплановано — жовтим; тип «терміновий» — жирним червоним

Приклад

╭────┬───────────────┬─────┬───────┬────────────────╮
│ ID │ Ім'я          │ Вік │ Кров  │ Телефон        │
├────┼───────────────┼─────┼───────┼────────────────┤
│  1 │ Іван Петренко │  41 │ A+    │ (050) 123-4567 │
│  3 │ Максим Бойко  │  16 │ O+    │ (093) 345-6789 │
╰────┴───────────────┴─────┴───────┴────────────────╯

Підказки

  1. Table сам обчислює ширину колонок; рамка — Border(TableBorder.Rounded); вирівнювання колонки — TableColumn з Centered() / RightAligned().
  2. Кожна комірка AddRow — рядок розмітки: значення з даних екрануйте Markup.Escape.
  3. Тип запису — switch-вираз за підкласом (UrgentAppointment => …).
  4. Імена пацієнта й лікаря для записів беріть через менеджери, як у DisplayAppointment.

📖 Документація:

Адаптація до вашого домену

Клініка Готель Ресторан Університет Прокат авто Бібліотека Спортзал
RenderPatients / RenderDoctors / RenderAppointments RenderGuests / RenderStaff / RenderBookings RenderCustomers / RenderWaiters / RenderReservations RenderStudents / RenderLecturers / RenderEnrollments RenderClients / RenderManagers / RenderRentals RenderReaders / RenderLibrarians / RenderLoans RenderMembers / RenderTrainers / RenderSessions

Коміт

git add ClinicApp/UI/ClinicRenderer.cs ClinicApp/Program.cs
git commit -m "Lab16 Task03"

Задача 4. Картки, дерево, діаграма і спіннер ⭐⭐⭐

Умова

Таблиця добре показує список, але для однієї сутності краще картка в рамці, для медичної картки — дерево за типами записів, для місячної виручки — діаграма. Додайте ці віджети і застосуйте їх у відповідних меню.

Що реалізувати:

  1. У ClinicRenderer додати шість методів зі специфікації.
  2. Застосувати їх у меню (таблиця «Де застосувати» нижче).

Специфікація

Метод Віджет Вміст
RenderPatientCard(Patient) Panel ID, дата народження, вік (з позначкою повнолітній/ні), група крові, телефон
RenderDoctorCard(Doctor) Panel ID, спеціальність, ліцензія, телефон, розклад, доступність
RenderMedicalRecord(Patient, IEnumerable<MedicalRecord>) Tree три гілки: діагнози, аналізи, рецепти; порожня гілка — «немає»
RenderSpecialityStats(IEnumerable<SpecialityReport>) Table звіт по спеціальностях (Лаба 14)
RenderMonthlyRevenue(IEnumerable<(int Year, int Month, decimal Total)>) BarChart виручка по місяцях (Лаба 14)
WithSpinner(string message, Action action) Status показує спіннер, поки виконується дія
Де застосувати Метод
«Медична картка» → «Картка пацієнта» RenderMedicalRecord
«Черга» → «Хто перший?» RenderPatientCard
«Файли» → «Експортувати всі звіти» WithSpinner
«Звіти» → «Спеціальності» / «Виручка по місяцях» RenderSpecialityStats / RenderMonthlyRevenue

Приклад

Медична картка: Іван Петренко
├── Діагнози (2)
│   ├── I10 — Гіпертонічна хвороба ⟳ хронічне
│   └── J06.9 — Гострий ринофарингіт
├── Аналізи (2)
│   ├── ✓ Гемоглобін: 145 г/л (норма: 120–160)
│   └── ⚠ Холестерин: 6.2 ммоль/л (норма: < 5.2)
└── Рецепти (1)
    └── Лізиноприл 10 мг × 30 днів [активний]

Підказки

  1. Panel — рамка навколо тексту з розміткою; заголовок — PanelHeader.
  2. Tree: гілки додаються до кореня, елементи — до гілок (AddNode).
  3. Розкласти записи за типами допоможе OfType<Diagnosis>() тощо (Лаба 14).
  4. BarChart приймає числа double — перетворіть decimal.
  5. WithSpinner приймає Action — будь-яку дію, як у Лабі 15.

📖 Документація:

Адаптація до вашого домену

Клініка Готель Ресторан Університет Прокат авто Бібліотека Спортзал
медична картка (Tree) історія проживань історія замовлень залікова книжка історія оренд формуляр читача щоденник тренувань

Коміт

git add ClinicApp/UI/ClinicRenderer.cs ClinicApp/Program.cs
git commit -m "Lab16 Task04"

Структура проєкту наприкінці лаби

Так має виглядати ClinicApp/, коли всі завдання виконано:

oop-course/                           ← гілка Lab-16 (після злиття — main)
├── .gitignore
├── oop-course.slnx
└── ClinicApp/
    ├── ClinicApp.csproj              ✏ Т1
    ├── Program.cs                    ✏ Т1 Т2 Т3 Т4
    ├── Clinic.cs
    ├── Enums/  (4 файли)
    ├── Models/  (16 файлів)
    ├── Managers/  (13 файлів)
    ├── Utils/  (12 файлів)
    ├── Interfaces/  (4 файли)
    ├── Comparators/  (4 файли)
    ├── Attributes/  (3 файли)
    ├── Events/  (4 файли)
    ├── Extensions/  (3 файли)
    └── UI/
        └── ClinicRenderer.cs         🆕 Т1  ✏ Т2 Т3 Т4

Легенда: 🆕 — новий файл · ✏ — змінено вміст · Тn — номер задачі, у якій ви працюєте з файлом. Файли без позначки лишились такими, як були після Лаби 15.

Назви файлів наведено для домену «клініка»; у власному домені назви ваші — важливо, що саме створюється й змінюється.


Перевірка перед здачею

dotnet build ClinicApp
dotnet run --project ClinicApp

Переконайтесь, що:

  • Структура проєкту збігається зі схемою вище; у ClinicApp.csproj є PackageReference на Spectre.Console
  • Головне меню і підменю — вибір стрілками
  • «Пацієнти» → «Показати всіх» — таблиця з рамкою, вік неповнолітніх жовтим
  • «Записи» — таблиця з кольоровими статусами
  • «Медична картка» → «Картка пацієнта» — дерево
  • «Черга» → «Хто перший?» — картка в рамці
  • «Файли» → «Експорт» — спіннер під час операції
  • «Звіти» → «Виручка по місяцях» — стовпчаста діаграма
  • Пацієнт з ім'ям, що містить [ або ], виводиться без помилок
  • У Program.cs не лишилось прямих викликів AnsiConsole — лише ClinicRenderer

Питання для самоперевірки

  1. Що таке NuGet-пакет? Чим він відрізняється від стандартних бібліотек .NET?
  2. Навіщо ClinicRenderer, якщо можна писати AnsiConsole.Write(...) безпосередньо в Program.cs?
  3. Чому Markup.Escape(text) обов'язковий для даних від користувача? Що станеться з ім'ям "Іван [молодший]" без нього?
  4. SelectionPrompt повертає рядок, тому switch порівнює рядки. Скільки місць у коді треба оновити, якщо змінити текст пункту? Як це спростити?
  5. Чим TextPrompt<int> кращий за int.TryParse(Console.ReadLine())?
  6. Чи варто залишати DisplayAll() у менеджерах, якщо його роль тепер виконує рендерер?

Статус гілки

Після всіх 4 завдань (кожне — окремий коміт Lab16 TaskNN на гілці Lab-16):

git push -u origin Lab-16
git checkout main
git merge --no-ff Lab-16 -m "Merge Lab-16: Console UI"
git push

Наступна лаба: git checkout main → git checkout -b Lab-17.

Розроблено Tomka Yurii · © 2026 ·