Stworzyłem bibliotekę rozszerzającą obsługę konsoli w .Net. Zobacz jak to zrobiłem

Jeśli nie programujesz aplikacji konsolowych to taka biblioteka do niczego Ci się nie przyda. Jeśli sporadycznie piszesz niewielkie narzędzia, które wyświetlają coś na ekranie to już możesz rozważyć wykorzystanie wybranych komponentów, by usprawnić tworzenie swojego kawałka oprogramowania i ewentualnie sprawić, że będzie lepiej wyglądał. Największą korzyść odniosą jednak twórcy dwóch rodzajów oprogramowania: gier w trybie tekstowym i zaawansowanych narzędzi, złożonych z licznych, skomplikowanych komend lub prezentujących większe ilości danych, np. w formie tabelarycznej.

Sebastian Gruchacz. Programista .Net / C# od 15 lat. Jako członek zespołów wewnętrznych i jako konsultant w ciągu ponad 17 lat pracy zawodowej zajmował się rozmaitymi aspektami wytwarzania oprogramowania (od frontendu po asembler), dev-opsem, lokalizacją i testami automatycznymi, analizą i architekturą, szkoleniami i prowadzeniem zespołów. Aktualnie, przez większość czasu, jako Starszy Programista .Net, wspiera rozwój duńskiej platformy zakupowej Justt, w wolnym czasie rozwija własne projekty i bloguje.


Ze względu na możliwe zastosowania podzieliłem bibliotekę na kilka komponentów:

  • Shared – współdzielone komponenty i klasy; dodatkowo wydzieliłem wspólne narzędzia używane podczas testów i w aplikacjach demonstracyjnych;
  • Root – podstawowe komponenty, obsługa schematów kolorów, 24-bitowej głębi koloru, operacji atomowych czy rozszerzenia do wyświetlania prostych elementów;
  • Operations – rozmaite komponenty i kontrolki do wyświetlania na ekranie: listy, tabele, ramki, menu, linie postępu (póki co, tylko w starym repozytorium) itp.;
  • Commands – obsługa linii poleceń, definiowanie i parsowanie rozmaitych formatów wprowadzanych poleceń, wyświetlanie pomocy, przechowywanie stanu, autouzupełnianie i wiele innych opcji wspierających tego rodzaju aplikacje.

Dodatkowo, w repozytorium znajdują się także liczne testy i przykładowe aplikacje demonstracyjne. Zachęcam do własnoręcznego zapoznania się z nimi i eksperymentowania. Na blogu w miarę postępów i stabilizowania kolejnych API będę dodawał kolejne artykuły i tutoriale, opisujące w jaki sposób korzystać z wybranych funkcji / komponentów. Oraz rozmaite ciekawostki „z produkcji”.

W tym artykule chciałbym pokrótce naszkicować historię powstania biblioteki, opisać, co najciekawsze już dostępne funkcje oraz zaprezentować dalsze plany rozwoju (w tym wskazać, które elementy są jeszcze na „bardzo wczesnym etapie rozwoju”).

I jeszcze ważna uwaga: paczki z alfy dostępne na Nuget.org są bardzo stare — w związku z tym mocno kuleją, odstają od aktualnych tutoriali i przykładów w tym artykule. W celu przyśpieszenia procesu powstawania biblioteki tymczasowo zrezygnowałem z ich aktualizowania i publikowania – głównie ze względu na zmiany w API. To może się wkrótce zmienić, ale jeszcze przez kilka iteracji pozostanie tak jak jest. (Zwyczajnie szkoda mi czasu, który mam przeznaczony na „prywatne” programowanie, „marnować” na częste dodawanie i usuwanie referencji pakietowych zamiast projektowych i vice-versa.)

Historia powstania

