Ncurses

Z PostgreSQL
Skočit na navigaci Skočit na vyhledávání

Autor: Pavel Stěhule, 7.4.2021

Poslední 4 roky jsem hodně času věnoval vývoji pspg. A nyní, když je pspg dokončen, tak bych zde chtěl shrnout zkušenosti z vývoje této aplikace, a napsat něco o ncurses. ncurses je základní knihovna, bez které bych aplikaci jako je pspg nemohl napsat (asi mohl, ale dalo by mi to výrazně víc práce).

ncurses je knihovna, která poskytuje základní funkcionalitu pro vývoj terminálových aplikací. I když se jedná o novější inkarnaci knihovny curses, má svoje léta. Historie této knihovny spadá do přelomu 70 a 80 let, a svým konceptem a možnostmi odpovídá této době. Jednoduché terminálové aplikace se nad touto knihovnou napíší jednoduše (typu unixového příkazu top). Napsat komplexnější aplikace typu mc nebo pspg je už výrazně pracnější. Bohužel, žádná rozumná alternativa (co se týče rozšíření a životaschopnosti, akceptace) neexistuje. S touto knihovnou se pracuje docela jednoduše. Také je, co se týče funkčnosti hodně omezená. Když porovnám vývoj nad ncurses s cca o 20 let mladší knihovnou libxml2, tak je programování v ncurses báječně jednoduché. Za těch 20 let se programování dostalo někam úplně jinam - libxml2 umí neskutečné věci, ale správně ji použít je výrazně obtížnější. Napadá mne analogie náročnosti řízení a možnosti stíhačky z první světové války (kdy už letadlo vypadalo jako letadlo) - rok 1918, se stíhačkou se začátku 2 světové (podívejte se na videa přípravy Spitfire k letu).


Přístup k počítačům s Unixem v 70 a 80 letech zprostředkovávaly terminály. Původně se jednalo o modifikované zařízení pro dálnopis, posléze to už byly jednoduché počítače s CRT monitorem a primitivními CPU. Terminály z 80 let už byla relativně pokročilá zařízení podporující barvy, jemnou grafiku (ať vektorovou nebo bitovou). Vývoj terminálů nebyl nijak zvlášť revoluční - dlouho to bylo elektro mechanické zařízení. Posléze byl mechanický tisk znaků na papír nahrazen zobrazením znaků v CRT monitoru, a postupně se přidávaly další možnosti (alternativní znakové sady, možnost skrolování obrazu, barvy, zvýraznění, ...), tak jak rostly schopnosti mikropočítačů. Způsob komunikace s terminálem byl a je stále stejný od dob dálnopisu. Když zobrazuji znak na obrazovku terminálů, používám ty samé prostředky, jako kdybych zobrazoval znak na papír připojeného dálnopisu. To je asi kritická vlastnost unixových terminálových aplikací - běží úplně stejně se stejnými prostředky, ať aplikaci pustíte lokálně nebo vzdáleně. Není tak důležité, kde je terminál umístěný fyzicky nebo jaký vlastně terminál používáte.

Díky terminálům bylo možné relativně jednoduše sdílet výpočetní výkon tehdejších počítačů. Na druhou stranu programátor neměl přímý přístup k hardware terminálu, a s terminálem komunikoval po relativně pomalé síti. To se samozřejmě podepsalo na vizuální podobě tehdejších aplikací. Unixové aplikace, alespoň pro mne, měly auru něčeho výjimečného, zvláštního. Ale skoro pokaždé jsem si říkal, proč jsou tak ošklivé. Aplikace napsané pro MS DOS koncem 80 a v první polovině 90 let vypadaly výrazně lépe. Vzpomínám na Turbo Pascal verze 5.5, pak do dokonalosti dotažené Turbo Vision v 6tce. Podívejte se na screenshoty FoxPro nebo WordPerfectu, nebo českého PC FANDu. Nicméně, když o tom tak přemýšlím, ono to jinak s těmi terminály nešlo.

