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.)
Spis treści
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 Demoscolorschemes) i wspomnianych dwóch wbudowanych, zaimportowałem zestaw 192 palet z repozytorium Iterm2-Color. Po uruchomieniu zestawu testów z pliku TestsObscureWare.Console.Root.TestsColoringVisualTests.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:
var controller = new ConsoleController(); var console = new SystemConsole( controller, new ConsoleStartConfiguration(ConsoleStartConfiguration.Colorfull) { DesiredRowWidth = 128 // for RGB bars });
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…
public static void WaitBeforeQuit(this IConsole console) { console.SetColors(DefaultStyle); console.WriteLine(); console.PrintColorfullTextLine( new KeyValuePair<ConsoleFontColor, string>(DefaultStyle, "Holding console window open. Press "), new KeyValuePair<ConsoleFontColor, string>(HighlightStyle, "ENTER"), new KeyValuePair<ConsoleFontColor, string>(DefaultStyle, " to quit for good.")); console.ReadLine(); }
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.:
public static void CleanLineSync(this IAtomicConsole console, int? lineNo = null, Color? color = null) { if (console == null) throw new ArgumentNullException(nameof(console)); console.RunAtomicOperations((ac => { lineNo = lineNo ?? ac.GetCursorPosition().Y; color = color ?? Color.Black; string text = new string(' ', console.WindowWidth); ac.SetCursorPosition(0, lineNo.Value); ac.WriteText(0, lineNo.Value, text, color.Value, color.Value); })); }
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:
var exitGuid = new Guid(@"a7725515-7f82-4c18-9c36-343003bdf20d"); var menuItems = new ConsoleMenuItem[] { new ConsoleMenuItem { Enabled = true, Caption = "menu item number one", Code = Guid.NewGuid() }, new ConsoleMenuItem { Enabled = true, Caption = "menu item number two", Code = Guid.NewGuid() }, new ConsoleMenuItem { Enabled = false, Caption = "menu item number three", Code = Guid.NewGuid() }, new ConsoleMenuItem { Enabled = true, Caption = "menu item number four with very long description", Code = Guid.NewGuid() }, new ConsoleMenuItem { Enabled = false, Caption = "menu item number five", Code = Guid.NewGuid() }, new ConsoleMenuItem { Enabled = true, Caption = "menu item number six", Code = Guid.NewGuid() }, new ConsoleMenuItem { Enabled = true, Caption = "Exit menu DEMO", Code = exitGuid } };
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:
var menuStyling = new MenuStyles { ActiveItem = new ConsoleFontColor(Color.Red, Color.Black), DisabledItem = new ConsoleFontColor(Color.Gray, Color.Black), NormalItem = new ConsoleFontColor(Color.WhiteSmoke, Color.Black), SelectedItem = new ConsoleFontColor(Color.Black, Color.LightGray), Alignment = TextAlign.Center };
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:
var menu = new ConsoleMenu(aConsole, new Rectangle(menuStartX, menuStartY, menuContentWidth, 0), menuItems, menuStyling); menu.RenderAll();
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:
ConsoleMenuItem result = null; while (result == null || result.Code != exitGuid) { result = menu.Focus(resetActiveItem: true); aConsole.CleanLineSync(0, Color.Black); aConsole.RunAtomicOperations(ac => { aConsole.SetCursorPosition(0, 0); aConsole.PrintColorfullText( new KeyValuePair<ConsoleFontColor, string>(new ConsoleFontColor(Color.BlanchedAlmond, Color.Black), @"Selected menu: "), new KeyValuePair<ConsoleFontColor, string>(new ConsoleFontColor(Color.Gold, Color.Black), result?.Caption ?? "#NULL#") ); }); Thread.Sleep(1000); }
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:
var text1Colors = new ConsoleFontColor(Color.Gold, Color.Black); var text2Colors = new ConsoleFontColor(Color.Brown, Color.Black); var text3Colors = new ConsoleFontColor(Color.Black, Color.Silver); var frame2Colors = new ConsoleFontColor(Color.Silver, Color.Black); var solidFrameTextColors = new ConsoleFontColor(Color.Red, Color.Yellow); var solidFrameColors = new ConsoleFontColor(Color.Yellow, Color.Black); FrameStyle block3Frame = new FrameStyle(frame2Colors, text3Colors, @"┌─┐││└─┘", '░'); FrameStyle doubleFrame = new FrameStyle(frame2Colors, text3Colors, @"╔═╗║║╚═╝", '▒'); FrameStyle solidFrame = new FrameStyle(solidFrameColors, solidFrameTextColors, @"▄▄▄██▀▀▀", '▓');
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.
ConsoleOperations ops = new ConsoleOperations(console); ops.WriteTextBox(new Rectangle(10, 20, 25, 7), @"Lorem ipsum dolor sit amet enim. Etiam ullamcorper. Suspendisse a pellentesque dui, non felis.", solidFrame);
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.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.
Tabele
Tabele to zasadniczo takie bardziej skomplikowane ramki. Pomijając, oczywiście, tabelę „bezramkową”. Niestety jest to też chyba najbardziej zaniedbana, jeszcze, część biblioteki. API wymaga porządkowania i standaryzacji.
Konfiguracja styli została zrealizowana w podobny sposób jak przy ramkach – są kolory (chociaż więcej, bo dochodzą kolory nagłówka, a tekst może mieć odmiennie kolorowane linie parzyste i nieparzyste) a sama definicja ramki jest dłuższa (i różna, w zależności od jej typu). Sugerowałbym za to nie podawać znaku wypełnienia – tabelki są bardziej czytelne, gdy puste przestrzenie pozostają… puste.
TableStyle tableStyle = new TableStyle( tableFrameColor, tableHeaderColor, tableOddRowColor, tableEvenRowColor, @"|-||||-||-|--", // simple, ascii table ' ', TableOverflowContentBehavior.Ellipsis);
Możliwe jest także zażądanie by tabelka zwijała zawartość komórek przekraczającą ich wyliczoną długość. Ale z tego co pamiętam to jeszcze nie działa idealnie, zwłaszcza z kwestiami spacji czy znaków interpunkcyjnych. Tymczasem przykład tabelki wyświetlanej z ramką lub bez, ale z zawartością, która się bezpiecznie mieści na ekranie.
Oprócz stylu, który może być odrobinę różny w zależności od rodzaju tabelki, należy utworzyć definicje kolumn tabeli:
DataTable<string> dt = new DataTable<string>( new ColumnInfo("Column a", ColumnAlignment.Left), new ColumnInfo("Column B", ColumnAlignment.Left), new ColumnInfo("Column V1", ColumnAlignment.Right), new ColumnInfo("Column V2", ColumnAlignment.Right));
Opcjonalnie, można ustawić kolumnom żądaną szerokość. Jeśli tego nie zrobimy algorytm postara się przyjąć optymalne szerokości zarówno do pomieszczenia nagłówków, jak i zawartości wierszy. W przypadku, gdy nie jest to możliwe zacznie przydzielać dostępną powierzchnię proporcjonalnie do wielkości zawartości. Ewentualnie przycinać zbyt długie teksty, jeśli takie są ustawienia. Nie działa to idealnie – zbyt wiele tu warunków brzegowych, zwłaszcza gdy mamy pomieszane w definicji szerokości ustalone i dynamiczne totalnie niedopasowane do zawartości, ale zazwyczaj jest OK.
Co gorsza, gdzieś mi zaginęły demonstracje z tabelką z pełnymi ramkami, póki co jest tylko ta na sposób SpecFlow / Cucumber i „goła”. Przykład z danymi wyplutymi z generatora:
Na pewno chciałbym trochę zmienić definicje kolumn, by np. umożliwić zachowanie niektórych kolumn jako np. never-overflow-resize-to-content np. dla kolumn z liczbami czy datami. Plus może także tabelki, które zamiast liter nieparzystych inaczej kolorują tło? Albo dodają ramki także pomiędzy wierszami? No, na pewno będzie co robić…
Console.Commands
Ta biblioteka funkcjonalnie jest niemal domknięta. Tam, gdzie — moim zdaniem — najbardziej jeszcze wymaga poprawek to i tak raczej kwestie współpracy z interfejsem, które są do naprawienia w bibliotece Operations. I ewentualne rozszerzanie / uzupełnianie drugorzędowej funkcjonalności – np. wyświetlanie w pomocy większych ilości tekstu i informacji, formatowania, przykładów czy autouzupełniania dla wybranych parametrów itp. Ale do rzeczy.
Najważniejsze założenia, które przyjąłem projektując tę bibliotekę były następujące:
- Obsługa różnych stylów formatowania poleceń,
- Ściśle typowane parametry polecenia, z automatycznymi konwersjami i większością walidacji załatwianą przez framework,
- Łatwość rozszerzania i konfigurowania rozmaitych aspektów,
- Automatyczne generowanie ekranów pomocy na podstawie definicji,
- Przechowywanie i przekazywanie maszyny stanu pomiędzy poleceniami,
- Obsługę zarówno parametrów nazwanych, jak i „swobodnych”, zarówno w środku, jak i na tylko na końcu polecenia,
- Auto-uzupełnianie – zarówno przełączników, samych komend, jak i wybranych wartości (to ostatnio sterowane już przez samą komendę),
- Możliwość uruchamiania aplikacji „hostującej” zarówno jako pojedynczej komendy, jak i jako silnika komend (wraz z dostępem do historii, schowka i autouzupełniania).
Większość z tych założeń udało się już zrealizować w zadowalającym stopniu!
Omówię teraz przykładowe polecenie z dema. Po więcej zachęcam – ponownie — do przeglądania tutoriali, aplikacji demonstracyjnych oraz unit testów.
Model
Każda komenda składa się z dwóch „części”: definicji parametrów oraz klasy wykonawczej. Obie łączy się ze sobą za pomocą dedykowanych atrybutów. Model:
[CommandModelFor(typeof(DirCommand))] [CommandName("dir")] [CommandDescription(@"Lists files within current folder or repository state, depending on selected options.")] public class DirCommandModel : CommandModel { [CommandOptionName(@"includeFolders")] [Mandatory(false)] [CommandOptionFlag("d", "D")] [CommandDescription("When set, specifies whether directories shall be listed too.")] public bool IncludeFolders { get; set; } [CommandOptionName(@"mode")] [Mandatory(false)] [CommandOptionSwitch(typeof(DirectoryListMode), "m", DefaultValue = DirectoryListMode.CurrentDir)] [CommandDescription("Specifies which predefined directory location shall be listed.")] public DirectoryListMode Mode { get; set; } [CommandOptionName(@"filter")] [Mandatory(false)] [CommandOptionSwitchless(0)] [CommandDescription("Specifies filter for enumerated files. Does not apply to folders.")] public string Filter { get; set; } // TODO: add sorting argument }
Model składa się z prostego zestawu właściwości, opatrzonych dwoma rodzajami atrybutów: atrybuty dla parsera i silnika informujące o zachowaniu i rodzaju parametru oraz atrybuty do generowania treści na ekranach pomocy. Niektóre, jak np. CommandName, CommandOptionFlag czy CommandOptionName będą wykorzystane w obu zakresach. Po zażądaniu w linii poleceń pomocy dla tej komendy możemy zobaczyć coś takiego:
Ale też i np. coś takiego:
Wszystko zależy od tego, jakie na początku przyjęliśmy ustawienia formatowania / parsowania. Takie:
var options = new CommandParserOptions { UiCulture = CultureInfo.CreateSpecificCulture("en-US"), FlagCharacters = new[] { @"", "-" }, SwitchCharacters = new[] { @"-", "--" }, OptionArgumentMode = CommandOptionArgumentMode.Separated, OptionArgumentJoinCharacater = ':', // not used because of: CommandOptionArgumentMode.Separated AllowFlagsAsOneArgument = false, CommandsSensitivenes = CommandCaseSensitivenes.Insensitive, SwitchlessOptionsMode = SwitchlessOptionsMode.EndOnly, };
Lub np. takie:
var options = new CommandParserOptions { UiCulture = CultureInfo.CreateSpecificCulture("en-US") FlagCharacters = new[] { @"--" }, SwitchCharacters = new[] { @"/" }, OptionArgumentMode = CommandOptionArgumentMode.Joined, OptionArgumentJoinCharacater = ':', AllowFlagsAsOneArgument = true, CommandsSensitivenes = CommandCaseSensitivenes.Insensitive, SwitchlessOptionsMode = SwitchlessOptionsMode.Mixed };
Widać też od razu, jaki ma to wpływ też na samą aplikację – po sposobie żądania pomocy. Zresztą, sam silnik o tym informuje stosownymi komunikatami:
Jeśli chodzi o możliwe wartości parametrów typu opcja to są to flagi (bool), enumeracje lub typy, dla których istnieją zdefiniowane konwertery / parsery (w praktyce: liczby, daty, Guidy) albo po prostu łańcuchy znaków.
Przykładowy parser:
[ArgumentConverterTargetType(typeof(TimeSpan))] internal class TimeSpanArgumentConverter : ArgumentConverter { /// <inheritdoc /> public override object TryConvert(string argumentText, CultureInfo culture) { return TimeSpan.Parse(argumentText, culture); } }
Ech. Chyba widzę przestrzeń do poprawy – lepszy byłby generyczny interfejs zamiast abstrakcyjnej klasy bazowej. Ale były ku temu powody, kiedy to pisałem – ten komentarz wiele wyjaśnia :
// WORKAROUND: this boxing is not fine, but we have to live with it... Generics would be a nightmare here...
Egzekutor
Po poprawnym sparsowaniu przekazanej komendy, obiekt modelu wraz z ustawionymi parametrami jest przekazywany do klasy wykonawczej. Ta może się skupić wyłącznie na analizie gotowych parametrów i właściwym wykonaniu żądanych działań:
[CommandModel(typeof(DirCommandModel))] public class DirCommand : IConsoleCommand { public void Execute(object contextObject, ICommandOutput output, object runtimeModel) { var model = runtimeModel as DirCommandModel; // necessary to avoid Generic-inheritance troubles... // TODO: custom filters normalization? switch (model.Mode) { case DirectoryListMode.CurrentDir: { this.ListCurrentFolder(contextObject, output, model); break; } case DirectoryListMode.CurrentLocalState: break; case DirectoryListMode.CurrentRemoteHead: break; default: break; } } private void ListCurrentFolder(object contextObject, ICommandOutput output, DirCommandModel parameters) { string filter = string.IsNullOrWhiteSpace(parameters.Filter) ? "*.*" : parameters.Filter; string basePath = Environment.CurrentDirectory; DataTable<string> filesTable = new DataTable<string>( new ColumnInfo("Name", ColumnAlignment.Left), new ColumnInfo("Size", ColumnAlignment.Right), new ColumnInfo("Modified", ColumnAlignment.Right)); var baseDir = new DirectoryInfo(basePath); if (parameters.IncludeFolders) { var dirs = baseDir.GetDirectories(filter, SearchOption.TopDirectoryOnly); foreach (var dirInfo in dirs) { filesTable.AddRow( dirInfo.FullName, new[] { dirInfo.Name, "<DIR>", Directory.GetLastWriteTime(dirInfo.FullName).ToString(output.UiCulture.DateTimeFormat.ShortDatePattern) }); } } var files = baseDir.GetFiles(filter, SearchOption.TopDirectoryOnly); foreach (var fileInfo in files) { filesTable.AddRow( fileInfo.FullName, new[] { fileInfo.Name, fileInfo.Length.ToFriendlyXBytesText(output.UiCulture), File.GetLastWriteTime(fileInfo.FullName).ToString(output.UiCulture.DateTimeFormat.ShortDatePattern) }); } output.PrintSimpleTable(filesTable); } }
Nie pamiętam już dokładnie jakie problemy z dziedziczeniem generycznych typów – w końcu od powstania tego kodu minęły niemal dwa lata, ale pewnie przyjrzę się temu jeszcze w najbliższym czasie… Może to była kwestia po prostu skomplikowanego wyszukiwania i konstruowania?
Ważne uwagi. Jak widać klasa wykonawcza sama nie przechowuje w sobie stanu. Nie powinna tego robić. Nie ma żadnej gwarancji, czy będzie tworzona jedna na cały czas życia programu czy od nowa przy każdym poleceniu. Do przechowywania stanu służy obiekt runtimeModel, którym steruje sama aplikacja – typ obiektu jest definiowany przy starcie silnika, a manipulacją zajmują się wyłącznie komendy.
Podobnie, komenda nie ma bezpośredniego dostępu do konsoli – wyświetla wyniki wyłącznie za pomocą szeregu metod dostępnych w interfejsie ICommandOutput. Z jednej strony to był niezły pomysł, ale przeczuwam, że w przyszłości będę chciał to zmienić. Chciałbym uniknąć możliwości wywoływania niepoprawnych operacji przez komendy (jak pisanie poza buforem ekranowym) oraz tworzenia komend interaktywnych (od czegoś w końcu te parametry komendy są!), ale z drugiej – nie chciałbym ograniczać użytkowników biblioteki. Do przemyślenia jak to sensownie pogodzić.
Konfiguracja
Silnik oczekuje rozmaitych parametrów. Dla niektórych ma dostarczone implementacje domyślne, niektóre należy dostarczyć. Ze względu na potencjalną złożoność tej inicjalizacji postanowiłem wykorzystać wzorzec fluent-builder.
Co można / trzeba przekazać?
- Oczywiście konsolę. Gdzieś trzeba wyświetlać wyniki, komunikaty i pobierać dane od użytkownika.
- Style. Dużo styli. W tej chwili chyba nawet za dużo. Po uproszczeniu sposobu komunikowania się klas wykonawczych z ekranem na pewno zostaną style używane do wyświetlania ekranów pomocy, błędów, ostrzeżeń i samej linii poleceń. Marzy mi się też linia poleceń, która będzie kolorowała składnię…
- Dependecy Incjection Resolver’a. Jeśli obiekty wykonawcze muszą mieć wstrzykiwane jakieś referencje – to koniecznie. Bez tego, domyślnie, muszą mieć bezparametrowe konstruktory.
- Lista komend / bibliotek z komendami. Albo i jedno, i drugie.
- Lista konwerterów opcjonalnych (todo).
- Obiekt stanu / kontekstu (Ten jest co prawda podawany dopiero na wejściu metody Run).
- Obiekt do komunikacji z systemowych schowkiem do operacji kopiuj-wklej.
var engine = CommandEngineBuilder.Build() //.WithCommands(typeof(DirCommand), typeof(ClsCommand), typeof(ExitCommand),… .WithCommandsFromAssembly(this.GetType().Assembly) .UsingOptions(options) .UsingClipboardProvider(clipboarder) .UsingDependencyResolver(resolver) .UsingStyles(CommandEngineStyles.DefaultStyles) .ConstructForConsole(console);
W przykładowej demonstracji są dość proste implementacje komend DIR i CD, a obiekt stanu zawiera jedynie bieżący katalog. Obserwuję też konkurencyjne rozwiązanie, które znalazłem kilka miesięcy temu. W niektórych aspektach jest moim zdaniem zbyt skomplikowane (nie cierpię podejścia Convention-over-Configuration – zbyt wile w nim magii) i niektóre funkcje, które są u mnie (od początku ) tam ciągle nie działają, ale w niektórych aspektach jest wykonane lepiej / prościej / czytelniej… Więc wkrótce refaktoring
Przyszłość
Sporo. Pomysłów mam wiele. Oto najważniejsze.
- Autouzupełnianie dla enumów lub wybranych komend.
- Kolorowanie składni w wierszu polecenia.
- Naprawienie wiersza polecenia.
- Rozszerzona pomoc – dodatkowe opisy, przykłady itp. Plus oczywiście składnia definicji.
- Usprawnienia ekranu pomocy.
- Lokalizacja komunikatów i ekranów pomocy.
- Lepsze sposoby wyświetlania wyników / komunikatów przez komendy, ale nadal bezpieczne… (Z tym czekam jeszcze na stabilizację pozostałych projektów i ich API).
- Usprawnienie hierarchii dziedziczenia i generyków.
- Walidacja spójności definicji i komend przy starcie (np. czy wszystkie typy danych mają konwertery, czy opcjonalne interfejsy wymagane przez definicję komendy są przez nią dostarczone itd.).
Podsumowanie
Z kodem do ludzi! Do tej pory pracowałem nad biblioteką głównie dla siebie, pod siebie i pod moje potrzeby. Wychodząc z nią między ludzi spodziewam się, że niektóre rozwiązania mogą być złe / niewygodne / zbyt ograniczone itd. Albo, że zupełnie czegoś nie przewidziałem, nie przetestowałem, nie wymyśliłem…
Jeśli znajdujesz w swojej pracy lub hobby miejsce na wykorzystanie moje biblioteki to świetna wiadomość. Jeśli jednak coś Ci w niej przeszkadza – to nie pozostawiaj tego w czterech ścianach swojej pracowni – daj znać, dołącz do projektu, zgłoś buga! Ja już podczas pisania tego artykułu, najwyższym wysiłkiem woli, powstrzymywałem się od natychmiastowego rozwiązywania zauważonych problemów w samym kodzie – inaczej nigdy nie skończyłbym pisać tego tekstu. Pomóż mi stworzyć narzędzie, które będzie pomocą dla nas obu, a nie drogą przez mękę. Co kilka (mądrych) głów to nie jedna.
Może z czasem uderzymy też na .Net Core?