Pull to refresh

Текстовый интерфейс, ч.2. Взаимодействие с пользователем

Reading time9 min
Views24K
Итак, вторая часть. Здесь я раскрою как получить информацию от пользователя, а так же о манипуляции этими данными. Тут можно затронуть вопрос, поднятый в комментариях к предыдущему посту — «А зачем это все нужно?». Примерами применения таких интерфейсов в 21-м веке являются различные аплаянсы на виртуальных машинах, которые реализуют отдельные сервисы. Чаще всего они представляют из себя минимальный дистрибутив Linux или набор загружаемое ядро + busybox. С помощью такого интерфейса можно реализовать некий фронтэнд для сервиса, позволяющий одним взглядом определить состояние основных узлов или выполнить некие операции в удобной для пользователя форме. Примером можно назвать подобные фронтэнды у VMware ESXi (vDirector, vCenter etc), Citrix Xen, которые сочетают как мощь web-интерфейса, так и TUI как резервного интерфейса и/или интерфейса настройки/диагностики. Переключаясь по десяткам машин можно одним взглядом увидеть, все ли в порядке или быстро узнать IP адрес, полностью заблокировав юзеру доступ к консоли, показывая ему только то, что ему достаточно знать (foolproof).



Чтобы не заглядывать в прошлый пост, напомню, что все «виджеты» я реализую через структуру, создающую подобие слоев, каждый из которых отвечает за что-то своё (тень, оформление, верхний слой, доступный пользователю для изменения). И все это объединено в панель, которая позволяет перемещать, скрывать и удалять все слои как одно целое.

struct _cursed_window
{
    WINDOW *background;
    WINDOW *decoration;
    WINDOW *overlay;
    PANEL *panel;
};
typedef struct _cursed_window cursed;


Рассматривая скриншоты, можно увидеть, как различаются цвета на некоторых из них. Хотя код один и тот же, отображение цвета зависит от конкретной реализации эмулятора терминала. Я оставил это как пример.

Я не буду упоминать работу с такими функциями как getch(), wgetch(), scanw, и сотоварищи. Это по сути аналоги соответствующих функций из С с некоторыми дополнениями.

Меню


Меню реализуется просто и наглядно. Движок поддерживает большое разнообразие вариантов меню, включая вложенные. Любые пункты меню могут быть активными или нет. Так же имеется поддержка скроллинга, поиска по комбинации и прочие радости, значительно упрощающие создание весьма сложных и навороченных меню.
Напишем функцию, которая будет возвращать значение, соответствующее выбору пользователя.

int32_t 
tui_make_menu (const char **choices, size_t choices_num)


На входе у нас массив со строками вариантов выбора и количество этих вариантов. Вот такой примерно у нас список может быть.

const char *choices[] =
	{
		"Restore",
		"Quick backup",
		"Adv. backup",
		 "Add machine",
		"Exit",
		 NULL,
	};


А передавать в функцию мы будем это примерно так
if(tui_make_menu(choices,(sizeof choices/sizeof choices[0]))==4)
	do_something();


А в самой функции теперь создадим меню на основе этого массива строк.

ITEM **my_items = (ITEM **)calloc(choices_num, sizeof(ITEM *));
for(int32_t  i = 0; i < choices_num; ++i)
        my_items[i] = new_item(choices[i], "");
MENU *my_menu = new_menu((ITEM **)my_items);


В вызове new_item я указал вторым аргументом пустую строку, это постфикс для пункта меню, он будет отображаться справа как комментарий (например, для указания соответствующей горячей клавиши).
Зададим окно для меню
set_menu_win(my_menu, new->overlay);

А так же зададим символ, который будет маркером выбранного пункта меню.
set_menu_mark(my_menu, " * ");


Ну и раскрасим наше меню.
set_menu_fore(my_menu, COLOR_PAIR(1) | A_REVERSE);
set_menu_back(my_menu, COLOR_PAIR(2));
set_menu_grey(my_menu, COLOR_PAIR(3));


И опубликуем его
post_menu(my_menu);