Aby Turbo Vision mohl nějak rozumně běžet, tak potřeboval přímý přístup k hardware grafické karty. Z důvodů rychlosti se obcházel i DOS. Při tehdejší rychlosti sítí (často se jednalo modemové spojení přes telefonní linku), tak by aplikace s vizuálem FoxPra byla nepoužitelná. V momentě, kdy padla technická omezení (konec 80 let), tak se vývoj terminálových aplikací hodně upozadil. Tam, kde šlo ergonomii UI se rychle přešlo na grafiku (XWindow), a vývoj samotné technologie terminálových aplikací se zastavil, zakonzervoval. Pokud používáme terminálové aplikace, tak se pohybujeme v prostředí, kde vývoj skončil v druhé polovině 90 let. Napadá mne, že poslední větší změnou byl přechod na UTF8 (a pořád po více než 20 letech tato "novinka" způsobuje problémy - viz dvě varianty knihovny ncurses a ncursesw a důležitost správného nastavení proměnné LANG).

Vyjma muzea už není šance narazit na fyzický terminál. Nicméně naše stávající softwarové emulace terminálů jsou velice věrné emulace hardware z půli osmdesátých let (včetně emulace chyb). Nejrozšířenější akceptovaný standard je VT220 z roku 1983 (8bit CPU 8051). Z nekomerčních softwarových emulátorů pouze xterm umí emulovat některé funkce vyšších verzí (VT240, VT340,VT520) jako je podpora protokolů ReGIS nebo Sixel. xterm se už ale používá málo a nejrozšířenější terminály jako je Gnome Terminál nebo Konsole tyto funkce nepodporují (ve stávajících verzích). Vývojová verze knihovny VTE (kterou používá Gnome Terminál) podporuje Sixel (bitmapová grafika) a experimentálně se pracuje na podpoře ReGISu (vektorová grafika). Je to na jednu stranu obdivuhodné, ale také šílené, protože softwarová emulace je poměrně komplexní, a vlastně narážíme na limity hardware, které už fyzicky 40 let nepoužíváme. Nikdo od té doby neměl dostatečnou tržní sílu a ambice něco s tím udělat (pro komerční sféru je to absolutně nezajímavá oblast, a jednotlivec nic nezmůže). Vím o jednom experimentu, ale je to experiment - nepřikládám tomu žádnou váhu. Je fakt, že je vidět určitý posun k vlastnostem, které definuje xterm, a tudíž už pomalu opouštíme omezení terminálu VT220. Mimochodem, ncurses a xterm posledních 25 let udržuje tentýž vývojář - Thomas E. Dickey. Thomas také udržuje prohlížeč Lynx a pokud potřebujete poradit s čímkoliv ohledně ncurses, tak Thomas je ta pravá osoba.

Default na mé Fedoře (v Gnome Terminálu):

[pavel@localhost ~]$ set | grep TERM
COLORTERM=truecolor
TERM=xterm-256color

Tím, že se jedná o relativně starou technologii, tak se zákonitě musíme zeptat, proč tuto starou technologii ještě dnes používat - v 21 století (40-50 let po jejím vzniku). Odpověď bude vždy subjektivní. Já osobně preferuji některé terminálové aplikace z důvodu rychlosti a určité ergonomie. Vzhledem k tomu, že se jedná o aplikace často napsané v 90 letech, a tehdy běžely relativně rychle, tak na soudobém hardware jsou opravdu velice rychlé, což pro mne je základ komfortu. Potom je tu další faktor. Možnosti, které má vývojář, když chce napsat terminálovou aplikaci, jsou opravdu omezené. Tudíž si vývojář musí hodně dobře rozmyslet, co bude zobrazovat a jak. Ty dobré terminálové aplikace mají velice promyšlené uživatelské rozhraní - musí dobře fungovat v 16 barvách při základním rozlišení 24 x 80 znaků. S takovými aplikacemi se mi dobře pracuje. Je fakt, že jich moc není, ale zas jich, pro svou práci, tolik nepotřebuji. Samozřejmě, že platí, že zvyk je železná košile, a s aplikacemi spouštěnými v terminálu jsem nikdy neměl problém. A to i když jsem pracoval vzdáleně a rychlost internetu nebyla nic moc. Ne, že by občas nezalagovaly, ale tou dobou už se s aplikacemi ovládanými přes vnc nedalo pracovat už vůbec.

