Hlavní navigace

Psaní aplikací pro terminál: ošetření vstupů

9. 9. 2021
Doba čtení: 20 minut

Sdílet

 Autor: Wikipedie
V předchozím článku jsem se snažil obecně někam zařadit knihovnu ncurses. V tomto článku se snažím o vysvětlení nebo popis zpracování vstupů a snažím se komentovat (a zdokumentovat) problémy, se kterými jsem se setkal při psaní pspg.

V tomto článku navazuji na svůj dubnový článek Psaní aplikací pro terminál: jak funguje knihovna ncurses.

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:

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

V jiném terminálu:

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áluSIGWINCH (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 F10 aktivuji 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, mazání 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 funkcipoll 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;

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

MIF analytika

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 100×50 znaků je sekvence  \e[8;50;100t

Závěr

Poté, co se člověk otrká a získá zkušenosti, tak se s ncurses pracuje docela dobře, když už se ví, co se může chtít. Je to nízkoúrovňová relativně nenáročná a rozšířená knihovna, která má svojí logiku. Za těch 30 let je tam asi dost balastu také i díky tomu, že se ncurses snaží být kompatibilní s různými implementacemi knihoven curses, ale s tím se dá žít. Příští a poslední díl by se věnoval zobrazení (výstupu) v ncurses.

Autor článku

Pavel Stěhule je odborníkem na relační databázový systém PostgreSQL, pracuje jako školitel a konzultant.