Ncurses: Porovnání verzí

Z PostgreSQL
Skočit na navigaci Skočit na vyhledávání
Bez shrnutí editace
Bez shrnutí editace
Řádek 1: Řádek 1:
[[category:Články]]
[[category:Články]]
= Ncurses =
Autor: [[Pavel Stěhule|Pavel Stěhule]], 7.4.2021
Autor: [[Pavel Stěhule|Pavel Stěhule]], 7.4.2021


Řádek 6: Řádek 7:
bez které bych aplikaci jako je <code>pspg</code> nemohl napsat (asi mohl, ale dalo by mi to výrazně víc práce).
bez které bych aplikaci jako je <code>pspg</code> nemohl napsat (asi mohl, ale dalo by mi to výrazně víc práce).


== Úvod ==
<i>ncurses</i> je knihovna, která poskytuje základní funkcionalitu pro vývoj terminálových aplikací. I když se jedná
<i>ncurses</i> je knihovna, která poskytuje základní funkcionalitu pro vývoj terminálových aplikací. I když se jedná
o novější inkarnaci knihovny <i>curses</i>, má svoje léta. Historie této knihovny spadá do přelomu 70 a 80 let, a
o novější inkarnaci knihovny <i>curses</i>, má svoje léta. Historie této knihovny spadá do přelomu 70 a 80 let, a
Řádek 376: Řádek 378:
s aplikací. Dodnes terminály (ani ty softwarové) nepodporují [https://cs.wikipedia.org/wiki/Mnohon%C3%A1sobn%C3%BD_buffering double buffering]. Pomocí ncurses je double buffering alespoň
s aplikací. Dodnes terminály (ani ty softwarové) nepodporují [https://cs.wikipedia.org/wiki/Mnohon%C3%A1sobn%C3%BD_buffering double buffering]. Pomocí ncurses je double buffering alespoň
částečně emulován.
částečně emulován.
== Zpracování vstupů ==
=== Vstup z tty (inicializace ncurses) ===
Ve velké většině případů se knihovna ncurses inicializuje voláním funkce
<code>initscr</code>. V tomto případě se vstup čte ze standardního vstupu <code>stdin</code>, a výstup
zapisuje na standardní výstup <code>stdout</code>. V případech, kdy jsou tyto soubory
asociovány se zařízením typu <code>tty</code> nebo <code>pty</code>, tak vše bude fungovat jak
očekáváme. Pokud si ale budete chtít napsat pager nebo filter, tak tam narazíte.
Standardní vstup bude výstupem předchozího příkazu v [https://www.pslib.cz/milan.kerslager/BASH:_Kolony koloně] a nikoliv soubor
asociovaný s terminálem.
Když jsem začínal s <code>pspg</code>, tak jsem ncurses neměl zažité, a docela dlouho jsem
vymýšlel, jak tento problém vyřešit. První funkční řešení, které jsem dlouho
používal, byla taková opičárna - po načtení vstupních dat jsem použil funkci
<code>reopen</code> nad standardním vstupem a dostupným terminálovým zařízením:
<pre>
if (!isatty(fileno(stdin)))
{
    if (freopen("/dev/tty", "r", stdin) != NULL)
        noatty = false;
    else if (freopen(ttyname(fileno(stdout)), "r", stdin) != NULL)
        noatty = false;
    else
    {
        /*
        * cannot to reopen terminal device. See discussion to issue #35
        * fallback solution - read keys directly from stderr. Just check
        * it it is possible.
        */
        if (!isatty(fileno(stderr)))
        {
            fprintf(stderr, "missing a access to terminal device\n");
            exit(EXIT_FAILURE);
        }
        noatty = true;
        fclose(stdin);
    }
}
</pre>
Výše uvedený kód na spoustě unixů funguje, ale není to nic hezkého. Určitě není
dobrý (a obvyklý) nápad otevírat standardní vstup v běžné aplikaci. To je práce
pro shell. Navíc je to úplně zbytečné. Přímo v ncurses máme jednoduché a
elegantní řešení. Nejdříve si člověk musí uvědomit, že ncurses "jen" čte ze
souboru nebo zapisuje do souboru. Typicky těmi soubory je <code>stdin</code> a <code>stdout</code>, ale
mělo by to fungovat pro všechny soubory. Jen zjistit jak (stačilo si přečíst
manuálovou stránku pro <code>initscr</code>, ale kdo čte dokumentaci).
Fígl je v inicializaci ncurses funkcí <code>newterm</code> namísto <code>initscr</code>:
<pre>
#include <ncurses.h>
#include <stdlib.h>
#include <unistd.h>
int
main()
{
    FILE      *f_tty = NULL;
    SCREEN    *term = NULL;
#ifndef __APPLE__
    f_tty = fopen("/dev/tty", "r+");
#endif
    if (!f_tty)
    {
        f_tty = fopen(ttyname(fileno(stdout)), "r");
        if (!f_tty && isatty(fileno(stderr)))
            f_tty = stderr;
    }
    if (!f_tty)
    {
        fprintf(stderr, "cannot to open tty device\n");
        exit(1);
    }
    term = newterm(termname(), stdout, f_tty);
    if (!term)
    {
        fprintf(stderr, "cannot to initialize ncurses screen\n");
        exit(1);
    }
    mvprintw(10, 10, "press ENTER");
    getch();
    endwin();
    delscreen(term);
}
</pre>
Bohužel na macOS je implementace <code>/dev/tty</code> polofunkční, což jsem zjistil i já
(nedá se použít ve funkci <code>poll</code>), a nedoporučuje se moc používat (tato chyba je
známá minimálně od roku 2005).
ncurses umožňuje v rámci jedné aplikace vytvoření a používání více terminálů.
Voláním funkce <code>set_term</code> mohu pak volit jeden aktivní. Nenašel jsem žádnou
aplikaci, nad ncurses, která by toto umožňovala. Například <code>emacs</code> podobný režim
podporuje.
=== Ladění, ladící výstup ===
Když jsem začínal s ncurses, tak jsem hodně bojoval s laděním aplikace. Jelikož
je standardní výstup <code>stdout</code> stejně jako standardní chybový výstup <code>stderr</code>
směrován na stejné tty zařízení, tak jakýkoliv pokus o tisk na <code>stderr</code> rozhodí
layout aplikace. Nakonec jsem skončil u explicitně vytvořené pojmenované roury,
kterou používám pro ladící výpisy. Pokud spustím <code>pspg</code> v debug režimu, tak si
tuto rouru otevřu a tisknu do ní. Šlo by to ale i jednodušeji - systémověji.
Otevřu si další terminál, a příkazem <code>tty</code> si vypíšu cestu k souboru terminálu.
Poté při spuštění aplikace přesměruji chybový výstup do tohoto souboru:
<pre>sh
[pavel@localhost ~]$ tty
/dev/pts/8
</pre>
V jiném terminálu:
<pre>sh
mojeapp ... 2>/dev/pts/8
</pre>
V aplikaci pak mohu používat <code>stderr</code> bez omezení.
=== Vstup znaků (vstup širokých znaků) ===
Za nenápadnou funkcí <code>getch</code> se skrývá hodně funkcionality. Tato funkce nevrací
jen kód aktuálně stisknuté klávesy, ale řeší dekódování vstupních escape
sekvencí, řeší ošetření signálu [https://www.root.cz/clanky/terminaly-tajemstvi-zbavene-procesy-a-signaly/ <code>SIGWINCH</code>] (změna velikosti terminálu), a v
kombinaci s nastavením timeoutu (funkce <code>timeout</code>) umožňuje volbu blokujícího
nebo neblokujícího čtení. Dnes už je tato funkce překonaná, protože nepodporuje
vstup širokých znaků (u nás například znaků s diakritikou v UTF). Místo této
funkce by se měla používat funkce <code>get_wch</code>. Ta vyžaduje kompilaci a linkování
vůči wide charové verzi ncurses <code>ncursesw</code>. Pokud nezbytně nutně nepotřebujete
ošetřovat široké znaky, tak můžete použít <code>#ifdef</code>:
Historicky bylo možné nastavit numerickou klávesnici (keypad) do dvou režimů.
V numerickém vracela čísla, v aplikačním pak escape sekvence odpovídající kurzorovým
šipkám. Možná touto funkcionalitou se inspirovali autoři ncurses, když psali funkci
<code>keypad</code>, která mění konfiguraci obsluhy funkčních kláves. Ve výchozím nastavení
<code>keypad(FALSE)</code> se přijatá posloupnost escape znaků <code>^[ [ A</code> posílá aplikaci jako
tři přijaté znaky. Pokud nastavíme <code>keypad(TRUE)</code>, pak zmíněnou escape sekvenci
ncurses rozpozná a nahradí svým kódem <code>KEY_UP</code>.
<pre>
/*
* chceme podporu sirokych znaku, obycejne se
* nastavi autoconfem. Musi byt nastaveno pred
* include ncurses.h
*/
#define NCURSES_WIDECHAR    1
#include <ncurses.h>
#include <locale.h>
static bool
_getch(int *c)
{
#if NCURSES_WIDECHAR > 0
    wint_t  ch;
    int    ret;
    ret = get_wch(&ch);
    *c = ch;
    return ret == KEY_CODE_YES;
#else
    /*
    * V ASCII verzi nemohlo dojit k prekryti kodu znaku
    * a specialnich kodu ncurses (jako KEY_UP, ..).
    */
    *c = getch();
    return true;
#endif
}
int
main()
{
    int    eventno = 0;
    int    c;
    bool    is_key_code;
    setlocale(LC_ALL, "");
    initscr();
    /* necekej na ENTER */
    cbreak();
    /* neviditelny kurzor */
    curs_set(0);
    /* neviditelne znaky pri psani */
    noecho();
    /* chci aby hlavni obrazovka skrolovala */
    scrollok(stdscr, TRUE);
    /*
    * bez aktivniho keypad modu nedochazi k dekodovani escape sekvenci,
    * kurzorove klavesy, funkcni klavesy, ...
    */
    keypad(stdscr, TRUE);
    move(0,0);
    is_key_code = _getch(&c);
    while (c != 'q')
    {
        if (is_key_code)
            printw("%4d: key: %s\n", ++eventno, keyname(c));
        else
            printw("%4d: c: %lc (%d)\n", ++eventno, c, c);
        refresh();
        is_key_code = _getch(&c);
    }
    endwin();
}
</pre>
=== Funkční klávesy ===
Práce s funkčními klávesami je jednoduchá - pomocí makra <code>KEY_F</code> můžeme jednoduše
vygenerovat kódy pro jednotlivé funkční klávesy. Pokud při stisku funkční klávesy
stiskneme <kbd>SHIFT</kbd>, tak pracujeme jakoby s druhou řadou funkčních kláves
<kbd>F13</kbd> - <kbd>F24</kbd>. Tady pak dochází k jakési schíze - např. pro
kombinaci kláves, kterou označujeme jako <kbd>SHIFT</kbd>+<kbd>F3</kbd>,
používáme kód <code>KEY_F(15)</code>.
Přiznám se, že netuším, jestli dále popisované chování je standard nebo pouze
specifikum <code>mc</code> midnight commandera. Stisk funkčních kláves může být emulován
stiskem klávesy <kbd>ALT</kbd> a číslo nebo posloupností kláves <kbd>ESCAPE</kbd>
a číslo. Toto chování ncurses neimplementuje, ale je možné je implementovat
aplikačně (je to například implementované v <code>pspg</code>, protože mi to přijde jako
šikovný nápad).
<pre>
#include <ncurses.h>
int
main()
{
    int    eventno = 0;
    int    c;
    bool    alt = false;
    initscr();
    cbreak();
    curs_set(0);
    noecho();
    scrollok(stdscr, TRUE);
    keypad(stdscr, TRUE);
    move(0,0);
    c = getch();
    while (c != 'q')
    {
        switch (c)
        {
            case 27:    /* ESCAPE */
                if (alt)
                {
                    alt = false
                    addstr("zadost o ESCAPE\n");
                }
                else
                    alt = true;
                break;
            case '2':
                if (alt)
                    addstr("stisknuta klaveska F2\n");
                break;
            case KEY_F(2):
                addstr("stisknuta klavesa F2\n");
                break;
            case KEY_F(14):
                addstr("stisknuta klavesa SHIFT+F2\n");
                break;
            default:
                addstr("stisknuto neco jineho\n");
        }
        if (c != 27)
            alt = false;
        refresh();
        c = getch();
    }
    endwin();
}
</pre>
=== Speciální klávesy ===
Pro základní speciální klávesy má ncurses definované speciální kódy jako např.
<code>KEY_UP</code>, <code>KEY_DOWN</code> atd. Pokud chceme detekovat stisk těchto kláves zároveň se
stisknutou klávesou <kbd>CTRL</kbd> případně ještě se <kbd>SHIFT</kbd>em, tak už
je nutné dynamicky získat kód příslušné kombinace kláves. Nejdříve si musíme
sestavit [https://man7.org/linux/man-pages/man5/user_caps.5.html identifikátor kombinace kláves]. Například
<code>kEND6</code> je kombinace kláves <kbd>CONTROL</kbd>+<kbd>SHIFT</kbd>+<kbd>END</kbd>. K
tomuto identifikátoru si můžeme vytáhnout z databáze terminfo odpovídající
sekvenci, a následně si voláním funkce <code>key_defined</code> můžeme zjistit kód
definovaný pro tuto sekvenci znaků. Jelikož zmíněné funkce patří k rozšíření
ncurses, je praktické volání těchto funkcí vložit do <code>#ifdef</code> a počítat s
náhradním řešením, když tyto funkce nebudou dostupné. Na rozšířených platformách
jsou tyto kódy stejné.
<pre>
static int
get_code(const char *capname, int fallback)
{
#ifdef NCURSES_EXT_FUNCS
    char    *s;
    int    result;
    s = tigetstr((NCURSES_CONST char *) capname);
    if (s == NULL || s == (char *) -1)
        return fallback;
    result = key_defined(s);
    return result > 0 ? result : fallback;
#else
    return fallback;
#endif
}
/*
* Set a value of CTRL_HOME and CTRL_END key codes. These codes
* can be redefined on some plaforms.
*/
void
initialize_special_keycodes()
{
#ifdef NCURSES_EXT_FUNCS
    use_extended_names(TRUE);
#endif
    CTRL_HOME = get_code("kHOM5", 538);
    CTRL_END = get_code("kEND5", 533);
    CTRL_SHIFT_HOME = get_code("kHOM6", 537);
    CTRL_SHIFT_END = get_code("kEND6", 532);
}
</pre>
=== ALT ===
Kombinace kláves <kbd>CONTROL</kbd> + znak, je mapována do intervalu 1..27. Takže, když chci
detekovat stisk <kbd>CTRL</kbd> + <kbd>O</kbd>, tak se podívám do ASCII tabulky, kde dohledám, že tato
kombinace kláves má kód 15. S <kbd>ALT</kbd>em to funguje úplně jinak. Při stisku kláves <kbd>ALT</kbd>
+ <kbd>O</kbd>, tak ncurses vygenerují dva kódy: ESCAPE (27) a O. V aplikačním kódu je nutné
tuto dvojici detekovat. V <code>pspg</code> volám <code>get_wch</code> v samostatném cyklu, který mi
umožní opakované volání, pokud získaná událost není validní nebo je ESCAPE. Tím
jak je to navržené a implementované, tak místo klávesy <kbd>ALT</kbd> můžeme používat
klávesu <kbd>ESCAPE</kbd> (jen jde o posloupnost kláves, nikoliv o současné stisknutí, tj
<kbd>ALT</kbd> + <kbd>O</kbd> = <kbd>ESCAPE</kbd>, <kbd>O</kbd>). ESCAPE, ve smyslu přerušení nějaké operace (klasický MSDOS
ESCAPE), je definován dvojím stisknutím klávesy <kbd>ESCAPE</kbd> (prvním stiskem, jak už
bylo řečeno, se přepínám do alternativní klávesnice). Kombinace kláves <kbd>CTRL</kbd>+<kbd>ALT</kbd>+<kbd>O</kbd>
vygeneruje kódy ESCAPE a 15 (^O).
<kbd>ALT</kbd>, potažmo <kbd>ESCAPE</kbd> se také může používat jako náhrada funkčních kláves <kbd>F1</kbd> až
<kbd>F10</kbd>. To se hodí, když vám některé funkční klávesy "sežere" terminál. Například
<kbd>F10</kbd>aktivuji menu v Gnome Terminálu, a tudíž stisk <kbd>F10</kbd> se k aplikaci, která mi
běží v terminálu nedostane. Pokud to aplikace implementuje (je to aplikační
záležitost, která musí obsluhovat <kbd>ALT</kbd>+<kbd>0</kbd>), tak mohu zkusit alternativu <kbd>ALT</kbd>+<kbd>0</kbd>
nebo ekvivalent <kbd>ESCAPE</kbd>, <kbd>0</kbd>.
=== Použití knihovny readline ===
Občas může být potřeba editovat řetězec. V ncurses máme funkci <code>getstr</code>. Určitě
tím uživatelům umožníte editaci, ale asi je neoslníte. Editace je dost
primitivní. Nefungují kurzorové klávesy, mazaní jde jedině zprava backspacem.
Když to porovnáte z možnostmi, které máme dnes v bashi, tak to zamrzí. A rovnou
vás napadne, proč nepoužít tu samou knihovnu <code>readline</code>. Při podrobnějším
zkoumání se ukáže, že to není až tak jednoduché. Knihovna <code>readline</code> je navržená
pro použití v REPL aplikacích, nikoliv v celoobrazovkových aplikacích. Nicméně
obsahuje funkce, které umožňují používat tuto knihovnu, aniž by měla přímý
přístup ke vstupu a výstupu (alternative callback interface). Vůbec netuším, proč
je toto API tak zbytečně složitě navržené (ale je fakt, že o této knihovně skoro
nic nevím):
<pre>
/* For wcwidth() */
#define _XOPEN_SOURCE 700
#include <locale.h>
#include <ncurses.h>
#include <readline/readline.h>
#include <stdlib.h>
#include <string.h>
#include <wchar.h>
static int      readline_proxy;
static bool    readline_proxy_is_valid = false;
char  *string = NULL;
/*
* Zkopiruje znak z proxy do readline
*/
static int
readline_getc(FILE  *dummy)
{
    readline_proxy_is_valid = false;
    return readline_proxy;
}
static int
readline_input_avail(void)
{
    return readline_proxy_is_valid;
}
/*
* Touto funkci readline predava vysledek editace
*/
static void
readline_callback(char *line)
{
    free(string);
    string = NULL;
    if (line)
        string = strdup(line);
}
/*
* Chceme mit zobrazeni ve vlastni rezii
*/
static void
readline_redisplay()
{
    /* do nothing here */
}
/*
* Vrati (zobrazovaci) sirku retezce.
*/
static size_t
strnwidth(const char *s, size_t n)
{
    mbstate_t shift_state;
    wchar_t wc;
    size_t wc_len;
    size_t width = 0;
    size_t ch_width;
    memset(&shift_state, '\0', sizeof shift_state);
    for (size_t i = 0; i < n; i += wc_len)
    {
        wc_len = mbrtowc(&wc, s + i, MB_CUR_MAX, &shift_state);
        if (!wc_len)
            return width;
        if ((wc_len == (size_t) -1) || (wc_len == (size_t) -2))
            return width + strnlen(s + i, n - i);
        if (iswcntrl(wc))
            width += 2;
        else if ((ch_width = wcwidth(wc)) > 0)
            width += ch_width;
    }
    return width;
}
int
main()
{
    int    c = 0;
    bool    alt = false;
    setlocale(LC_ALL, "");
    initscr();
    cbreak();
    noecho();
    /* ENTER neni \n */
    nonl();
    /*
    * readline vyzaduje neprekodovany vstup tj
    * vypnuty keypad a cteni vice bajtovych
    * znaku po bajtech (emuluje se binarni
    * cteni ze souboru v aktualnim kodovani)
    */
    keypad(stdscr, FALSE);
    /*
    * Instalace hooku - pokud se pouzije rl_getc_function,
    * tak by se mel VZDY pouzit i rl_input_available_hook.
    */
    rl_getc_function = readline_getc;
    rl_input_available_hook = readline_input_avail;
    rl_redisplay_function = readline_redisplay;
    /*
    * Nechceme obsluhu signalu v readline, a nechceme tab
    * complete (neni nakonfigurovano, hrozi pady.
    */
    rl_catch_signals = 0;
    rl_catch_sigwinch = 0;
    rl_inhibit_completion = 0;
    /* Zahajeni editace */
    rl_callback_handler_install(": ", readline_callback);
    /* Vlozi vychozi (default) text */
    rl_insert_text("Editaci ukonci 2x stisk ESCAPE");
    while (1)
    {
        int    cursor_pos;
        clear();
        mvaddstr(10, 10, rl_display_prompt);
        addstr(rl_line_buffer);
        if (string)
        {
            mvaddstr(12, 6, "text: ");
            attron(A_REVERSE);
            addstr(string);
            attroff(A_REVERSE);
        }
        /* nastav kurzor */
        cursor_pos = strnwidth(rl_display_prompt, SIZE_MAX) +
                    strnwidth(rl_line_buffer, rl_point);
        move(10, 10 + cursor_pos);
        refresh();
        c = getch();
        /* ignoruj tabelatory */
        if (c == '\t')
            continue;
        if (c == 27)
        {
            if (alt)
                break;
            else
                alt = true;
        }
        else
            alt = false;
        readline_proxy = c;
        readline_proxy_is_valid = true;
        /* posli echo readline, ze jsou nova data k precteni */
        rl_callback_read_char();
    }
    rl_callback_handler_remove();
    endwin();
}
</pre>
S mírnými úpravami byl výše uvedený příklad přeložitelný i s knihovnou
<code>editline</code>, která by mohla být zajímavou alternativou zejména díky BSD licenci.
Tato knihovna implementuje podmnožinu API knihovny <code>readline</code>, nicméně zrovna
callback API nefunguje dobře. (A stávající správce mi napsal, že ho to vůbec
nepřekvapuje). Proměnná <code>rl_point</code> není aktualizována a stále obsahuje 0 (což je
evidentně chyba knihovny). Podařilo se mi zadat i českou diakritiku, ale bylo
potřeba knihovně předávat široké znaky (a s těmi zase nefungovala knihovna
<code>readline</code>). U <code>editline</code> není podporován hook <code>rl_input_available_hook</code> (a ani
není nutný, jelikož místo více bajtových znaků knihovna vyžaduje široké znaky). U
<code>readline</code> je jeho použití nutnost (jinak dojde k zacyklení při zadání více
bajtového znaku).
Všimněte si, že zde již používám "pokročilejší" posloupnost operací - napřed
zobrazím obsah, a teprve potom čekám na stisk klávesy. Jednoduché ukázky, a
školní příklady většinou mají tyto operace prohozené. Začíná se stiskem klávesy,
a pak podle stisknuté klávesy se kreslí výstup. Zřejmou nevýhodou je prázdná
obrazovka při prvním čekání na stisk klávesy.
Při práci s knihovnou readline jsem se setkal ještě s jedním docela "zákeřným"
problémem. readline vyžaduje vypnutý keypad režim. Naopak v normální aplikaci
budeme asi chtít mít keypad zapnutý. Jelikož se v ncurses keypad nastavuje na
okno, tak jsem na standardním okně <code>stdscr</code> keypad povolil, a v okně, které jsem
používal pro vstup, jsem měl keypad vypnutý. Skoro to fungovalo, až na případy,
kdy první událost byl stisk kurzoru (čehokoliv, co funguje pouze se zapnutým
keypadem). Tady se ukázala další vlastnost ncurses. Strukturu <code>WINDOW</code> do jisté
míry můžeme chápat i jako konfiguraci terminálu. V ncurses vždy "čteme" data z
okna. Nic takového reálně neexistuje, data čteme z tty. Okna jsou pouze abstrakce
ncurses. Při čtení se nastaví příslušná konfigurace (pro dané okno). Pokud je
viditelný kurzor nebo echo, tak se nastaví kurzor a čeká se na znak. Po získání
znaku se nastavuje konfigurace hlavního okna (si myslím).
Pokud používáte funkci [https://man7.org/linux/man-pages/man2/poll.2.html <code>poll<code>] nebo <code>select</code> (pokud se na událost čeká ve vašem
kódu), tak skoro vždy událost v terminálu vzniká ještě před tím, než ncurses
nakonfiguruje terminál podle konfigurace odpovídající použitému oknu), a může se
stát, že událost neodpovídá požadované konfiguraci (požadovanému oknu). Nejedná
se o chybu. Je to vlastnost architektury. Když plánujete psát komplexnější
terminálovou aplikaci, tak nejlepším začátkem je, si nastudovat možnosti a funkce
nějakého jednoduššího terminálu (VT52 nebo VT100) a vaši aplikaci psát jakoby pro
tento terminál - externí krabici s klávesnicí připojenou na sériový port
počítače. Případně jít ještě dál. Pracovat s mentálním modelem: "čtu data ze
souboru, který mi krmí klávesnice, a zapisuji do souboru, kterým krmím tiskárnu".
Pro vývojáře, který v životě nepracoval na fyzickém terminálu to není moc
přirozené. Na druhou stranu, tato architektura má něco do sebe. Jednoduše a
hlavně naprosto přirozeně umožňuje vzdálenou správu, jednoduše umožňuje
zanořování aplikací - např. aplikace jako <code>screen</code>, <code>tmux</code> atd jsou díky tomu v
podstatě velice jednoduše realizovatelné aplikace.
=== Používání myši ===
Implementace obsluhy událostí generovaných myší v ncurses neměla nejlepší pověst.
Narazil jsem na pár aplikací, které ačkoliv používaly ncurses, tak myš
obsluhovaly pomocí knihovny <code>gpm</code>. Já jsem u <code>pspg</code> zůstal s obsluhou myši v
ncurses, ale něco málo jsem si musel přeprogramovat (double click), a pro režim,
kdy pohybuji s myší a zároveň držím tlačítko, jsem musel použít rozšířený režim,
který není nativně podporován ncurses (musím ho zapínat, vypínat escape
sekvencí).
Události, které generuje myš (pohyb, stisknutí tlačítka, rotace kolečka),
primárně zpracovává terminál (emulátor terminálu). Podle aktuálního režimu na
tuto událost sám zareaguje (v primární obrazovce (primary screen) zobrazením
historie) nebo vygeneruje escape sekvenci, kterou pošle aplikaci (v alternativní
obrazovce). Tady za nás ncurses udělá docela dost práce - escape sekvenci převede
na událost <code>KEY_MOUSE</code>, kterou získám funkci <code>getch</code> a připraví datovou strukturu
<code>MEVENT</code> (obsahuje polohu kurzoru myši, a bitmapu s kombinací stisknutých
tlačítek myši a vybraných kláves na klávesnici). <code>MEVENT</code> získáme voláním funkce
<code>getmouse</code>. U této funkce je dobré kontrolovat vrácenou hodnotu. Stalo se mi, že
ncurses špatně detekoval událost, a teprve při dekódování celé escape sekvence,
se zjistilo, že poslední událost byla zmatečná. Funkce <code>getmouse</code> také může
vrátit <code>ERR</code> pokud k získání události používám <code>wgetch</code>, přičemž kurzor myši byl
mimo dané okno. Souřadnice kurzoru jsou vždy vztažené k celé obrazovce. Voláním
funkce <code>wenclose</code> můžeme zjistit, zdali byl kurzor myši uvnitř okna, a použitím
funkce <code>wmouse_trafo</code> získáme souřadnice kurzoru vztažené k počátku zadaného
okna.
Starší ncurses ještě nepodporovaly kolečko na myši, a bohužel se ještě s touto
verzí ncurses často potkávám. Tudíž vždy je nutné používat <code>#ifdef NCURSES_MOUSE_VERSION > 1</code>,
pokud testujete tlačítka 4 a 5 (což odpovídá pohybu kolečka od sebe nebo k sobě).
Je to například problém RHEL7 s ncurses 5.9 z roku 2014. A firem, které jsou
ještě na této verzi RHELu je pořád dost.
<pre>
#include <ncurses.h>
int
main()
{
    int    eventno = 0;
    int    c;
    initscr();
    cbreak();
    curs_set(0);
    noecho();
    scrollok(stdscr, TRUE);
    /*
    * bez aktivniho keypad modu nedochazi k dekodovani
    * vstupnich escape sekvenci, vcetne mysi, musi byt TRUE
    */
    keypad(stdscr, TRUE);
    move(0,0);
    mousemask(BUTTON1_PRESSED |
              BUTTON1_RELEASED |
              BUTTON1_CLICKED |
              BUTTON1_DOUBLE_CLICKED
#if NCURSES_MOUSE_VERSION > 1
              | BUTTON4_PRESSED
              | BUTTON5_PRESSED
#endif
            , NULL);
    c = getch();
    while (c != 'q')
    {
        if (c == KEY_MOUSE)
        {
            MEVENT      mevent;
            int        res;
            if (getmouse(&mevent) == OK)
            {
                printw("%4d: key: %s, y: %d, x: %d",
                        ++eventno,
                        keyname(c),
                        mevent.y,
                        mevent.x);
                /*
                * tam, kde to neni nutne se nedoporucuje pouzivat
                * funkci print, ktera je vyrazne narocnejsi na CPU
                */
                if (mevent.bstate & BUTTON1_PRESSED)
                    addstr(", BUTTON1_PRESSED");
                if (mevent.bstate & BUTTON1_RELEASED)
                    addstr(", BUTTON1_RELEASED");
                if (mevent.bstate & BUTTON1_CLICKED)
                    addstr(", BUTTON1_CLICKED");
                if (mevent.bstate & BUTTON1_DOUBLE_CLICKED)
                    addstr(", BUTTON1_DOUBLE_CLICKED");
#if NCURSES_MOUSE_VERSION > 1
                if (mevent.bstate & BUTTON4_PRESSED)
                    addstr(", BUTTON4_PRESSED");
                if (mevent.bstate & BUTTON5_PRESSED)
                    addstr(", BUTTON5_PRESSED");
#endif
                addch('\n');
            }
            else
                addstr("broken mouse event\n");
        }
        else
            printw("%4d: key: %s\n", ++eventno, keyname(c));
        refresh();
        c = getch();
    }
    endwin();
}
</pre>
Při zpracování události od myši ncurses čeká ve výchozím nastavení 250 ms
(nastavuje se funkcí <code>mouseinterval</code>) na případnou další událost od myši, a podle
toho jestli přijde nebo nepřijde nastaví flag <code>PRESSED</code> nebo <code>CLICKED</code>. Mne to v
<code>pspg</code> způsobovalo nepříjemné jakoby opožděné reakce na myš. Přeci jen 250ms už
si všimnete. Takže jsem si nastavil <code>mouseinterval(0)</code>, čímž jsem vyblokoval
generování flagu <code>CLICKED</code>, a na flag <code>PRESSED</code> jsem napojil získání fokusu, a na
flag <code>RELEASED</code> pak provedení akce. Tím <code>pspg</code> uživatele nenutí mít příliš rychlé
prsty, a zároveň ovládání <code>pspg</code> není "líné"
V <code>mc</code> se mi líbí, že uživatel může pohybovat myší, a držením levého tlačítka mít
neustále zvýrazněný fokus. Dost dlouho mi trvalo, jak toho v ncurses docílit.
Sice si mohu nastavit masku myši <code>REPORT_MOUSE_POSITION</code>, ale to samo od sebe nic
neudělá. Je potřeba na terminálu vybrat některý z protokolů pro sledování myši
"xterm Mouse Tracking", a pro tento účel mi vyhovoval protokol označený číslem
1002. Zapíná se escape sekvencí <code>\033[?1002h</code> a vypíná <code>\033[?1002l</code> (flag
<code>REPORT_MOUSE_POSITION</code> musí být aktivní).
=== Velikost terminálu ===
Nejstarší terminály měly fixní konstantní velikost danou typem terminálu. Na
základě údajů ze své databáze vlastností jednotlivých terminálů pak ncurses
nastaví proměnné <code>LINES</code> a <code>COLS</code>. U terminálů, které jsou emulovány softwarově
tento přístup není možný. Jejich velikost je dynamická, a proto by pseudoterminál
při své inicializaci měl nastavit systémové proměnné <code>$LINES</code> a <code>$COLUMNS</code>,
odkud hodnoty převezme ncurses, které nestaví své proměnné <code>LINES</code> a <code>COLS</code>. U
softwarově emulovaných terminálů běžně dochází ke změně velikosti i v průběhu
běhu aplikací,  tudíž by terminálová aplikace měla ošetřit signál <code>SIGWINCH</code>,
který je generován terminálem při změně velikosti.
Zde nastávají první komplikace, kdy některé aplikace neošetřují vše, co by měly.
Mějme posloupnost aplikací  <code>bash</code>, <code>psql</code> a <code>pspg</code>. Pokud by <code>bash</code> neošetřoval
<code>SIGWINCH</code>, tak se může stát, že systémové proměnné <code>$LINES</code> a <code>$COLUMNS</code> nebudou
obsahovat aktuální hodnoty. Zrovna tak se může stát, že ačkoliv <code>pspg</code> správně
ošetřuje <code>SIGWINCH</code>, tak nedokáže nastavit zmíněné systémové proměnné vnějšímu
procesu, a po ukončení <code>pspg</code> jsou tyto proměnné opět neaktuální. Chování ncurses
můžeme ovlivnit voláním konfiguračních funkcí <code>use_env</code> a <code>use_tioctl</code>. V
závislosti na konfiguraci ncurses bere v potaz systémové proměnné <code>$LINES</code> a
<code>$COLUMNS</code>, případně je ignoruje a velikost terminálu si zjišťuje samotná
knihovna.
V moderních systémech a v moderních ncurses se můžete spolehnout na vestavěnou (v
ncurses) obsluhu signálu, a na obsah proměnných <code>LINES</code> a <code>COLS</code> (po změně
velikosti aplikace dostane událost <code>KEY_RESIZE</code>. U starších systémů jsem se
setkal s tím, že tyto proměnné při startu aplikace, případně po změně velikosti
okna nebyly aktuální, a musel jsem si je načíst sám. Jinak <code>KEY_RESIZE</code> funguje
asi všude. Správné automatické nastavení <code>LINES</code> a <code>COLS</code> funguje tak na 99.9%
(ale je možné, že bych si možná pomohl laděním <code>use_env</code> a <code>use_tioctl</code>, a možná
také ne. <code>ioctl</code> mi fungovalo všude):
<pre>
#include <stdio.h>
#include <stdlib.h>
#include <ncurses.h>
int
main()
{
    int    c = ' ';
    initscr();
    noecho();
    cbreak();
    curs_set(0);
    while (c != 'q')
    {
        int lines, cols;
        mvprintw(9, 10, "keyname: %s", keyname(c));
        if (c == KEY_RESIZE)
        {
            /* velikost terminalu */
            mvprintw(10, 10, "terminal: %d, %d", LINES, COLS);
            /* velikost hlavniho okna */
            getmaxyx(stdscr, lines, cols);
            mvprintw(11, 10, "stdscr: %d, %d", lines, cols);
        }
        refresh();
        c = getch();
    }
    endwin();
    exit(EXIT_SUCCESS);
}
</pre>
V případě, že si sami ošetřujete signál <code>SIGWINCH</code>, je nutné o změně velikosti
terminálu informovat ncurses:
<pre>
#include <stdio.h>
#include <stdlib.h>
#include <ncurses.h>
#include <signal.h>
#include <sys/ioctl.h>
#include <termios.h>
static bool handle_sigwinch = false;
static void
SigwinchHandler(int sig_num)
{
    struct winsize size;
    if (ioctl(fileno(stdout), TIOCGWINSZ, (char *) &size) >= 0)
        resize_term(size.ws_row, size.ws_col);
    handle_sigwinch = true;
    signal(SIGWINCH, SigwinchHandler);
}
int
main()
{
    int    c = ' ';
    initscr();
    noecho();
    cbreak();
    curs_set(0);
    /* nastavi timeout pro funkci getch() */
    timeout(250);
    /*
    * tim, ze nastavuji signal po initscr, si
    * vynucuji vlastni obsluhu signalu
    */
    signal(SIGWINCH, SigwinchHandler);
    while (c != 'q')
    {
        int lines, cols;
        if (handle_sigwinch)
        {
            struct winsize size;
            handle_sigwinch = false;
            if (ioctl(fileno(stdout), TIOCGWINSZ, (char *) &size) >= 0)
                resize_term(size.ws_row, size.ws_col);
        }
        /* pokud neni timeout */
        if (c != -1)
        {
            mvprintw(9, 10, "keyname: %s", keyname(c));
            clrtoeol();
        }
        /* velikost terminalu */
        mvprintw(10, 10, "terminal: %d, %d    ", LINES, COLS);
        /* velikost hlavniho okna */
        getmaxyx(stdscr, lines, cols);
        mvprintw(11, 10, "stdscr: %d, %d    ", lines, cols);
        refresh();
        /* maximalne po 250 ms skonci na timeout */
        c = getch();
    }
    endwin();
    exit(EXIT_SUCCESS);
}
</pre>
Jelikož jsem přepsal ncurses obsluhu signálu, tak resize terminálu nepřeruší
provádění funkce <code>getch</code> (signál spolkla moje aplikace, nikoliv ncurses), a abych
zachoval určitou interaktivitu, tak nastavuji timeout 250 ms. Všimněte si.
Aktuální velikost terminálu získám voláním funkce <code>ioctl</code>, a volám funkci
<code>resize_term</code>. Uvnitř této funkce se přepočítají (a přealokují) okna a nastaví
proměnné <code>LINES</code> a <code>COLS</code>. Tato funkce by se neměla volat uvnitř obsluhy signálu.
U jednodušších aplikací, když se potřebujeme vyhnout dlouhém blokujícímu čekání
na klávesnici, tak si můžeme pomoct nastavením timeoutu. Pozor ale, příliš krátký
timeout zbytečně zatěžuje CPU (a je trend od používání krátkých timeoutů
ustupovat, tak aby nedocházelo ke zbytečnému uspávání a probouzení procesu),
Poznámka: ačkoliv se tato funkce jmenuje <code>resize_term</code> neznamená to, že s ní
můžete změnit velikost terminálu. Touto funkcí si vynutíte aktualizaci datových
struktur v ncurses, které jsou nějakým způsobem navázané na velikost terminálu.
Změnu velikosti terminálu si lze vynutit escape sekvencemi (na některých
terminálech, určitě ne všude). Například pro nastavení terminálu na velikost
100x50 znaků je sekvence <code>\e[8;50;100t<code>

Verze z 11. 9. 2021, 07:05

Ncurses

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).

Úvod

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.

Zpracování vstupů

Vstup z tty (inicializace ncurses)

Ve velké většině případů se knihovna ncurses inicializuje voláním funkce initscr. V tomto případě se vstup čte ze standardního vstupu stdin, a výstup zapisuje na standardní výstup stdout. V případech, kdy jsou tyto soubory asociovány se zařízením typu tty nebo pty, tak vše bude fungovat jak očekáváme. Pokud si ale budete chtít napsat pager nebo filter, tak tam narazíte. Standardní vstup bude výstupem předchozího příkazu v koloně a nikoliv soubor asociovaný s terminálem.

Když jsem začínal s pspg, tak jsem ncurses neměl zažité, a docela dlouho jsem vymýšlel, jak tento problém vyřešit. První funkční řešení, které jsem dlouho používal, byla taková opičárna - po načtení vstupních dat jsem použil funkci reopen nad standardním vstupem a dostupným terminálovým zařízením:

if (!isatty(fileno(stdin)))
{
    if (freopen("/dev/tty", "r", stdin) != NULL)
        noatty = false;
    else if (freopen(ttyname(fileno(stdout)), "r", stdin) != NULL)
        noatty = false;
    else
    {
        /*
         * cannot to reopen terminal device. See discussion to issue #35
         * fallback solution - read keys directly from stderr. Just check
         * it it is possible.
         */
        if (!isatty(fileno(stderr)))
        {
            fprintf(stderr, "missing a access to terminal device\n");
            exit(EXIT_FAILURE);
        }
        noatty = true;
        fclose(stdin);
    }
}

Výše uvedený kód na spoustě unixů funguje, ale není to nic hezkého. Určitě není dobrý (a obvyklý) nápad otevírat standardní vstup v běžné aplikaci. To je práce pro shell. Navíc je to úplně zbytečné. Přímo v ncurses máme jednoduché a elegantní řešení. Nejdříve si člověk musí uvědomit, že ncurses "jen" čte ze souboru nebo zapisuje do souboru. Typicky těmi soubory je stdin a stdout, ale mělo by to fungovat pro všechny soubory. Jen zjistit jak (stačilo si přečíst manuálovou stránku pro initscr, ale kdo čte dokumentaci).

Fígl je v inicializaci ncurses funkcí newterm namísto initscr:

#include <ncurses.h>
#include <stdlib.h>
#include <unistd.h>

int
main()
{
    FILE       *f_tty = NULL;
    SCREEN     *term = NULL;

#ifndef __APPLE__

    f_tty = fopen("/dev/tty", "r+");

#endif

    if (!f_tty)
    {
        f_tty = fopen(ttyname(fileno(stdout)), "r");
        if (!f_tty && isatty(fileno(stderr)))
            f_tty = stderr;
    }

    if (!f_tty)
    {
        fprintf(stderr, "cannot to open tty device\n");
        exit(1);
    }

    term = newterm(termname(), stdout, f_tty);
    if (!term)
    {
        fprintf(stderr, "cannot to initialize ncurses screen\n");
        exit(1);
    }

    mvprintw(10, 10, "press ENTER");
    getch();

    endwin();
    delscreen(term);
}

Bohužel na macOS je implementace /dev/tty polofunkční, což jsem zjistil i já (nedá se použít ve funkci poll), a nedoporučuje se moc používat (tato chyba je známá minimálně od roku 2005).

ncurses umožňuje v rámci jedné aplikace vytvoření a používání více terminálů. Voláním funkce set_term mohu pak volit jeden aktivní. Nenašel jsem žádnou aplikaci, nad ncurses, která by toto umožňovala. Například emacs podobný režim podporuje.

Ladění, ladící výstup

Když jsem začínal s ncurses, tak jsem hodně bojoval s laděním aplikace. Jelikož je standardní výstup stdout stejně jako standardní chybový výstup stderr směrován na stejné tty zařízení, tak jakýkoliv pokus o tisk na stderr rozhodí layout aplikace. Nakonec jsem skončil u explicitně vytvořené pojmenované roury, kterou používám pro ladící výpisy. Pokud spustím pspg v debug režimu, tak si tuto rouru otevřu a tisknu do ní. Šlo by to ale i jednodušeji - systémověji. Otevřu si další terminál, a příkazem tty si vypíšu cestu k souboru terminálu. Poté při spuštění aplikace přesměruji chybový výstup do tohoto souboru:

sh
[pavel@localhost ~]$ tty
/dev/pts/8

V jiném terminálu:

sh
mojeapp ... 2>/dev/pts/8

V aplikaci pak mohu používat stderr bez omezení.

Vstup znaků (vstup širokých znaků)

Za nenápadnou funkcí getch se skrývá hodně funkcionality. Tato funkce nevrací jen kód aktuálně stisknuté klávesy, ale řeší dekódování vstupních escape sekvencí, řeší ošetření signálu SIGWINCH (změna velikosti terminálu), a v kombinaci s nastavením timeoutu (funkce timeout) umožňuje volbu blokujícího nebo neblokujícího čtení. Dnes už je tato funkce překonaná, protože nepodporuje vstup širokých znaků (u nás například znaků s diakritikou v UTF). Místo této funkce by se měla používat funkce get_wch. Ta vyžaduje kompilaci a linkování vůči wide charové verzi ncurses ncursesw. Pokud nezbytně nutně nepotřebujete ošetřovat široké znaky, tak můžete použít #ifdef:

Historicky bylo možné nastavit numerickou klávesnici (keypad) do dvou režimů. V numerickém vracela čísla, v aplikačním pak escape sekvence odpovídající kurzorovým šipkám. Možná touto funkcionalitou se inspirovali autoři ncurses, když psali funkci keypad, která mění konfiguraci obsluhy funkčních kláves. Ve výchozím nastavení keypad(FALSE) se přijatá posloupnost escape znaků ^[ [ A posílá aplikaci jako tři přijaté znaky. Pokud nastavíme keypad(TRUE), pak zmíněnou escape sekvenci ncurses rozpozná a nahradí svým kódem KEY_UP.

/*
 * chceme podporu sirokych znaku, obycejne se
 * nastavi autoconfem. Musi byt nastaveno pred
 * include ncurses.h
 */
#define NCURSES_WIDECHAR    1

#include <ncurses.h>
#include <locale.h>

static bool
_getch(int *c)
{

#if NCURSES_WIDECHAR > 0

    wint_t  ch;
    int     ret;

    ret = get_wch(&ch);
    *c = ch;

    return ret == KEY_CODE_YES;

#else

    /*
     * V ASCII verzi nemohlo dojit k prekryti kodu znaku
     * a specialnich kodu ncurses (jako KEY_UP, ..).
     */
    *c = getch();

    return true;

#endif

}

int
main()
{
    int     eventno = 0;
    int     c;
    bool    is_key_code;

    setlocale(LC_ALL, "");

    initscr();

    /* necekej na ENTER */
    cbreak();
    /* neviditelny kurzor */
    curs_set(0);
    /* neviditelne znaky pri psani */
    noecho();
    /* chci aby hlavni obrazovka skrolovala */
    scrollok(stdscr, TRUE);

    /*
     * bez aktivniho keypad modu nedochazi k dekodovani escape sekvenci,
     * kurzorove klavesy, funkcni klavesy, ...
     */
    keypad(stdscr, TRUE);

    move(0,0);

    is_key_code = _getch(&c);
    while (c != 'q')
    {
        if (is_key_code)
            printw("%4d: key: %s\n", ++eventno, keyname(c));
        else
            printw("%4d: c: %lc (%d)\n", ++eventno, c, c);

        refresh();

        is_key_code = _getch(&c);
    }

    endwin();
}

Funkční klávesy

Práce s funkčními klávesami je jednoduchá - pomocí makra KEY_F můžeme jednoduše vygenerovat kódy pro jednotlivé funkční klávesy. Pokud při stisku funkční klávesy stiskneme SHIFT, tak pracujeme jakoby s druhou řadou funkčních kláves F13 - F24. Tady pak dochází k jakési schíze - např. pro kombinaci kláves, kterou označujeme jako SHIFT+F3, používáme kód KEY_F(15).

Přiznám se, že netuším, jestli dále popisované chování je standard nebo pouze specifikum mc midnight commandera. Stisk funkčních kláves může být emulován stiskem klávesy ALT a číslo nebo posloupností kláves ESCAPE a číslo. Toto chování ncurses neimplementuje, ale je možné je implementovat aplikačně (je to například implementované v pspg, protože mi to přijde jako šikovný nápad).

#include <ncurses.h>

int
main()
{
    int     eventno = 0;
    int     c;
    bool    alt = false;

    initscr();

    cbreak();
    curs_set(0);
    noecho();
    scrollok(stdscr, TRUE);
    keypad(stdscr, TRUE);

    move(0,0);

    c = getch();
    while (c != 'q')
    {
        switch (c)
        {
            case 27:    /* ESCAPE */
                if (alt)
                {
                    alt = false					
                    addstr("zadost o ESCAPE\n");
                }
                else
                    alt = true;

                break;

            case '2':
                if (alt)
                    addstr("stisknuta klaveska F2\n");
                break;

            case KEY_F(2):
                addstr("stisknuta klavesa F2\n");
                break;

            case KEY_F(14):
                addstr("stisknuta klavesa SHIFT+F2\n");
                break;

            default:
                addstr("stisknuto neco jineho\n");
        }

        if (c != 27)
            alt = false;

        refresh();

        c = getch();
    }

    endwin();
}

Speciální klávesy

Pro základní speciální klávesy má ncurses definované speciální kódy jako např. KEY_UP, KEY_DOWN atd. Pokud chceme detekovat stisk těchto kláves zároveň se stisknutou klávesou CTRL případně ještě se SHIFTem, tak už je nutné dynamicky získat kód příslušné kombinace kláves. Nejdříve si musíme sestavit identifikátor kombinace kláves. Například kEND6 je kombinace kláves CONTROL+SHIFT+END. K tomuto identifikátoru si můžeme vytáhnout z databáze terminfo odpovídající sekvenci, a následně si voláním funkce key_defined můžeme zjistit kód definovaný pro tuto sekvenci znaků. Jelikož zmíněné funkce patří k rozšíření ncurses, je praktické volání těchto funkcí vložit do #ifdef a počítat s náhradním řešením, když tyto funkce nebudou dostupné. Na rozšířených platformách jsou tyto kódy stejné.

static int
get_code(const char *capname, int fallback)
{

#ifdef NCURSES_EXT_FUNCS

    char    *s;
    int     result;

    s = tigetstr((NCURSES_CONST char *) capname);

    if (s == NULL || s == (char *) -1)
        return fallback;

    result = key_defined(s);
    return result > 0 ? result : fallback;

#else

    return fallback;

#endif

}

/*
 * Set a value of CTRL_HOME and CTRL_END key codes. These codes
 * can be redefined on some plaforms.
 */
void
initialize_special_keycodes()
{

#ifdef NCURSES_EXT_FUNCS

    use_extended_names(TRUE);

#endif

    CTRL_HOME = get_code("kHOM5", 538);
    CTRL_END = get_code("kEND5", 533);
    CTRL_SHIFT_HOME = get_code("kHOM6", 537);
    CTRL_SHIFT_END = get_code("kEND6", 532);
}

ALT

Kombinace kláves CONTROL + znak, je mapována do intervalu 1..27. Takže, když chci detekovat stisk CTRL + O, tak se podívám do ASCII tabulky, kde dohledám, že tato kombinace kláves má kód 15. S ALTem to funguje úplně jinak. Při stisku kláves ALT + O, tak ncurses vygenerují dva kódy: ESCAPE (27) a O. V aplikačním kódu je nutné tuto dvojici detekovat. V pspg volám get_wch v samostatném cyklu, který mi umožní opakované volání, pokud získaná událost není validní nebo je ESCAPE. Tím jak je to navržené a implementované, tak místo klávesy ALT můžeme používat klávesu ESCAPE (jen jde o posloupnost kláves, nikoliv o současné stisknutí, tj ALT + O = ESCAPE, O). ESCAPE, ve smyslu přerušení nějaké operace (klasický MSDOS ESCAPE), je definován dvojím stisknutím klávesy ESCAPE (prvním stiskem, jak už bylo řečeno, se přepínám do alternativní klávesnice). Kombinace kláves CTRL+ALT+O vygeneruje kódy ESCAPE a 15 (^O).

ALT, potažmo ESCAPE se také může používat jako náhrada funkčních kláves F1F10. To se hodí, když vám některé funkční klávesy "sežere" terminál. Například F10aktivuji menu v Gnome Terminálu, a tudíž stisk F10 se k aplikaci, která mi běží v terminálu nedostane. Pokud to aplikace implementuje (je to aplikační záležitost, která musí obsluhovat ALT+0), tak mohu zkusit alternativu ALT+0 nebo ekvivalent ESCAPE, 0.


Použití knihovny readline

Občas může být potřeba editovat řetězec. V ncurses máme funkci getstr. Určitě tím uživatelům umožníte editaci, ale asi je neoslníte. Editace je dost primitivní. Nefungují kurzorové klávesy, mazaní jde jedině zprava backspacem. Když to porovnáte z možnostmi, které máme dnes v bashi, tak to zamrzí. A rovnou vás napadne, proč nepoužít tu samou knihovnu readline. Při podrobnějším zkoumání se ukáže, že to není až tak jednoduché. Knihovna readline je navržená pro použití v REPL aplikacích, nikoliv v celoobrazovkových aplikacích. Nicméně obsahuje funkce, které umožňují používat tuto knihovnu, aniž by měla přímý přístup ke vstupu a výstupu (alternative callback interface). Vůbec netuším, proč je toto API tak zbytečně složitě navržené (ale je fakt, že o této knihovně skoro nic nevím):

/* For wcwidth() */
#define _XOPEN_SOURCE 700

#include <locale.h>
#include <ncurses.h>
#include <readline/readline.h>
#include <stdlib.h>
#include <string.h>
#include <wchar.h>

static int      readline_proxy;
static bool     readline_proxy_is_valid = false;
char   *string = NULL;

/*
 * Zkopiruje znak z proxy do readline
 */
static int
readline_getc(FILE  *dummy)
{
    readline_proxy_is_valid = false;
    return readline_proxy;
}

static int
readline_input_avail(void)
{
    return readline_proxy_is_valid;
}

/*
 * Touto funkci readline predava vysledek editace
 */
static void
readline_callback(char *line)
{
    free(string);
    string = NULL;

    if (line)
        string = strdup(line);
}

/*
 * Chceme mit zobrazeni ve vlastni rezii
 */
static void
readline_redisplay()
{
    /* do nothing here */
}

/*
 * Vrati (zobrazovaci) sirku retezce. 
 */
static size_t
strnwidth(const char *s, size_t n)
{
    mbstate_t shift_state;
    wchar_t wc;
    size_t wc_len;
    size_t width = 0;
    size_t ch_width;

    memset(&shift_state, '\0', sizeof shift_state);

    for (size_t i = 0; i < n; i += wc_len)
    {
        wc_len = mbrtowc(&wc, s + i, MB_CUR_MAX, &shift_state);
        if (!wc_len)
            return width;

        if ((wc_len == (size_t) -1) || (wc_len == (size_t) -2))
            return width + strnlen(s + i, n - i);

        if (iswcntrl(wc))
            width += 2;
        else if ((ch_width = wcwidth(wc)) > 0)
            width += ch_width;
    }

    return width;
}

int
main()
{
    int     c = 0;
    bool    alt = false;

    setlocale(LC_ALL, "");

    initscr();
    cbreak();
    noecho();

    /* ENTER neni \n */
    nonl();

    /*
     * readline vyzaduje neprekodovany vstup tj
     * vypnuty keypad a cteni vice bajtovych
     * znaku po bajtech (emuluje se binarni
     * cteni ze souboru v aktualnim kodovani)
     */
    keypad(stdscr, FALSE);

    /*
     * Instalace hooku - pokud se pouzije rl_getc_function,
     * tak by se mel VZDY pouzit i rl_input_available_hook.
     */
    rl_getc_function = readline_getc;
    rl_input_available_hook = readline_input_avail;
    rl_redisplay_function = readline_redisplay;

    /*
     * Nechceme obsluhu signalu v readline, a nechceme tab
     * complete (neni nakonfigurovano, hrozi pady.
     */
    rl_catch_signals = 0;
    rl_catch_sigwinch = 0;
    rl_inhibit_completion = 0;

    /* Zahajeni editace */
    rl_callback_handler_install(": ", readline_callback);

    /* Vlozi vychozi (default) text */
    rl_insert_text("Editaci ukonci 2x stisk ESCAPE");

    while (1)
    {
        int     cursor_pos;

        clear();

        mvaddstr(10, 10, rl_display_prompt);
        addstr(rl_line_buffer);

        if (string)
        {
            mvaddstr(12, 6, "text: ");
            attron(A_REVERSE);
            addstr(string);
            attroff(A_REVERSE);
        }

        /* nastav kurzor */
        cursor_pos = strnwidth(rl_display_prompt, SIZE_MAX) +
                     strnwidth(rl_line_buffer, rl_point);
        move(10, 10 + cursor_pos);

        refresh();

        c = getch();

        /* ignoruj tabelatory */
        if (c == '\t')
            continue;

        if (c == 27)
        {
            if (alt)
                break;
            else
                alt = true;
        }
        else
            alt = false;

        readline_proxy = c;
        readline_proxy_is_valid = true;

        /* posli echo readline, ze jsou nova data k precteni */
        rl_callback_read_char();
    }

    rl_callback_handler_remove();

    endwin();
}

S mírnými úpravami byl výše uvedený příklad přeložitelný i s knihovnou editline, která by mohla být zajímavou alternativou zejména díky BSD licenci. Tato knihovna implementuje podmnožinu API knihovny readline, nicméně zrovna callback API nefunguje dobře. (A stávající správce mi napsal, že ho to vůbec nepřekvapuje). Proměnná rl_point není aktualizována a stále obsahuje 0 (což je evidentně chyba knihovny). Podařilo se mi zadat i českou diakritiku, ale bylo potřeba knihovně předávat široké znaky (a s těmi zase nefungovala knihovna readline). U editline není podporován hook rl_input_available_hook (a ani není nutný, jelikož místo více bajtových znaků knihovna vyžaduje široké znaky). U readline je jeho použití nutnost (jinak dojde k zacyklení při zadání více bajtového znaku).

Všimněte si, že zde již používám "pokročilejší" posloupnost operací - napřed zobrazím obsah, a teprve potom čekám na stisk klávesy. Jednoduché ukázky, a školní příklady většinou mají tyto operace prohozené. Začíná se stiskem klávesy, a pak podle stisknuté klávesy se kreslí výstup. Zřejmou nevýhodou je prázdná obrazovka při prvním čekání na stisk klávesy.

Při práci s knihovnou readline jsem se setkal ještě s jedním docela "zákeřným" problémem. readline vyžaduje vypnutý keypad režim. Naopak v normální aplikaci budeme asi chtít mít keypad zapnutý. Jelikož se v ncurses keypad nastavuje na okno, tak jsem na standardním okně stdscr keypad povolil, a v okně, které jsem používal pro vstup, jsem měl keypad vypnutý. Skoro to fungovalo, až na případy, kdy první událost byl stisk kurzoru (čehokoliv, co funguje pouze se zapnutým keypadem). Tady se ukázala další vlastnost ncurses. Strukturu WINDOW do jisté míry můžeme chápat i jako konfiguraci terminálu. V ncurses vždy "čteme" data z okna. Nic takového reálně neexistuje, data čteme z tty. Okna jsou pouze abstrakce ncurses. Při čtení se nastaví příslušná konfigurace (pro dané okno). Pokud je viditelný kurzor nebo echo, tak se nastaví kurzor a čeká se na znak. Po získání znaku se nastavuje konfigurace hlavního okna (si myslím).

Pokud používáte funkci poll nebo select (pokud se na událost čeká ve vašem kódu), tak skoro vždy událost v terminálu vzniká ještě před tím, než ncurses nakonfiguruje terminál podle konfigurace odpovídající použitému oknu), a může se stát, že událost neodpovídá požadované konfiguraci (požadovanému oknu). Nejedná se o chybu. Je to vlastnost architektury. Když plánujete psát komplexnější terminálovou aplikaci, tak nejlepším začátkem je, si nastudovat možnosti a funkce nějakého jednoduššího terminálu (VT52 nebo VT100) a vaši aplikaci psát jakoby pro tento terminál - externí krabici s klávesnicí připojenou na sériový port počítače. Případně jít ještě dál. Pracovat s mentálním modelem: "čtu data ze souboru, který mi krmí klávesnice, a zapisuji do souboru, kterým krmím tiskárnu". Pro vývojáře, který v životě nepracoval na fyzickém terminálu to není moc přirozené. Na druhou stranu, tato architektura má něco do sebe. Jednoduše a hlavně naprosto přirozeně umožňuje vzdálenou správu, jednoduše umožňuje zanořování aplikací - např. aplikace jako screen, tmux atd jsou díky tomu v podstatě velice jednoduše realizovatelné aplikace.

Používání myši

Implementace obsluhy událostí generovaných myší v ncurses neměla nejlepší pověst. Narazil jsem na pár aplikací, které ačkoliv používaly ncurses, tak myš obsluhovaly pomocí knihovny gpm. Já jsem u pspg zůstal s obsluhou myši v ncurses, ale něco málo jsem si musel přeprogramovat (double click), a pro režim, kdy pohybuji s myší a zároveň držím tlačítko, jsem musel použít rozšířený režim, který není nativně podporován ncurses (musím ho zapínat, vypínat escape sekvencí).

Události, které generuje myš (pohyb, stisknutí tlačítka, rotace kolečka), primárně zpracovává terminál (emulátor terminálu). Podle aktuálního režimu na tuto událost sám zareaguje (v primární obrazovce (primary screen) zobrazením historie) nebo vygeneruje escape sekvenci, kterou pošle aplikaci (v alternativní obrazovce). Tady za nás ncurses udělá docela dost práce - escape sekvenci převede na událost KEY_MOUSE, kterou získám funkci getch a připraví datovou strukturu MEVENT (obsahuje polohu kurzoru myši, a bitmapu s kombinací stisknutých tlačítek myši a vybraných kláves na klávesnici). MEVENT získáme voláním funkce getmouse. U této funkce je dobré kontrolovat vrácenou hodnotu. Stalo se mi, že ncurses špatně detekoval událost, a teprve při dekódování celé escape sekvence, se zjistilo, že poslední událost byla zmatečná. Funkce getmouse také může vrátit ERR pokud k získání události používám wgetch, přičemž kurzor myši byl mimo dané okno. Souřadnice kurzoru jsou vždy vztažené k celé obrazovce. Voláním funkce wenclose můžeme zjistit, zdali byl kurzor myši uvnitř okna, a použitím funkce wmouse_trafo získáme souřadnice kurzoru vztažené k počátku zadaného okna.

Starší ncurses ještě nepodporovaly kolečko na myši, a bohužel se ještě s touto verzí ncurses často potkávám. Tudíž vždy je nutné používat #ifdef NCURSES_MOUSE_VERSION > 1, pokud testujete tlačítka 4 a 5 (což odpovídá pohybu kolečka od sebe nebo k sobě). Je to například problém RHEL7 s ncurses 5.9 z roku 2014. A firem, které jsou ještě na této verzi RHELu je pořád dost.

#include <ncurses.h>

int
main()
{
    int     eventno = 0;
    int     c;

    initscr();

    cbreak();
    curs_set(0);
    noecho();
    scrollok(stdscr, TRUE);

    /*
     * bez aktivniho keypad modu nedochazi k dekodovani
     * vstupnich escape sekvenci, vcetne mysi, musi byt TRUE
     */
    keypad(stdscr, TRUE);

    move(0,0);

    mousemask(BUTTON1_PRESSED |
              BUTTON1_RELEASED |
              BUTTON1_CLICKED |
              BUTTON1_DOUBLE_CLICKED

#if NCURSES_MOUSE_VERSION > 1

              | BUTTON4_PRESSED
              | BUTTON5_PRESSED

#endif

             , NULL);

    c = getch();
    while (c != 'q')
    {
        if (c == KEY_MOUSE)
        {
            MEVENT      mevent;
            int         res;

            if (getmouse(&mevent) == OK)
            {
                printw("%4d: key: %s, y: %d, x: %d",
                        ++eventno,
                        keyname(c),
                        mevent.y,
                        mevent.x);

                /*
                 * tam, kde to neni nutne se nedoporucuje pouzivat
                 * funkci print, ktera je vyrazne narocnejsi na CPU
                 */
                if (mevent.bstate & BUTTON1_PRESSED)
                    addstr(", BUTTON1_PRESSED");

                if (mevent.bstate & BUTTON1_RELEASED)
                    addstr(", BUTTON1_RELEASED");

                if (mevent.bstate & BUTTON1_CLICKED)
                    addstr(", BUTTON1_CLICKED");

                if (mevent.bstate & BUTTON1_DOUBLE_CLICKED)
                    addstr(", BUTTON1_DOUBLE_CLICKED");

#if NCURSES_MOUSE_VERSION > 1

                if (mevent.bstate & BUTTON4_PRESSED)
                    addstr(", BUTTON4_PRESSED");

                if (mevent.bstate & BUTTON5_PRESSED)
                    addstr(", BUTTON5_PRESSED");

#endif

                addch('\n');
            }
            else
                addstr("broken mouse event\n");
        }
        else
            printw("%4d: key: %s\n", ++eventno, keyname(c));

        refresh();

        c = getch();
    }

    endwin();
}

Při zpracování události od myši ncurses čeká ve výchozím nastavení 250 ms (nastavuje se funkcí mouseinterval) na případnou další událost od myši, a podle toho jestli přijde nebo nepřijde nastaví flag PRESSED nebo CLICKED. Mne to v pspg způsobovalo nepříjemné jakoby opožděné reakce na myš. Přeci jen 250ms už si všimnete. Takže jsem si nastavil mouseinterval(0), čímž jsem vyblokoval generování flagu CLICKED, a na flag PRESSED jsem napojil získání fokusu, a na flag RELEASED pak provedení akce. Tím pspg uživatele nenutí mít příliš rychlé prsty, a zároveň ovládání pspg není "líné"

V mc se mi líbí, že uživatel může pohybovat myší, a držením levého tlačítka mít neustále zvýrazněný fokus. Dost dlouho mi trvalo, jak toho v ncurses docílit. Sice si mohu nastavit masku myši REPORT_MOUSE_POSITION, ale to samo od sebe nic neudělá. Je potřeba na terminálu vybrat některý z protokolů pro sledování myši "xterm Mouse Tracking", a pro tento účel mi vyhovoval protokol označený číslem 1002. Zapíná se escape sekvencí \033[?1002h a vypíná \033[?1002l (flag REPORT_MOUSE_POSITION musí být aktivní).


Velikost terminálu

Nejstarší terminály měly fixní konstantní velikost danou typem terminálu. Na základě údajů ze své databáze vlastností jednotlivých terminálů pak ncurses nastaví proměnné LINES a COLS. U terminálů, které jsou emulovány softwarově tento přístup není možný. Jejich velikost je dynamická, a proto by pseudoterminál při své inicializaci měl nastavit systémové proměnné $LINES a $COLUMNS, odkud hodnoty převezme ncurses, které nestaví své proměnné LINES a COLS. U softwarově emulovaných terminálů běžně dochází ke změně velikosti i v průběhu běhu aplikací, tudíž by terminálová aplikace měla ošetřit signál SIGWINCH, který je generován terminálem při změně velikosti.

Zde nastávají první komplikace, kdy některé aplikace neošetřují vše, co by měly. Mějme posloupnost aplikací bash, psql a pspg. Pokud by bash neošetřoval SIGWINCH, tak se může stát, že systémové proměnné $LINES a $COLUMNS nebudou obsahovat aktuální hodnoty. Zrovna tak se může stát, že ačkoliv pspg správně ošetřuje SIGWINCH, tak nedokáže nastavit zmíněné systémové proměnné vnějšímu procesu, a po ukončení pspg jsou tyto proměnné opět neaktuální. Chování ncurses můžeme ovlivnit voláním konfiguračních funkcí use_env a use_tioctl. V závislosti na konfiguraci ncurses bere v potaz systémové proměnné $LINES a $COLUMNS, případně je ignoruje a velikost terminálu si zjišťuje samotná knihovna.

V moderních systémech a v moderních ncurses se můžete spolehnout na vestavěnou (v ncurses) obsluhu signálu, a na obsah proměnných LINES a COLS (po změně velikosti aplikace dostane událost KEY_RESIZE. U starších systémů jsem se setkal s tím, že tyto proměnné při startu aplikace, případně po změně velikosti okna nebyly aktuální, a musel jsem si je načíst sám. Jinak KEY_RESIZE funguje asi všude. Správné automatické nastavení LINES a COLS funguje tak na 99.9% (ale je možné, že bych si možná pomohl laděním use_env a use_tioctl, a možná také ne. ioctl mi fungovalo všude):

#include <stdio.h>
#include <stdlib.h>
#include <ncurses.h>

int
main()
{
    int     c = ' ';

    initscr();

    noecho();
    cbreak();
    curs_set(0);

    while (c != 'q')
    {
        int lines, cols;

        mvprintw(9, 10, "keyname: %s", keyname(c));

        if (c == KEY_RESIZE)
        {
            /* velikost terminalu */
            mvprintw(10, 10, "terminal: %d, %d", LINES, COLS);

            /* velikost hlavniho okna */
            getmaxyx(stdscr, lines, cols);
            mvprintw(11, 10, "stdscr: %d, %d", lines, cols);
        }

        refresh();
        c = getch();
    }

    endwin();

    exit(EXIT_SUCCESS);
}

V případě, že si sami ošetřujete signál SIGWINCH, je nutné o změně velikosti terminálu informovat ncurses:

#include <stdio.h>
#include <stdlib.h>
#include <ncurses.h>
#include <signal.h>
#include <sys/ioctl.h>
#include <termios.h>

static bool handle_sigwinch = false;

static void
SigwinchHandler(int sig_num)
{
    struct winsize size;

    if (ioctl(fileno(stdout), TIOCGWINSZ, (char *) &size) >= 0)
        resize_term(size.ws_row, size.ws_col);

    handle_sigwinch = true;

    signal(SIGWINCH, SigwinchHandler);
}

int
main()
{
    int     c = ' ';

    initscr();

    noecho();
    cbreak();
    curs_set(0);

    /* nastavi timeout pro funkci getch() */
    timeout(250);

    /*
     * tim, ze nastavuji signal po initscr, si
     * vynucuji vlastni obsluhu signalu
     */
    signal(SIGWINCH, SigwinchHandler);

    while (c != 'q')
    {
        int lines, cols;

        if (handle_sigwinch)
        {
            struct winsize size;

            handle_sigwinch = false;

            if (ioctl(fileno(stdout), TIOCGWINSZ, (char *) &size) >= 0)
                resize_term(size.ws_row, size.ws_col);
        }

        /* pokud neni timeout */
        if (c != -1)
        {
            mvprintw(9, 10, "keyname: %s", keyname(c));
            clrtoeol();
        }

        /* velikost terminalu */
        mvprintw(10, 10, "terminal: %d, %d    ", LINES, COLS);

        /* velikost hlavniho okna */
        getmaxyx(stdscr, lines, cols);
        mvprintw(11, 10, "stdscr: %d, %d    ", lines, cols);

        refresh();

        /* maximalne po 250 ms skonci na timeout */
        c = getch();
    }

    endwin();

    exit(EXIT_SUCCESS);
}

Jelikož jsem přepsal ncurses obsluhu signálu, tak resize terminálu nepřeruší provádění funkce getch (signál spolkla moje aplikace, nikoliv ncurses), a abych zachoval určitou interaktivitu, tak nastavuji timeout 250 ms. Všimněte si. Aktuální velikost terminálu získám voláním funkce ioctl, a volám funkci resize_term. Uvnitř této funkce se přepočítají (a přealokují) okna a nastaví proměnné LINES a COLS. Tato funkce by se neměla volat uvnitř obsluhy signálu. U jednodušších aplikací, když se potřebujeme vyhnout dlouhém blokujícímu čekání na klávesnici, tak si můžeme pomoct nastavením timeoutu. Pozor ale, příliš krátký timeout zbytečně zatěžuje CPU (a je trend od používání krátkých timeoutů ustupovat, tak aby nedocházelo ke zbytečnému uspávání a probouzení procesu),

Poznámka: ačkoliv se tato funkce jmenuje resize_term neznamená to, že s ní můžete změnit velikost terminálu. Touto funkcí si vynutíte aktualizaci datových struktur v ncurses, které jsou nějakým způsobem navázané na velikost terminálu. Změnu velikosti terminálu si lze vynutit escape sekvencemi (na některých terminálech, určitě ne všude). Například pro nastavení terminálu na velikost 100x50 znaků je sekvence \e[8;50;100t