Как и всегда, жизнедеятельность движка мы обеспечиваем в ручном, но наглядном режиме.

do
    {
        switch(user_key)
        {
        case KEY_MOUSE:
            menu_driver(my_menu, KEY_MOUSE);
            touchwin(panel_window(new->panel));
            update_panels();
            doupdate();
            sleep(1);
            goto mouse_end;
        case KEY_DOWN:
            menu_driver(my_menu, REQ_DOWN_ITEM);
            break;
        case KEY_UP:
            menu_driver(my_menu, REQ_UP_ITEM);
            break;
        }
        touchwin(panel_window(new->panel));
        update_panels();
        doupdate();
    } while((user_key = wgetch(new->overlay)) != 0xD);


Да-да. Сделаем отступление и поговорим о работе с мышкой.

Мышь


Ncurses позволяет нам без особого усложнения и изменения кода дублировать действия, которые мы уже делали клавиатурой, с помощью мышки. Достаточно включить реагирования на события мышки простым маскированием.
mousemask(ALL_MOUSE_EVENTS, NULL);

В данном случае все события разрешены, но вы можете, естественно захотеть реагировать только на двойной клик правой кнопкой BUTTON2_DOUBLE_CLICKED или только на отпускание левой кнопки BUTTON1_RELEASED и т.д.

После этого в нашем стандартном цикле обработки ввода нужно будет добавить case KEY_MOUSE и уже далее передать полученное событие в движок меню или же сделать что-то свое. В первом случае движок меню самостоятельно определит, как следует перенести маркер меню.
Очень актуально, если работа идет через VNC с не всегда адекватной работой мышки.

Теперь, когда пользователь определился и выбрал пункт меню, мы можем узнать его выбор

mouse_end:
int32_t user_selection = item_index(current_item(my_menu));


Убираем мусор

unpost_menu(my_menu);
free_menu(my_menu);
for(int32_t i = 0; i < choices_num; ++i)
    free_item(my_items[i]);
tui_del_win(new);


и возвращаем значение

return (user_selection);


Формы и поля


Поле ввода в ncurses имеет весьма мощный функционал, позволяющий манипулировать текстом, введенным пользователем в широких пределах. Но сначала напишем небольшую функцию, которая будет возвращать нам текст от пользователя и на её примере рассмотрим, как и что можно с этим полем делать.

char *
tui_make_input(const char *info, int32_t max_len);

То есть мы должны задать максимальную длину поля и некое информационное послание, объясняющее, что мы хотим от пользователя получить.

Создаем буфер с нашим полем ввода. В данном случае, размер поля фиксирован, но поле может и расширяться автоматически,
Такой стиль инициализации обязателен.
FIELD *field[2];
field[0] = new_field(1, max_len, 0, 0, 0, 0);
field[1] = NULL;


FORM  *my_form;
set_field_back(field[0], A_UNDERLINE); 


Поле в max_len знаков длиной, в 1 строку высотой. Мы создали одно поле для формы, но создать можно их сколько угодно, например, если у вас форма ввода логина/пароля. Тогда эти поля можно использовать совместно, например переходить в новое поле при нажатии определенной клавиши и/или при заполнении формы (к примеру если это форма ввода серийного номера).
А также включили опцию выделения поля ввода нижним подчеркиванием.

Типы

Каждое поле может иметь один или несколько типов, которые задаются следующим образом:
set_field_type(field[0],TYPE_ALPHA);

В данном случае, включается фильтр на буквы, и введенные пользователем цифры или другие знаки, не относящиеся к алфавиту, не будут отображены. На скриншоте пример такого поля ввода с включенным фильтром TYPE_IPV4. К сожалению, проверяет он только на наличие цифр и точки, но не правильность самого адреса. Но и тут вы можете проверять каждую нажатую клавишу (прошедшую фильтр) и, проверив, решать — отображать её или нет или вывести предупреждение.



Теперь наши поля мы объединяем в форму

my_form = new_form(field);
set_form_sub(my_form, new->overlay);
post_form(my_form);