Zpět k ncurses a k terminálům. Jak zobrazím znak na terminálu? Použiji funkci fprintf a na standardní výstup pošlu nějaký text. Pokud je standardní výstup procesu nastavený na terminál, tak někde uvidím vytištěný text. Z pohledu operačního souboru je terminál zařízení, které zastupuje speciální soubor (/dev/tty) do kterého mohu zapisovat, a zrovna tak z něj mohu číst. Zapsaná data se zobrazí na obrazovce (kdybych měl připojený dálnopis, tak by se vytiskla na tiskárně dálnopisu). Čtení z tohoto souboru vlastně znamená přečtení bufferu klávesnice.

[pavel@localhost ~]$ echo "AHOJ" > /dev/tty
AHOJ

Primitivní textový editor (s možnostmi dálnopisu 70 let) vytvoříme voláním příkazu cat, který čte z /dev/tty a zapisuje do souboru (v tomto případě dopis.txt)

[pavel@localhost ~]$ cat /dev/tty > dopis.txt
Ahoj,
jmenuji se Pavel
[pavel@localhost ~]$ cat dopis.txt 
Ahoj,
jmenuji se Pavel

tty zařízení je pro většinu uživatelů naprosto transparentní - každý proces při svém startu podědí prostředí, které určuje, co je vstupem (zařízení zastoupené souborem, soubor nebo roura), stejně tak co je výstupem, a kam se má tisknout chybový výstup. Pokud nedojde k přesměrování nebo k použití roury, tak vstupy a výstupy procesu jsou na začátku nastavené na /dev/tty.

Na mé Fedoře mám po startu rovnou používat 3 terminály - z Gnome přístupné přes CTRL-ALT3 - CTRL-ALT6, a pro ně existují zařízení (speciální soubory) /dev/tty3 - /dev/tty6. Zařízení /dev/tty je virtuální. Pro každou session je namapované na nějaký terminál tty nebo pseudo terminál - pty. To, které zařízení se používá, vám zobrazí příkaz tty

[pavel@localhost ~]$ tty
/dev/pts/1

Zrovna tak, jako můžeme pracovat s /dev/tty, tak můžeme pracovat s konkrétními zařízeními. Například mohu přesměrovat standardní chybový výstup do jiného terminálu (což se dá dobře použít pro debugování ncurses aplikací).

[pavel@localhost ~]$ tty
/dev/pts/1
[pavel@localhost ~]$ ./a.out 2> /dev/pts/3

Pod pseudoterminálem si můžeme představit dynamicky vytvořenou proxy, mezi kontrolovanou (ovládanou) aplikací, a mezi aplikací, která zajišťuje služby terminálu. xterm, konzole, gnome terminál, screen nebo tmux - tyto aplikace, při svém startu, otevírají (vytváří) nové pseudo terminály. Dá se to použít všelijak. Například příkaz script" otevře pseudoterminál, ve kterém můžeme normálně pracovat, nicméně vstup a výstup je zaznamenán, a později může být přehrán příkazem scriptreplay.

Dříve se samozřejmě intenzivně řešila komunikace, rychlost, způsob, protokol. Pokud si nehrajete se zařízeními připojenými na sériový nebo paralelní port, tak to už řešit nemusíte. To rád nechám na operačním systému.

Kdybych měl ncurses přirovnat k nějaké rozšířenější knihovně, tak mne napadá GDK (GIMP Drawing Kit - grafická primitiva), i když nabízí určitou funkcionalitu, kterou má knihovna GTK (GIMP Toolkit - komponenty uživatelského rozhraní - menu, tlačítka, edit box, ..). Část ncurses, která se věnuje komponentám, se označuje jako ncurses forms. Nemám ale pocit, že by se tato část ncurses dnes používala, ale mohu se mýlit. Mně nevyhovuje, protože všechny komponenty dodávané v ncurses jdou mimo standard CUA (Common User Access), který je určitým jednotícím konceptem textových DOS aplikací z přelomu 80 a 90 let (na tom jsem vyrostl). Ale jak jsem zmínil dříve, návrh UI terminálových aplikací musel být úplně jiný z důvodu naprosto odlišné architektury. ncurses se vetšinou používá pro low level operace. Když to hodně zjednoduším - tak primárně jde o zobrazení textu na konkrétní pozici x,y terminálu. Je to poměrně hloupé, nízkoúrovňové, definované možnostmi tehdejších terminálů. Umí to vyčistit určitou oblast na obrazovce, a vrchol funkcionality je vertikální odskrolování části obrazovky.