Mam nadzieję, że dla Ciebie będzie to równie ciekawe jak dla mnie. Otóż jako programista, który wychował się na grach, nie byłbym sobą, gdybym w zaciszu domowym co jakiś czas nie próbował stworzyć swojego własnego magnum opus. Czy choćby nie próbował eksperymentować z jakimiś jego aspektami. Traktując to jako głównie zabawę i rozwój warsztatu stricte programistycznego, nie będę wchodził w jakiekolwiek skomplikowane, graficzne interfejsy użytkownika, skomplikowaną grafikę czy tryby 3D. Przede wszystkim, zależy mi na modelowaniu ciekawych mechanizmów i na ewentualnym opowiedzeniu historii. Nie mam czasu na mozolne siedzenie w Photoshopie czy 3D Max…

W takiej sytuacji rodzaje gier, z którymi mógłbym chcieć się mierzyć to paragrafówki, tekstowe przygodówki i rogaliki. Z tych wymienionych, te ostatnie wydają się najciekawsze, ze względu na złożoną mechanikę i ciekawe elementy rozgrywki czy generowania świata. Tradycyjni przedstawiciele gatunku, tacy jak Rogue, Adom, NetHack, Moria czy Angband były brzydkie jak noc. O wiele ciekawsze są nowsze produkcje pokroju prostego (co nie znaczy łatwego!) Brogue czy (niesamowicie złożonego) Ultima Ratio Regum, które mimo że pozostają w trybie tekstowym są bajecznie kolorowe, ale czytelne (w odróżnieniu od opartego o podobne założenia, ale niesamowicie złożonego Dwarf Fortress, które wymaga wielkiego poświęcenia i skupienia już na etapie zapoznawania się z dość skomplikowanym interfejsem).

Tymczasem konsola w Windows, a w szczególności System.Console dostępna w .Net udostępnia zawrotne 16 kolorów. I żadne WinAPI nie pomoże. I już. Cokolwiek nie zrobimy, gra będzie wyglądać jak sprzed 30 lat. Co najwyżej, możemy sobie wybrać inny zestaw 16 barw, a w zasadzie to 14, bo nie wyobrażam sobie rezygnacji z czarnego i białego… No nie poszalejemy…

Stąd mój pomysł, by pisać samą aplikację tak, jakby „wszystkie” kolory były dostępne, ustawić sobie jakąś lepszą paletę barw (np. by zawierała jakiś ceglasty / pomarańczowy kolor) i w locie sobie wyliczać, który kolor z palety będzie najbardziej zbliżony do żądanego. Łatwiej powiedzieć niż zakodować. Tymczasem, zajęło mi to jeden wieczór. W tym czasie szukałem tego jednego, właściwego komentarza na StackOverflow, sugerującego, by nie porównywać po prostu kanałów barwnych w przestrzeni RGB, ale HSL [Hue, Saturation, Luminosity] w System.Drawing.Color. Ten ostatni parametr będzie nazwany B, jak Brightness, ale to szczegół, choć w teorii istotny. HSL bardziej skupia się na aspektach jasności i nasycenia barw niż samym kolorze. W praktyce – najlepsze wyniki uzyskałem pisząc taką heurystykę, która ważyła wyniki z obu przestrzeni.

Mając dostęp do takiej konwersji mogłem już skupić się na programowaniu samej gry. A gdybym kiedyś w przyszłości dopisał konsolę udostępniającą RGB (np. w trybie graficznym) to dzięki abstrakcjom i interfejsom dla samej gry nie powinno być to zauważalne. A tymczasem już mogłem na spokojnie prototypować interfejs i testować silnik.

To był rok 2016. Aktualnie trochę się pozmieniało.

Po pierwsze, Windows 10 (od aktualizacji Fall Creators Update, buildy powyżej 16299) udostępnia w systemowej konsoli tzw. Virtual Terminal Sequences, oprócz masy innych komend także wsparcie dla palety 256 kolorów oraz pełnego koloru 24-bitowego. To pierwsze raczej mało kogo zainteresuje, zwłaszcza, gdy dostępne jest to drugie – a już szczególnie w sytuacji, gdy nie udostępniono żadnego API do odczytu i zapisu tych palet 256… (Przynajmniej ja nic o tym nie wiem, może coś się zmieniło do tego czasu, ale tak czy inaczej: who cares?)