А так же печатаем наше приглашение.
mvwprintw(new->decoration,1,1, info);


Далее мы должны обеспечить работу формы

uint32_t user_key=0;
do
    {
        switch(user_key)
        {
        case 0xD:
        form_driver(my_form, REQ_VALIDATION);        	
        goto check;
        default:
        form_driver(my_form, user_key);
        break;
        }
        touchwin(panel_window(new->panel));
        update_panels();
        doupdate();
    } while((user_key = getch()) != KEY_F(12));


Мы ждем в цикле от пользователя нажатия клавиши, в случае если это не ВВОД — передаем клавишу драйверу поля, который уже применяет фильтр и выводит на экран, если нужно. Если это клавиша ВВОД — запрашиваем проверку поля и выходим из цикла.

Теперь дело за малым — получить то, что ввел пользователь:
check:
char *result=0;
asprintf(&result, "%s", field_buffer(field[0], 0));


Далее подчищаем за собой и возвращаем результат

unpost_form(my_form);
free_field(field[0]);
free_form(my_form);
tui_del_win(new);
return result;


Разумеется, можно задать и предварительное значение поля ввода, которое можно использовать как приглашение или значение по умолчанию:
set_field_buffer(field[0], 0, «Default»);

Ну и действительно множество других опций. Хочу заметить, что манипуляции с удалением/перемещением знаков или целых слов лежат на пользователе. В том же цикле do-while необходимо отлавливать нажатия нужных клавиш и через form_driver делать запросы на удаление знака, переход по буквам или словам. С помощью форм можно без особой сложности реализовать простейший текстовый редактор, львиная доля работы с текстом в котором ляжет на движок ncurses. Ведь поддерживается даже выравнивание текста по полям, удаление целых слов и прочие радости.

Кнопки


Так как ncurses не предоставляет готовую реализацию, сделаем её сами. Самый простой вариант кнопки это окно с тенью, в нажатом состоянии опускающееся на свою тень. В прошлом посте я рассказывал, что в простейшем случае тень наедет на нижнее окно. Это происходит из-за того, что для рисования той же тени мы должны создать слой, и слой этот прямоугольный. Неиспользуемая область его все равно существует и произойдет как на скриншоте снизу



Эффект «прозрачности» в реализации по умолчанию отсутствует. Но это не беда, её можно реализовать самостоятельно. В большинстве случаев, кнопку мы будем рисовать внутри окна, поэтому будем считать, что мы знаем, на каком окне расположена кнопка.
В функции мы вычитываем текст в нижележащем окне вместе с его атрибутами (цвет, режим). ASCII код + атрибут занимает один байт и лежат в переменной типа chtype (uint8_t). Для отображения этого типа есть специальная функция mvwaddchstr.

Код для кнопки
cursed *tui_new_button(WINDOW *bwin, int32_t  sy, int32_t  sx, const char *label, size_t len)
{
    cursed *new = malloc (sizeof *new);
    int32_t  w = len + 4;
    int32_t  h = 5;
    new->background = newwin(h, w + 1, sy, sx);
    wbkgd(new->background, COLOR_PAIR(4));
    /* Get start coordinates of underlying window*/
    int32_t  shad_x, shad_y;
    getbegyx(bwin, shad_y, shad_x);

    chtype c[len + 7] = {0};
    /* Extract underlying text and copy it to the button's shadow layer */
    for(int32_t  i=0; i < h; i++)
    {
        mvwinchnstr(bwin,sy-shad_y + i,sx-shad_x,c, len + 6);
        mvwaddchstr(new->background,i,0, c);
    }

    wattron(new->background, COLOR_PAIR(10));
    for (int32_t  i = 2; i < w - 1; i++)
        mvwaddch(new->background, h - 1, i, ' ');
    for (int32_t  i= 2; i < h; i++)
        mvwprintw(new->background, i, w - 1, "  ");
    wattroff(new->background, COLOR_PAIR(10));

    new->decoration = derwin(new->background,  h-2, w-2, 1, 1);
    wbkgd(new->decoration, COLOR_PAIR(1));
    box(new->decoration, 0, 0);
    int32_t  x, y;
    getmaxyx(new->decoration, y, x);
    new->overlay = derwin(new->decoration,  y-2, x-2, 1, 1);
    wbkgd(new->overlay, COLOR_PAIR(1));
    new->panel = new_panel(new->background);

    wprintw(new->overlay, label);
    update_panels();
    doupdate();
    return new;
}