Když jsem s ncurses začínal, tak jsem narážel na to, že jsem si do určitých funkcí promítal svoje představy, ovlivněné tím, že jsem se napřed setkal s o 15 let novější knihovny. Základní strukturou ncurses je WINDOW. Ani ve snu by mne nenapadlo, že okna se (v ncurses) nesmí překrývat (jinak dochází k vizuálním defektům). V dokumentaci to samozřejmě je, kdo ale čte dokumentaci (napřed). Samozřejmě, že používání knihovny ncurses bude výrazně jednodušší pro člověka, který má zkušenosti s psaním terminálových aplikací a ví, co znamená raw mode, echo, atd. Na Unixech v 80 a 90 letech to musel být denní chleba každého vývojáře.

V momentě, kdy si člověk nastaví správná očekávání, tak už pak práce s ncurses tolik nebolí, pořád ale musí být vývojář dost akurátní. Jeden špatně zobrazený znak může snadno a výrazně rozhodit layout aplikace. Jsou situace, kdy ncurses správně ořízne výstup, a layout se nerozhodí. Nicméně po pár měsících se vám někdo ozve, že v určitých případech je zobrazení rozhozené, a ukáže se, že jednak zobrazujete znaky, které by se zobrazovat nemusely, druhak, že na operačním systému dotyčného je ncurses v 15 let staré verzi, s hromadou neopravených chyb. Tudíž se vyplatí být hodně precizní. Pak zas vaše aplikace funguje prakticky všude - Linux, BSD, MacOS, Solaris, ... Problém s neaktuálními zastaralými verzemi ncurses je docela velký. Někdy se jedná o systémy, které se už nevyvíjí. Jindy možná dodavatelé nechtějí riskovat problémy s kompatibilitou (může se jednat o provoz 30 let starých kritických aplikací), a někdy možná na to pečou. Na Linuxu to není tak velký problém, i když je možné potkat uživatele, kteří mají stále 5 kovou verzi. V roce 2015 se z důvodu podpory více než 256 barev změnilo ABI a číslo řady se změnilo na 6 (to byla jedna z největších změn za posledních 20 let). Thomas vydává nové verze skoro každý týden, ale až na těžké výjimky se jedná jen o bugfixové verze.

Jak tedy vytisknout řetězec na 3 řádek 10 sloupec v terminálu? Musíme si zjistit, jakou escape sekvencí nastavíme kurzor na danou pozici. Tu vytiskneme, a pak vytiskneme text.

#include <stdio.h>

int main()
{
    /* vymaz obrazovku */
    fprintf(stdout, "\033[2J");

    /* presun kurzor */
    fprintf(stdout, "\033[3;10f");

    /* vytiskni znak */
    fprintf(stdout, "AHOJ");

    /* a nesysli si to v cache */
    fflush(stdout);

    /* cekej na stisk klavesy */
    (void) getchar();
}

Tento jednoduchý kód by se dal určitě vylepšit. Pokud si tuto ukázku spustíte, tak zjistíte, tak jako zjistili už dávno před Vámi, že není příjemné, když se na jedné obrazovce míchá výstup řádkově orientovaných aplikací, a celo-obrazovkově orientovaných aplikací (screen oriented). Proto některé terminály podporují (pro tento typ aplikací) tzv alternativní obrazovku (alternate screen). Stačí jen poslat na terminál správnou sekvenci znaků (a neměli bychom zapomenout vytisknout escape sekvenci, která alternativní obrazovku vypne před ukončením aplikace). Pak bychom asi chtěli schovat kurzor, a také bychom raději, aby aplikace nevypisovala znaky, které napíše uživatel, a skončila při prvním stisku klávesy a nečekala na stisk ENTERu.