Po drugie, również mniej więcej pod koniec 2017, Microsoft wypuścił narzędzie Color.Tool służące do wczytywania schematów palet kolorów w postaci XML i INI.

Ja na te obie informacje natknąłem się kilka tygodni temu i postanowiłem oba te fakty uwzględnić w mojej bibliotece! Z ciekawostek warto jeszcze dodać, że Color.Tool posiada wbudowane trzy eksperymentalne heurystyki, konkurencyjne do mojej.

Pozostałe biblioteki powstały już w wyniku bardziej przyziemnych potrzeb. Otóż w ramach pracy zawodowej potrzebowałem (w miarę na szybko) stworzyć narzędzie demonstracyjne dla pewnej biblioteki służącej do zarządzania plikami w chmurze i transmitowania ich w obie strony. Interakcja z programem mogła być dość złożona, a maszyna stanu mogła przechowywać sporo informacji, których ciągłe ręczne wprowadzanie byłoby niezwykle uciążliwe i podatne na błędy (takie jak tokeny autoryzacyjne, listy i filtry plików). Stąd takie, a nie inne potrzeby techniczne, które zacząłem rozwijać po godzinach w domu, by potem w pracy móc je wykorzystać w tym narzędziu, które, nawiasem mówiąc, i tak powstawało dość niespiesznie obok głównego projektu. Dzięki temu wyrobiłem się na czas a aplikacja demonstracyjna grała i trąbiła (wyniki były kolorowe, tabelki równe, pomoc do komend łatwo dostępna, asynchronicznie paski postępu podczas ściągania plików też robiły swoje…).

Niestety, części biblioteki odpowiedzialne za te „wyższe” funkcje w zasadzie już dawno nie były przeze mnie dotykane, ale teraz, gdy część dotyczącą kolorów i podstawowego sterowania parametrami konsoli mam już za sobą (nie planuję już żadnych drastycznych zmian w API) – powinno się to zmienić.

Console.Root

Tak prezentuje się demo, pokazujące pełną paletę barwną w konsoli C#:

W sytuacji, gdy nie uruchomimy tej demonstracji (w miarę) aktualnym Windows 10, zobaczymy „tylko” wyniki pracy heurystyki (użyte zostały domyślne schemat i heurystyka):

Heurystyki, które znalazłem w Microsoftowym Color.Tool i dwie wersje mojej, postanowiłem wyabstrahować i dodać do nich stosowne interfejsy. Jeśli masz ochotę, możesz poeksperymentować z ich parametrami czy nawet dodać swoją, lepszą, która lepiej współpracuje z wybraną przez Ciebie paletą. W bibliotekę wbudowane są też (na tę chwilę) na stałe dwie palety – klasyczna Windows i ta z Windows 10. Poniżej prezentuję wyniki rzutowania ostatniego „dwupaska” z powyższych demonstracji, na ten sam schemat kolorów, ale za pomocą różnych heurystyk:

A tu, sytuacja odwrotna – jedna heurystyka, ale mapująca barwy referencyjne na rozmaite palety:

Ostatnio, oprócz powyższych 8 palet z Color.Toola (w repozytorium w Demos\colorschemes) i wspomnianych dwóch wbudowanych, zaimportowałem zestaw 192 palet z repozytorium Iterm2-Color. Po uruchomieniu zestawu testów z pliku Tests\ObscureWare.Console.Root.Tests\ColoringVisualTests.cs na dysku C: pojawi się folder TestResults, w którym można znaleźć porównania mapowań wszystkich tych schematów za pomocą dostępnych heurystyk. Nawet szybkie przejrzenie tych rezultatów daje do myślenia. Są one także gotowe do pobrania / sklonowania na Githubie.