Осталось сделать анимацию нажатия кнопки. Поскольку
«The subwindow functions (subwin, derwin, mvderwin, wsyncup, wsyncdown, wcursyncup, syncok) are flaky, incompletely implemented, and not well tested »,
то я эффект нажатия кнопки реализовал через такие вот костыли. То есть я скрываю оригинальную панель кнопки, делаю дубликат без тени, перемещаю по диагонали, делаю паузу и удаляю клон. Ну и разумеется снова показываю оригинальную кнопку.

void tui_toggle_button (cursed *button)
{
	int32_t  x, y;
	getbegyx(button->background, y, x);
  	hide_panel(button->panel);
	WINDOW *dup_dec = dupwin(button->decoration);
	PANEL *duppan = new_panel(dup_dec);
	move_panel(duppan, y + 2, x + 3);
	update_panels();
	doupdate();
	usleep(200000);
	del_panel(duppan);
	show_panel(button->panel);
}




Остальные стандартные элементы интерфейса.


Комбинируя форму и меню можно с легкостью сделать выпадающий список с автопоиском. С помощью горизонтально-ориентированного меню можно сделать radiobutton.
Тот же индикатор прогресса операции реализовывается просто:



void 
tui_progress_bar(WINDOW *win, double progress)
{
    int32_t  height, width;
    getmaxyx(win, height, width);
    wattron(win, COLOR_PAIR(8));
    for (int32_t  i = 0; i < width * progress; i++)
    {
        mvwaddch(win, (height / 2), i, ' ');
    }
    wattroff(win, COLOR_PAIR(8));
    wattron(win, COLOR_PAIR(7));
    for (int32_t  i = width * progress; i < width; i++)
    {
        mvwaddch(win, (height / 2), i, ' ');
    }
    wattroff(win, COLOR_PAIR(7));
    wattron(win, A_STANDOUT);
    mvwprintw(win, (height / 2), (width / 2) - 2, "%.0f%%", progress*100);
    wattroff(win, A_STANDOUT);
}


Тот же програссбар можно использовать и в чисто текстовых приложениях. Как наглядный и удобный индикатор прогресса, хотя такое же можно сделать чисто на printf, но не будет возможности обновлять что-то ещё в других строках.

Заключение


Текстовый интерфейс это просто. Многие пишут консольные утилиты, в некоторых случаях это основной рабочий инструмент. Почему бы его не сделать наглядней и удобней для восприятия? На данном скриншоте вообще не используется ничего кроме 3 функций вывода без окон и панелей.

Если он используется и для скриптов, tui можно включать/выключать по ключу в аргументах запуска. Так или иначе, если программа не может использовать графику, это не значит, что она должна оставаться непонятной. Тот же вывод справки можно сделать наглядней или даже контекстным. Те же автодополнения по TAB можно реализовать и ваши пользователи скажут вам спасибо. Хорошим примером тут является утилита xe из комплекта Xen Server. После того, как юзер напечатал ключевое слово, по нажатию TAB программа предлагает варианты. Например uuid= и тут же автоматом подставляются UUID машин, зарегистрированных в системе. Это невероятно упрощает и ускоряет работу.

PS: Если кто-то хочет попробовать поработать с curses, я выложил простейший пример тут
github.com/Pugnator/curses_test
Прошу сильно не ругать за код — я лишь любитель.
В директории bin просто запустить make
Если нужно, доустановить:
sudo apt-get install ncurses-dev (для debian based)
Tags:
Hubs:
Total votes 47: ↑42 and ↓5+37
Comments16

Articles