#include <stdio.h>
#include <termios.h>
#include <unistd.h>

struct termios orig_termios;

int
main()
{
    struct termios current;

    /* uloz aktualni nastaveni terminalu */
    tcgetattr(STDIN_FILENO, &orig_termios);

    /* vypni echo, a necekej na enter */
    tcgetattr(STDIN_FILENO, &current);
    current.c_lflag &= ~(ECHO | ICANON);
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &current);
    
    /* aktivuj alternativni obrazovku */
    fprintf(stdout, "\033[?1049h");

    /* schovej kurzor */
    fprintf(stdout, "\033[?25l");

    /* vymaz obrazovku */
    fprintf(stdout, "\033[2J");

    /* presun kurzor */
    fprintf(stdout, "\033[3;10f");

    /* vytiskni znak */
    fprintf(stdout, "AHOJ");

    /* a nesysli si to v cache */
    fflush(stdout);

    /* cekej na stisk klavesy */
    (void) getchar();

    /* prepni se na standardni obrazovku */
    fprintf(stdout, "\033[?1049l");

    /* zobraz kurzor */
    fprintf(stdout, "\033[?25h");

    /* vrat puvodni nastaveni terminalu */
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_termios);
}

Tahle ta blbůstka mi zabrala víc půl hodiny práce než jsem vygoglil správná API a správné escape sekvence. Kdybych použil ncurses, tak bych si mohl tuhle práci ušetřit, ale to bych zas nevěděl, kolik práce mi ncurses ušetří. Zjevnou nevýhodou kódu, který řídí tisk na terminálu explicitními escape sekvencemi, je jeho nepřehlednost. Ačkoliv většina terminálů je alespoň částečně kompatibilní s VT50, VT100, tak to neplatí na 100% a můj příklad na některých terminálech nemusí fungovat (s některými sekvencemi jsem měl problém i v Gnome terminálu). Tudíž takhle by se terminálové aplikace psát neměly (že se tak občas píší, je věc jiná).

Je jasné, že se vývojáři snažili práci s escape sekvencemi sjednotit a obalit nějakým API. Výsledkem takových snah, byla dvě konkurující si API. Starší termcap a novější (přehlednější, rychlejší) terminfo. ncurses implementuje obě tato API jako svou nízko úrovňovou vrstvu. Některé aplikace si vystačí pouze s touto vrstvou. less a screen používají termcap API. Naopak např. tmux používá terminfo. Také vyšší vrstva ncurses používá terminfo.

#include <stdio.h>
#include <stdlib.h>
#include <termcap.h>
#include <termios.h>
#include <unistd.h>

struct termios orig_termios;

int
main()
{
    struct termios current;
    char buf[1024];

    /* nacti definici terminalu podle sys prom TERM */
    tgetent(buf, getenv("TERM"));

    /* uloz aktualni nastaveni terminalu */
    tcgetattr(STDIN_FILENO, &orig_termios);

    /* vypni echo, a necekej na enter */
    tcgetattr(STDIN_FILENO, &current);
    current.c_lflag &= ~(ECHO | ICANON);
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &current);

    /* aktivuj alternativni obrazovku */
    fputs(tgetstr("ti", NULL), stdout);

    /* schovej kurzor */
    fputs(tgetstr("vi", NULL), stdout);

    /* vymaz obrazovku */
    fputs(tgetstr("cl", NULL), stdout);

    /* presun kurzor */
    fputs(tgoto(tgetstr("cm", NULL), 10, 3), stdout);

    /* vytiskni znak */
    fprintf(stdout, "AHOJ");

    /* a nesysli si to v cache */
    fflush(stdout);

    /* cekej na stisk klavesy */
    (void) getchar();

    /* prepni se na standardni obrazovku */
    fputs(tgetstr("te", NULL), stdout);

    /* zobraz kurzor */
    fputs(tgetstr("ve", NULL), stdout);

    /* vrat puvodni nastaveni terminalu */
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_termios);
}