Okazuje się, że nawet te (zazwyczaj) gorsze heurystyki w przypadku niektórych palet dają lepsze rezultaty. Z drugiej strony – chyba nikt nie spodziewa się, że np. kolor łososiowy zostanie dobrze zmapowany na paletę, gdzie jest jedynie zdefiniowane po 8 odcieni niebieskiego i zielonego? W przypadku autorów palet / heurystyk może to być interesujące narzędzie do porównania wyników rozmaitych kombinacji. Poniżej przykłady fragmentów wygenerowanych zestawień – wyraźnie widać fragmenty zarówno „dobre”, jak i kuriozalne.

Osobiście nie przeglądałem ich wszystkich, ale z tych co widziałem to najlepsze efekty uzyskuję chyba (jakby nie było – ocena poprawności takiego dopasowania jest IMHO mocno indywidualna…) na dość zrównoważonej palecie, którą przygotowałem modyfikując paletę standardową kolorami firmy DNV (chociaż dla bardziej uniwersalnych zastosowań, jeden z tych ciemnoniebieskich pewnie lepiej by było zastąpić czymś innym; w miarę potrzeb):

Zdecydowanie, w największej ilości przypadków, najlepsze dopasowania dają moja domyślna heurystyka (Gruchen’s Default) oraz domyślna z ColorToola (MS Weighted RGB Similarity), które zresztą zazwyczaj dają identyczne rezultaty.

Teraz kwestia najważniejsza – jak z tego korzystać? Bardzo prosto.

Należy utworzyć (lub pobrać domyślne instancje) kilka obiektów:

  • ColorScheme – pojemnik zawierający zestaw 16 kolorów, które mają być używane w przypadku niedostępności 24-bitowej głębi. Wybrać któryś predefiniowany w BuildInColorSchemes lub użyć SchemeLoader’a, by załadować schemat z pliku. Albo utworzyć ręcznie podając 16 par kolorów.
  • ColorHeuristic – wybrać jedną z kilku istniejących lub własną implementację interfejsu IColorHeurisitics. Hmmm… chyba dodam też klasę BuildInColorHeuristics dla łatwiejszego kodowania.

Mając te dwa obiekty można zainicjować klasę ColorBalancer, zajmującą się przeliczaniem kolorów na wskazaną paletę używając wybranej heurystyki. Proste. Klasa ta również buforuje raz przeliczone mapowania, w zamyśle przyśpieszając kolejne odwołania do tego samego koloru.

Instancję balancera przekazujemy w konstruktorze klasy ConsoleController. Ta klasa odpowiada za odczyt bieżącej konfiguracji kolorów systemowej konsoli i jej zmiany (nadpisanie przekazaną paletą). Można też nie tworzyć ręcznie balancera i utworzyć kontrolera bez podawania parametrów, wówczas ten wykorzysta domyślną heurystykę i domyślny zestaw kolorów. W planach mam ew. zmianę domyślnego zestawu kolorów (Win 10) na bieżący schemat konsoli – by go nie nadpisywać bez wyraźnego żądania…

I finalnie, można wreszcie utworzyć właściwą klasę zarządzającą dostępem do konsoli – SystemConsole. Jej konstruktor wymaga jedynie podania instancji kontrolera oraz (opcjonalnie) konfiguracji konsoli.

Ta ostatnia klasa umożliwia wybranie parametrów ekranu takich jak ilość oczekiwanych wierszy i kolumn (ekranu i bufora), czy ma być pełny ekran, a jeśli tak +- to który, czy próbować aktywować tryb wirtualnej konsoli (czyli w tym 24-bitowy kolor) i kilka innych.

Generalnie w tej okolicy jeszcze pewnie sporo pracy i mniej istotnych zmian będzie, takich jak np. opcjonalna blokada wielkości okna, możliwość przywrócenia trybu bez wirtualnej konsoli, zdarzenia dotyczące zmiany wielkości okna i lub bufora, przeniesienie wszystkich kontrolnych operacji do samego kontrolera (by łatwiej zaimplementować wszystko pod .Net Core w przyszłości) itp. Zasadniczo na bieżącym etapie to nie ma wielkiego znaczenia, ale kilka kontrolek z pozostałych bibliotek może wpaść w niemałe tarapaty nie mogąc na pewnych parametrach konsoli polegać… Ale to kwestie najbliższych tygodni. (Mam nadzieję…)

Całość, w prostym przypadku może wyglądać np. tak:

Gdzie jedyne oczekiwanie, to by ilość kolumn w oknie konsoli wynosiła 128 zamiast standardowych 120. Reszta jest domyślna…

Co ważne – z tej konsoli nie korzysta się już na zasadzie statycznego, wbudowanego singletona, ale wstrzykuje się jego instancję wszędzie tam, gdzie jest potrzebna. Z jednej strony to pewne zagrożenie – bo kod „standardowy” będzie mógł z niej nadal korzystać bezpośrednio (i narozrabiać), z drugiej – to zazwyczaj tylko drobny refaktoring: zmiany dużego „C” na małe.

Zastanawiam się nad ewentualnym bezpośrednim wykorzystaniem API systemowych i przekierowaniu strumieni konsoli systemowej „w maliny” by uniknąć takich konfliktów… Może się udać.

Interfejs IConsole implementuje wszystkie kluczowe metody „zwykłej” konsoli, plus szereg nowych. Mam nadzieję, że praca z nią będzie możliwie intuicyjna…

Obiekty „*Style”, to instancje struktury ConsoleFontColor, zawierające pary koloru napisu i tła (RGB), które będą użyte do wyświetlania najbliższego napisu, oraz wszelkich napisów następujących po nim (które nie definiują nowego koloru / zestawu kolorów).

Niestety, to jest dość istotna uciążliwość – w przypadku konsoli monochromatycznej nie mamy problemów związanych z „losowymi” zmianami używanych kolorów – bo są dwa – czarne tło i (niemal) białe litery. Jednak w momencie, gdy przestawimy aktualny kolor (napisów lub tła) – wszystkie napisy, które będziemy wysyłać do konsoli od tego momentu będą korzystały z nowych wartości. Do czasu aż ustawimy nowe kolory.

Rodzi to konkretne problemy, gdy fragmenty ekranu wyświetlamy z kilku wątków (np. aktywna linia poleceń, a powyżej kilka linii z aktualizującymi się na bieżąco paskami postępu) i każdy operuje innymi kolorami (i co gorsza w innym obszarze ekranu…). To wymaga synchronizacji i pewnej atomizacji operacji. Zadanie dość karkołomne, gdyby za każdym razem chcieć to ręcznie ogarniać. Stąd moje prace zarówno nad kontrolkami, które tego stanu same pilnują jak i wraperem IAtomicConsole, który odpowiada za zsynchronizowane wykonanie poleceń, np.:

Oczywiście, w przypadku, gdy nasza aplikacja po prostu wyświetla kolejne linie tekstu nie ma takiej potrzeby – stad rozwiązanie dostępne jest jako opcjonalne, a nie wbudowane w samą konsolę…

Console.Operations

Biblioteka ta, w stosunku do tego jaką mam na nią wizję, dopiero raczkuje, a to dlatego, że do tej pory powstawała mocno ad-hoc, jej jakość i kompletność pozostawia wiele do życzenia. W najbliższym czasie zamierzam przystąpić do jej refaktoringu i porządkowania, dlatego przykłady, które poniżej pokażę będą raczej skąpe i pobieżne – po więcej (aktualnych) przykładów zapraszam na bloga i / lub przeglądania aplikacji demonstracyjnych.

W większości przypadków staram się trzymać pewnego wzorca, który nazwałem sobie roboczo MSP – Model – Style – Presenter. Model to z grubsza dane do wyświetlenia. Styl to specyficzny dla danej kontrolki zestaw parametrów, które mówią jej jak ma wyświetlić dane. Wreszcie prezenter, to połączenie zwyczajowego widoku i kontrolera (o ile ma zastosowanie), czyli klasa (lub funkcja), która bierze model i styl i generuje odpowiednio na ekranie, ewentualnie umożliwiając użytkownikowi interakcję.