Přenositelnost kódu je určitě mnohem vyšší, pracnost stejná (nebo možná ještě větší). Alespoň mně dalo mnohem víc práce dohledat správné termcap kódy. API termcap je z konce 70 a začátku 80 let. Tehdy to bezpochyby muselo hodně přinést. Předek ncurses, knihovna curses, používala termcap API. Osobně jsem rád, že tak nízko úrovňová API už nemusím používat, a že mám k dispozici ncurses (od roku 1993, pcurses (free verze curses) je dostupná od roku 1986 (v té době jsem ještě neměl počítač), curses samotné je z roku 1980).

Konečně se dostávám k ncurses.

#include <ncurses.h>

int
main()
{
    /* inicializuj ncurses a aktivuj alternativni obrazovku */
    initscr();

    /* vypnuti echa, radkoveho bufferu, a zneviditelneni kurzoru */
    noecho();
    cbreak();
    curs_set(0);

    /* vymaz obrazovku */
    clear();

    /* na 3 radek 10 sloupec defaultni obrazovky tiskni retezec */
    mvprintw(3, 10, "AHOJ");

    /* zobraz obsah obrazovky na terminal */
    refresh();

    (void) getchar();

    /* uklid */
    endwin();
}

I vůči předchozímu příkladu je použití ncurses neskutečný pokrok. Kód je mnohem čitelnější, a měl jsem ho napsaný za pár minut.

Překlad není také nijak složitý (musíte mít nainstalovaný devel balíček pro knihovnu ncurses).

[pavel@localhost ~]$ gcc test-ncurses.c -lncurses -o test-ncurses
[pavel@localhost ~]$ ./test-ncurses 

Nicméně originální knihovna (curses) se z různých důvodů příliš neujala (co čtu na wiki - tak tam byla určitá funkční omezení, a také tam byly nějaké problémy s licencováním). Došlo k několika reimplementacím. pcurses Pavla Curtise z roku 1982 je přímým předkem ncurses z roku 1993. PDCurses (Public Domain curses) Marka Hesslinga (snad rok 1987) se dodnes používá na jiných než Unix platformách jako je DOS, OS/2, MS Windows konzole (ale také na XWindows nebo SDL). Rozumně napsaná aplikace pro ncurses běží s drobnými úpravami (mají jinak umístěné hlavičkové soubory) i na PDCurses. Je to jedna z cest, jak psát multiplatformní aplikace. I když asi dnes bych si zvolil nový port Turbo Visionu, který vypadá hodně hezky, je rychlý a hlavně podporuje UTF8. Na druhou stranu, někdy se chcete chytřejším frameworkům vyhnout (z různých důvodů - závislosti, rychlost startu, paměťová náročnost). Takže stejně skončíte u ncurses nebo u escape sekvencí.

Výhodou ncurses není jen přehlednější zápis aplikace a pohodlnější programování. ncurses se snaží optimalizovat objem dat posílaných na terminál, a snaží se redukovat problém s nestabilním obrazem (flickering). Komplexnější aplikace jsou většinou napsané tak, že v cyklu překreslí obrazovku, a čekají na interakci uživatele. V ncurses se nezapisujeme přímo na standardní výstup, ale zapisujeme do interního bufferu ncurses, který simuluje virtuální obrazovku. Příkaz refresh porovnává aktuální a předchozí virtuální obraz, a na standardní výstup (a přes něj do terminálu), pošle příkazy, které upraví stávající obsah terminálu do požadovaného nového obsahu. Není to úplně hloupé. Na základě porovnání dvou obsahů je knihovna ncurses schopná samostatně vygenerovat příkazy pro odskrolování obsahu, a potom na terminál poslat pouze tu část obrazovky, která je na terminálu nově. V době hardwarových terminálů to muselo mít velký význam. Svůj význam to může mít i dnes. Když si přes ssh pustím v terminálu ncurses aplikaci, tak tato optimalizace pomáhá.

Navíc tím, že v ncurses se nezapisuje přímo na standardní výstup ale do interního bufferu, který se aktualizuje jedním příkazem, tak se redukuje problém s problikáváním obrazu (flickering). Ten je způsobený tím, že obnova (refresh) obrazovky není sesynchronizována s aplikací. Dodnes terminály (ani ty softwarové) nepodporují double buffering. Pomocí ncurses je double buffering alespoň částečně emulován.