Dzięki temu można jeden styl łatwo współdzielić między kontrolkami tych samych typów (a ponieważ style dla kontrolek bardziej złożonych to często kompozycje styli kontrolek prostszych – fragmenty styli można współdzielić między różnymi kontrolkami) osiągając łatwo spójny i harmonijny interfejs. Osobiście jestem bardzo zadowolony z pewnej takiej ascetycznej elegancji „kontrolki” demo-menu:

Po przykłady użycia tego menu zapraszam do analizy źródła dowolnego projektu demonstracyjnego. W tej chwili lista demonstracji w każdym takim projekcie jest co prawda „zapałowana” na sztywno, ale planuję dynamiczne ich wykrywanie za pomocą np. MEF.

Menu-aplikacji

W duchu wzorca MSP zbudowany jest komponent MENU, pierwszy „nowej generacji”:

Najpierw definiujemy zawartość menu:

Następnie styl – ten dla menu zawiera parametry kolorów, kody klawiszy sterujących (domyślne można nadpisać – strzałki + Home + End + Enter), czy opcje formatowania pozycji, w tym przykładzie większość parametrów pozostaje domyślna:

Ramka wokół menu widoczna na przykładowym obrazku powyżej nie jest częścią menu – jest rysowana oddzielnie (przykłady tworzenia ramek omawiam poniżej). Sama inicjalizacja prezentera i renderowanie menu to tylko odrobina kodu:

Aktywacja menu, to proste wywołanie metody Focus(), które zwraca wybrany przez użytkownika element. Przykład w demie jest banalny – jeśli wybrana jest inna pozycja w menu oprócz ostatniej to wyświetla jej tytuł powyżej, odczekuje sekundę (by pokazać odmienną stylistykę aktywnego elementu) po czym ponownie aktywuje interakcję użytkownika i menu:

Istnieje też opcja podpięcia się pod zdarzenie ItemChanged, które generuje zdarzenia podczas wędrówek użytkownika po pozycjach menu.

Samo menu nie tylko wyświetla nieaktywne elementy menu innym kolorem, ale i pomija je podczas nawigacji.

Opcje rozwoju:

  • Opcjonalna możliwość wyjścia z menu (np. ESC) jeśli np. z menu jest tworzony jakiś dialog.
  • Może też inne opcje prezentowania aktywnego (nie wybranego) elementu, nie tylko inny kolor, ale np. jakaś strzałka?

Ramki

Ramki to w zasadzie pierwszy „komponent”, który napisałem w ramach tego projektu, niemal go nie ruszałem od tamtej pory, więc może wymagać „trochę” refaktoringu i porządkowania (np. nie uwzględnia istnienia IAtomicConsole, a powinien dawać taką opcję).

W ramach wzorca MSP ramki mają „prawidłowo” wydzielony jedynie Styl:

Styl ramek to po prostu para kolorów (znaki + tło) na ramkę i druga para (często ta sama) na wypełnienie jej wnętrza, do tego łańcuch (wewnętrznie tablica znaków…) z elementami ramki w odpowiedniej kolejności plus jeden znak wypełnienia (dla „gołej” ramki – spacja oczywiście). Kontrolery ramek nie zostały jeszcze jakoś porządnie wydzielone (po prostu są częścią typu ConsoleOperations, a ich model to po prostu współrzędne wywołania i tekst do wyświetlenia. Jeśli się nie zmieści zostanie przycięty. Odpowiedni (dość naiwny, niestety) algorytm postara się też połamać długi tekst na wiersze.

Marzy mi się udana integracja z funkcjami z Colorfull.Console i np. generowanie tła ramki w sposób gradientowy… Trochę to będzie złożone, ale efekt powinien być tego warty.

Zapraszamy do dyskusji

Patronujemy

 
 
More Stories
Milion userów nowego OS Huawei? Chińczycy nie boją się USA