Содержание статьи
- Разные части терминала
- Как эмулятор терминала интерпретирует и отображает данные, полученные из командной оболочки?
- Создание пользовательского интерфейса внутри терминала
- Специальный набор символов
- SIGWINCH — отзывчивый пользовательский интерфейс в терминале
- Необрабатываемый режим
- Используя приобретенные знания, сделаем подлянку!
- Заключение
info
Это перевод статьи Арама Древекенина Anatomy of a Terminal Emulator, дополненный врезками от редакции «Хакера». Перевод и текст врезок — Юрий Язев.
В статье мы поговорим о разных частях терминала и о том, как они взаимодействуют, напишем небольшую прогу для чтения ввода в командной строке и рассмотрим, как она интерпретирует команды. Затем мы обсудим, как создать пользовательский интерфейс в терминальном приложении. И в конце концов увидим, как можно использовать все это ради шутки.
Примеры кода я привел на языке Rust, в то же время я буду стараться делать их как можно проще и короче, чтобы даже незнакомому с Rust программисту было все понятно. Кроме того, я подробно опишу значимые части кода. Мы рассмотрим работу эмулятора терминала в Linux, но с таким же успехом ты можешь применить сведения в любой другой Unix-подобной системе (например, в macOS).
Материал рассчитан на новичков в разработке терминальных приложений, тем не менее я старался сделать его полезным и для ветеранов разработки приложений командной строки.
Установка Rust
Чтобы установить Rust в Linux или macOS, надо выполнить в командной строке следующую команду:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
В macOS ее выполнение проходит без сучка, без задоринки. Однако в Linux могут возникнуть разные проблемы, например связанные с отсутствием curl
. Если ты не хочешь тратить время на их решение, можешь установить Rust из официального репозитория системы. С 20-й версии Ubuntu среди ее пакетов есть Rust. В этом случае в командной строке надо набрать следующее:
sudo apt install rustc
Cargo
Вместе с компилятором языка Rust устанавливается система сборки Cargo. Ее используют, чтобы управлять проектами, а кроме этого, она играет роль пакетного менеджера. С ее помощью создаются, объединяются, строятся проекты и скачиваются зависимые библиотеки.
Чтобы создать проект, набери в консоли cargo
.
В результате будет создан проект с таким содержимым.
Исходный код программы HelloRust содержится в файле main.
, который, в свою очередь, находится в папке src
.
Файл Cargo.
содержит метаданные описания проекта:
[package]name = "HelloRust"version = "0.1.0"edition = "2022"[dependencies]
Кроме того, в нем указываются библиотеки, используемые для компиляции программы (ниже ключевого слова [
). Все рассматриваемые в статье примеры обращаются к либе nix
.
Чтобы скомпилировать пример, надо выполнить команду cargo
, находясь в папке с проектом. А чтобы запустить проект на выполнение, нужно набрать команду cargo
. Она не только запустит программу на выполнение, но и пересоберет ее, если в исходный код будут внесены изменения.
Где писать Rust-код?
В любой удобной для тебя IDE, поддерживающей подсветку синтаксиса. Это в идеальном случае. Плагины поддержки языка существуют для многих сред, например VS Code. Но можно воспользоваться абсолютно любым текстовым редактором. Это уже на твое усмотрение.
Если рассматриваемая тема тебе интересна, напиши об этом в комментарии. Мы подумаем над новыми статьями, посвященными этому языку.
Разные части терминала
Время от времени эмулятор терминала называют командной строкой (оболочкой) или pty (псевдотерминалом). Для начала обсудим смысловую нагрузку этих названий и решим, в каких контекстах они могут использоваться.
Эмулятор терминала имеет длинную историю, но мы сконцентрируемся на тех вещах, которые подразумеваются под этим словом в наши дни. Как эмулятор терминала (gnome terminal, alacritty, rxvt-unicode), так и командная оболочка (bash, zsh, fish) представляют собой приложения, выполняемые под управлением операционной системы.
Эмулятор терминала — это графическое приложение, которое преобразует поступающие из командной оболочки данные и отображает их на экране. Обычно вывод текстовый, но могут быть исключения. Их мы рассмотрим дальше.
Командная оболочка (у нас распространено короткое название shell) предоставляет интерфейс к операционной системе, позволяет пользователю взаимодействовать с ее файловой системой, запускать процессы, а иногда — выполнять скрипты.
Эти две программы соединяются с помощью pty (pseudoterminal), который организует двунаправленный асинхронный канал связи между ними. В одном направлении pty передает данные от терминала к оболочке (STDIN), во втором — от оболочки к терминалу (STDOUT). Другими словами, когда пользователь вводит команды в терминал, последний пересылает их через канал STDIN псевдотерминала (pty) в оболочку. С другой стороны, когда оболочка готова показать пользователю результат на экране, она отправляет данные в терминал по каналу STDOUT псевдотерминала.
Как видно, у pty имеются две стороны. Мы будем называть их «первичная» и «вторичная», хотя в официальной документации они называются master и slave. На первичной стороне находится эмулятор терминала, на вторичной — командная оболочка. Тем не менее на вторичной стороне может располагаться любая программа, ожидающая подключения терминала, например Vim или Top.
Предлагаю взглянуть на картинку, демонстрирующую, как эта система работает в целом, на примере команды ls
.
Как эмулятор терминала интерпретирует и отображает данные, полученные из командной оболочки?
Когда командная оболочка отправляет текст терминалу, она использует весь доступный ей набор инструкций. Ты мыслишь в правильном направлении, если назвал этот набор escape-символами ANSI. Они используются, когда командной оболочке надо каким‑либо образом форматировать вывод: изменить цвет/стиль шрифта или переместить курсор ввода. Чтобы увидеть, как это работает, давай напишем небольшую программку на Rust, которая создает псевдотерминал (pty) и на вторичной стороне запускает предопределенную в конкретной системе командную оболочку. Таким образом мы сможем увидеть, какие данные из недр системы нам шлет shell.
fn read_from_fd(fd: RawFd) -> Option<Vec<u8>> { unimplemented!()}fn spawn_pty_with_shell(default_shell: String) -> RawFd { unimplemented!()}fn main() { let default_shell = std::env::var("SHELL") .expect("could not find default shell from $SHELL"); let stdout_fd = spawn_pty_with_shell(default_shell); let mut read_buffer = vec![]; loop { match read_from_fd(stdout_fd) { Some(mut read_bytes) => { read_buffer.append(&mut read_bytes); } None => { println!("{:?}", String::from_utf8(read_buffer).unwrap()); std::process::exit(0); } } }}
www
Исходные коды примеров в статье можно найти в репозитории автора.
Функция main
начинается с того, что мы получаем путь к системной командной оболочке из переменной окружения SHELL
. В случае ошибки функция expect
выводит сообщение. Далее вызывается функция spawn_pty_with_shell
, которой в качестве параметра передается полученный на прошлом шаге путь к системной оболочке. Внутри функции оболочка запускается в новом процессе на вторичной стороне pty. Но этот момент рассмотрим ниже.
Функция spawn_pty_with_shell
возвращает файловый дескриптор первичной стороны pty STDOUT. Затем в функцию read_from_fd
(она будет рассмотрена чуть позже) в качестве параметра передается полученный STDOUT (хранимый в переменной stdout_fd
), а внутри функции происходит чтение данных из STDOUT. Чтение выполняется, пока данные не закончатся или процесс не завершится. После этого все полученные данные выводятся на терминал.
Рассмотрим функцию spawn_pty_with_shell
:
use nix::pty::forkpty;use nix::unistd::ForkResult;use std::os::unix::io::RawFd;use std::process::Command;fn spawn_pty_with_shell(default_shell: String) -> RawFd { match forkpty(None, None) { Ok(fork_pty_res) => { let stdout_fd = fork_pty_res.master; // Первичная сторона if let ForkResult::Child = fork_pty_res.fork_result { // Вторичная сторона pty Command::new(&default_shell) .spawn() .expect("failed to spawn"); std::thread::sleep(std::time::Duration::from_millis(2000)); std::process::exit(0); } stdout_fd } Err(e) => { panic!("failed to fork {:?}", e); } }}
Заметь, в этом коде мы используем библиотеку nix
— обертку для взаимодействия с небезопасным кодом на C. Функция forkpty
из библиотеки libc
разветвляет текущий процесс. Выражение match
, внутри которого выполняется функция forkpty
, сверяет результат функции с предопределенными шаблонами и отдает управление на ветку кода с соответствующим шаблоном.
Иначе говоря, выражения match
языка Rust имеют ту же смысловую нагрузку, что оператор switch
в C++. Отсюда следует: если forkpty
успешно разветвила процесс и вернула OK
, то программа продолжает выполнение в двух потоках. Главный поток сохраняет в переменную stdout_fd
дескриптор файлового потока — STDOUT, а в дочернем потоке происходит запуск дефолтной оболочки, полученной функцией spawn_pty_with_shell
в параметре default_shell
. После этого мы ожидаем две секунды, чтобы командная строка загрузилась, а затем закрываем ее процесс, то есть выходим.
Далее в главном потоке функция spawn_pty_with_shell
возвращает дескриптор файлового потока (STDOUT) в main
, чтобы та передала его функции read_from_fd
для чтения данных из дочернего потока. Если forkpty
возвращает ошибку, вызывается макрос panic
, который возвращает сообщение об ошибке и немедленно завершает приложение, потому что в таком случае его выполнение бесполезно.
Рассмотрим следующую функцию — read_from_fd
:
use nix::unistd::read;fn read_from_fd(fd: RawFd) -> Option<Vec<u8>> { // https://linux.die.net/man/7/pipe let mut read_buffer = [0; 65536]; let read_result = read(fd, &mut read_buffer); match read_result { Ok(bytes_read) => Some(read_buffer[..bytes_read].to_vec()), Err(_e) => None, }}
Приняв в параметре файловый дескриптор fd
, который был получен из функции spawn_pty_with_shell
, мы передаем его вместе с изменяемым буфером памяти размером 65 536 байтов функции read
. Она читает указанный объем байтов, помещает его в переданный буфер и возвращает количество прочитанных байтов. При условии, что чтение завершилось успешно (значение переменной read_result
больше нуля), прочитанный буфер превращается в вектор. Затем этот вектор возвращается в основную функцию. На моей машине вывод выглядит так:
"\
helpfor
Fish
В качестве дефолтной командной оболочки в моей системе функционирует Fish. Ее установка стандартна: sudo
. Теперь посмотрим, какие оболочки установлены у нас в системе: cat /
.
Тем не менее Fish не Terminator, и сделать ее дефолтной оболочкой стандартным приемом не получится.
Между тем можно пойти другим путем и отредактировать переменную окружения $SHELL
. Чтобы просмотреть, какая оболочка записана в ней в данный момент, достаточно ввести команду chsh
. Напротив, чтобы прописать новое значение (в нашем случае оболочку Fish), надо выполнить команду: chsh
.
Unicode
В оригинальной статье представлен вывод, состоящий из ANSI-символов. Однако в нашем случае операционная система выводит текст в Unicode-символах. Можно предположить, что у автора операционка чисто английская, у меня же операционка локализирована на русский. К счастью, остались в прошлом русские ANSI-кодировки, которые из‑за своей несовместимости отображали документы кракозябрами. Теперь бал правят Unicode-кодировки, которые прекрасно выводят символы не только кириллицы, но и всех остальных языков мира. На тему борьбы с кодировками десять лет назад я написал статью. Надеюсь, будет интересно и познавательно.
В выводе мы можем наблюдать обычный текст, обильно приправленный escape-последовательностями Unicode. Раньше мы обсуждали escape-последовательности ANSI. Unicode-последовательности отличаются от рассмотренных тем, что могут содержать большее разнообразие символов, однако такой подход не используется из‑за необходимости обеспечить совместимость с устаревшим аппаратным и программным обеспечением. И вряд ли когда будет использоваться даже в новом оборудовании или программном обеспечении, ведь любую escape-последовательность можно составить из ANSI-символов.
Давай посмотрим, как эмулятор терминала интерпретирует escape-последовательности. Escape-символ \
используется для обозначения начала или конца escape-последовательности. Два начальных символа \
указывают на то, что данный символ относится к Unicode-символам. У ANSI-символов такого нет.
Независимо от используемого для вывода языка любой текстуальный интерфейс, который выводится в терминале, работает одинаковым образом.
Используя команду echo
, можно выполнить в терминале такую инструкцию:
echo -e "I am some \033[38;5;9mred text!»
033 — восьмеричное представление escape-символа. Запись \
представляет его в шестнадцатеричном виде. Нетрудно догадаться, что в десятичном виде символ будет выглядеть как 27.
В результате мы увидим следующий вывод.
Теперь давай взглянем на более сложный пример. Ниже показана крупная escape-последовательность, специально подготовленная для этого примера:
[1H[J[8;20H[38;2;167;216;255m!_[B[2D|*`~-.,[B[7D|.-~^`[2;51H!_[B[2D|*~=-.,[B[7D|_,-'`[14;12H!_[B[2D|*`--,[B[6D|.-'[16;34H!_[B[2D|*`~-.,[B[7D|_,-~`[14;43H!_[B[2D|*`~-.,[B[7D|_.-"`[11;20H[38;2;227;176;110m|[B[D|[5;51H|[B[D|[17;12H|[19;34H|[17;43H|[18;11H[38;2;190;97;107m/^\[B[4D/ \[B[6D/, \[B[8D/#" \[B[10D/#
#_ _ \[7;50H/^\[B[4D/ \[B[6D/, \[B[8D/#" \[B[10D/##_ _ \[18;42H[38;2;190;97;107m/^\[B[4D/ \[B[6D/, \[B[8D/#" \[B[10D/##_ _ \[22;25H[38;2;167;216;255m_____[B[8D0~{_ _ _}~0[B[9D| , |[B[7D| ((* |[B[7D| ` |[B[6D`-.-`[30;25H[38;2;227;176;110m_,--,_[B[7D/ | | \[B[8D| | | |[B[8D| | <&>[B[8D| | | |[B[8D| | | |[28;12H_[B[2D/+\[B[4D|+|+|[B[5D|+|+|[B[5D|+|+|[B[5D^^^^^[28;43H_[B[2D/+\[B[4D|+|+|[B[5D|+|+|[B[5D|+|+|[B[5D^^^^^[25;57H___[B[3D/+\[B[3D|+|[B[3D|+|[B[3D^^^[11;45H[38;2;112;117;121m_[3C_[3C_[3C_[B[14D[ ]_[ ]_[ ]_[ ][12;15H_[3C_[3C_[3C_[3C_[13;14H[ ]_[ ]_[ ]_[ ]_[ ] |_=_-=_ - =_|[14;15H|_=_ =-_-_ = =_|[14;46H|=_= - |[15;18H_- _ |[15;50H= [] |[16;16H|= [] |[16;49H_- |[17;16H|_=- - |[17;46H|=_- |[18;16H|=_= - |[18;46H|_ - =[] |[19;7H_[19;15H_|_=- _ _ _| _[19;37H_[19;46H|=_- |[20;6H[ ][20;16H[ ]_[ ]_[ ]_[ ]_[ ]_[ ]_[20;47H[ ]=- |[21;7H|[21;17H_=-___=__=__- =-_ -=_[21;48H| _ [] |[22;6H_[3C_[3C_[3C_-_ =[22;37H_[3C_[3C_[3C_ - |\[23;5H[ ]_[ ]_[ ]_[ ]=_[23;36H[ ]_[ ]_[ ]_[ ]=- | \[24;5H|_=__-_=-_ =_|-=_[24;36H|_=-___-_ =-__|_ | \[25;6H| _- =- |-_[25;37H|= _= | - |[3C\[26;6H|= -_= |= _[26;37H|_-=_ |=_ |[3C|[27;6H| =_- |_ = _[27;37H| =_ = = |=_- |[3C|[28;6H|-_=-[28;18H|=_ = |=_= -[28;49H| = |[3C|[29;6H|=_-[29;18H| -= |_=-[29;49H|=_ |[3C|[30;6H|=_[30;18H|= - -[30;37H|_=[30;49H| -_ |= |[31;6H| -[31;18H|-_=[31;37H|=_[31;49H|-=_ |_-/[32;6H|=_=[32;18H| =_=[32;37H|_-[32;49H|_ = |=/[33;6H| _[33;18H|= -[33;37H|=_=[33;49H|_=- |/[34;6H|=_ = | =_-_[34;37H| =_ | -_ |[35;6H|_=-_ |=_=[35;37H|=_= |=- |[36;1H[38;2;163;189;141m^^^^^^^^^^`^`^^`^`^`^^^»"""""""^`^^``^^`^^`^^`^`^``^`^``^``^^
Если ее правильно интерпретировать, мы получим такое изображение.
Однако не спеши! Если схватить приведенную последовательность и скормить ее команде echo
или cat
, результат не оправдает ожидания, иначе говоря, последовательность не будет интерпретирована. Почему же это происходит?
Дело в том, что веб‑страницы плохо подходят для отображения символов escape-последовательностей. И при копировании через буфер обмена много ценной информации теряется. Но выход есть: ты можешь воспользоваться файлом Castle
из папки raw_castle_ansi
, находящейся среди примеров к этой статье. Клонируй или скачай в zip-архиве этот файл, натрави на него команду cat
, и в терминале отобразится то, что надо.
Escape-коды ANSI используются во многих областях. Наиболее известные из них: CSI (Control Sequence Introducers — проводники контрольного ряда), OSC (Operating System Commands — команды операционной системы) и прямые escape-последовательности. Описание их основ и выполняемых операций выходит за рамки этой статьи, однако внизу я оставлю ссылки на материалы, с помощью которых ты сам во всем разберешься.
Создание пользовательского интерфейса внутри терминала
Для многих языков программирования существует большое количество замечательных библиотек пользовательских элементов управления. Они позволяют создавать текстуальные приложения, выполняемые в терминале. В этом разделе мы подробно рассмотрим функционирование таких либ. Поэтому материал будет полезен, даже если ты собираешься использовать либы высокого уровня.
Специальный набор символов
Когда мы рассматривали вывод оболочки Fish, то обратили пристальное внимание на инструкцию \
. Мы определили этот escape-символ как границу escape-последовательности. Другими словами, за этим символом escape-последовательности обрабатываются по‑другому. Сначала текстовый поток может читаться и выводиться как обычный текст, а встретив этот стоп‑символ, интерпретатор будет обрабатывать символы особым образом, образуя специальный набор символов. Он может использоваться, например, для рисования текстуальных интерфейсов. Escape-символ \
представлен в шестнадцатеричном виде, и, как сказано выше, в восьмеричном представлении он имеет вид 033.
Рассмотрим пример. Выполни в терминале следующую команду:
echo -e "\033(0lqqqk\nx x\nmqqqj»
После переключения на использование «специальных символов» мы можем рисовать фигуры посложнее, а во время рисования — переключать вывод обратно для отображения обычных символов. Загони в терминал такой набор текста:
echo
lq \
x
x
x \
l
x\
m
x \
x
x
mqqqqqqqqqqqqqj
Он интерпретируется в такой вывод.
В предыдущем примере мы переключаемся с обычного набора символов на специальный и обратно.
Ниже представлена таблица специальных символов, которые можно использовать в текстуальном рисовании.
SIGWINCH — отзывчивый пользовательский интерфейс в терминале
В последние годы под влиянием всемирной паутины и динамических веб‑страниц стал популярен термин «отзывчивый UI», относящийся к приложениям, способным изменять свой интерфейс в зависимости от размеров окна.
Пользователи консольных приложений тоже часто изменяют размер и расположение окна терминала на рабочем столе, чтобы отображать текстовую информацию рядом с другими оконными приложениями или чтобы наблюдать данные из нескольких терминалов одновременно.
Разрабатывая текстуальные приложения, работающие в окне терминала, мы тоже можем воспользоваться парадигмой «отзывчивого UI» и создавать крутой «пользовательский опыт», не только подгоняя приложения к размеру окна, но и отображая разные элементы в зависимости от этого размера.
Чтобы сделать это, мы можем прослушивать сигнал SIGWINCH
, который генерируется каждый раз в запущенном в данный момент процессе, когда окно терминала меняет размер. Получив этот сигнал, мы изменим отображение элементов управления в соответствии с изменившимся размером окна терминала. Это демонстрирует следующий пример:
use nix::pty::Winsize;use signal_hook::{consts::signal::*, iterator::Signals};use std::io::prelude::*;fn get_terminal_size() -> (u16, u16) { // Строки и столбцы use libc::ioctl; use libc::TIOCGWINSZ; let mut winsize = Winsize { ws_row: 0, ws_col: 0, ws_xpixel: 0, ws_ypixel: 0, }; unsafe { ioctl(1, TIOCGWINSZ, &mut winsize) }; (winsize.ws_row, winsize.ws_col)}fn draw_rect(rows: u16, columns: u16, x: u16, y: u16, middle_text: &str) { let top_and_bottom_edge = "q".repeat(columns as usize - 2); let blank_middle = " ".repeat(columns as usize - 2); for y_index in y..y + rows { if y_index == y { print!( "\u{1b}[{};{}H\u{1b}(0l{}k", y_index + 1, x, top_and_bottom_edge ); } else if y_index == (y + rows) - 1 { print!( "\u{1b}[{};{}H\u{1b}(0m{}j", y_index + 1, x, top_and_bottom_edge ); } else { print!("\u{1b}[{};{}H\u{1b}(0x{}x", y_index + 1, x, blank_middle); } } print!( "\u{1b}(B\u{1b}[{};{}H{}", y + rows / 2 + 1, x + (columns - middle_text.chars().count() as u16) / 2, middle_text );}fn side_by_side_ui(rows: u16, columns: u16, left_text: &str, right_text: &str) { let left_rect_rows = rows; let left_rect_columns = columns / 2; let left_rect_x = 0; let left_rect_y = 0; let right_rect_rows = rows; let right_rect_columns = columns / 2; let right_rect_x = (columns / 2) + 1; let right_rect_y = 0; draw_rect( left_rect_rows, left_rect_columns, left_rect_x, left_rect_y, left_text, ); draw_rect( right_rect_rows, right_rect_columns, right_rect_x, right_rect_y, right_text, );}fn top_and_bottom_ui(rows: u16, columns: u16, top_text: &str, bottom_text: &str) { let top_rect_rows = rows / 2; let top_rect_columns = columns; let top_rect_x = 0; let top_rect_y = 0; let bottom_rect_rows = rows / 2 + 1; let bottom_rect_columns = columns; let bottom_rect_x = 0; let bottom_rect_y = rows / 2; draw_rect( top_rect_rows, top_rect_columns, top_rect_x, top_rect_y, top_text, ); draw_rect( bottom_rect_rows, bottom_rect_columns, bottom_rect_x, bottom_rect_y, bottom_text, );}fn draw_ui() { println!("\u{1b}[H\u{1b}[J"); // Очистить экран println!("\u{1b}[?25l"); // Скрыть курсор let primary_text = "Тут немного произвольного текста"; let secondary_text = "Здесь тоже! Плюс пожимающий плечами смайлик: ¯\\_(ツ)_/¯"; let min_side_width = std::cmp::max( primary_text.chars().count(), secondary_text.chars().count() ) as u16 + 2; // 2 для границы let (rows, columns) = get_terminal_size(); if columns / 2 > min_side_width { side_by_side_ui(rows, columns, primary_text, secondary_text); } else if columns > min_side_width { top_and_bottom_ui(rows, columns, primary_text, secondary_text); } else { println!("\u{1b}(Прости, терминал слишком маленький!") } let _ = std::io::stdout().flush();}fn main() { let mut signals = Signals::new(&[ SIGWINCH, SIGTERM, SIGINT, SIGQUIT, SIGHUP ]).unwrap(); draw_ui(); for signal in signals.forever() { match signal { SIGWINCH => { draw_ui(); } SIGTERM | SIGINT | SIGQUIT | SIGHUP => { break; } _ => unreachable!(), } } println!("\u{1b}[H\u{1b}[J"); // Очистить экран println!("\u{1b}[?25h"); // Показать курсор}
Чтобы поймать нужный сигнал, мы используем ящик signal-hook
(ты же помнишь, что подключаемые либы в Rust называются ящиками?). Внутри функции main
разворачивается следующий алгоритм:
- происходит первоначальный вывод графических элементов с помощью функции
draw_ui(
;) - запускается цикл по всем сигналам, поступающим в приложение;
- ожидается сигнал
SIGWINCH
, поступающий в момент изменения размера окна терминала; - после его получения вызывается функция перерисовки элементов управления —
draw_ui(
.)
Для корректной перерисовки содержимого окна терминала нам необходимо знать его размер, который нам подскажет системный вызов ioctl
. Он выполняется в функции getTerminalSize(
. Последняя вызывается из draw_ui(
.
Затем мы ждем поступления сигналов SIGTERM
, SIGINT
, SIGQUIT
и SIGHUP
, которые сигнализируют о завершении работы приложения. Когда обнаруживается один из них, цикл перебора сигналов прерывается. Содержимое окна терминала очищается, и отображается курсор.
В зависимости от размера окно терминала может иметь три разных содержания.
Необрабатываемый режим
При разработке приложений для терминала мы хотим более тонко контролировать пользовательский ввод. По умолчанию канал ввода STDIN не позволяет нам этого добиться. Рассмотрим следующий пример. Допустим, приложение задает такой прямолинейный вопрос:
Вы
Однако, как мы обсуждали в начале статьи, pty построчно буферизирует ввод STDIN. Это означает, что приложение сможет прочесть ввод только после того, как пользователь нажмет Enter, предварительно введя однобуквенный ответ.
Чтобы избежать этой проблемы, мы можем переключить терминал в необрабатываемый режим. В таком случае нам удается достигнуть следующих преимуществ:
- STDIN читает ввод посимвольно;
- команда
echo
перестает работать.
Специальная обработка ввода отключается (нажатие комбинации клавиш Ctrl + C больше не отправляет сигнал SIGINT терминалу).
Рассмотрим небольшую программку, демонстрирующую описанное:
use nix::sys::termios;fn set_raw_mode() { let mut tio = termios::tcgetattr(0).expect("невозможно получить атрибут терминала"); termios::cfmakeraw(&mut tio); match termios::tcsetattr(0, termios::SetArg::TCSANOW, &tio) { Ok(_) => {} Err(e) => panic!("ошибка {:?}", e), };}fn main() { set_raw_mode(); let mut buffer = [0; 1]; let stdin = io::stdin(); let mut handle = stdin.lock(); loop { print!("\n\r"); print!("Вы хотите завершить программу? [д/н]»); io::stdout().lock().flush().unwrap(); handle.read_exact(&mut buffer).unwrap(); if buffer == [108] { // д break; } }}
Обрати внимание: в этой программе снова используется ящик nix
, но на этот раз из него нам нужен интерфейс termios
. Он позволяет получить контроль над терминалом, в котором выполняется наше приложение, и переключить его в необрабатываемый режим. Передаваемое команде tcgetattr
в качестве параметра число означает следующее: 0 — файловый дескриптор STDIN, 1 — STDOUT. В рассматриваемой программе оба значения эквивалентны.
Необрабатываемый режим — широко распространенное свойство терминальных приложений. В нем пользовательский ввод не дублируется, и приложение может выполнять с ним любые действия:
- изменять цвет шрифта в зависимости от корректности команды (как во многих современных командных оболочках);
- контролировать вводимый текст, чтобы он не вылезал за границы определенных элементов управления;
- реагировать непосредственно на вводимые символы, не дожидаясь нажатия клавиши Enter.
После запуска приложения нажатие любой клавиши будет дублировать строку вопроса: «Вы хотите завершить программу? [д/н]», нажатие клавиши с буквой д
завершит приложение.
Используя приобретенные знания, сделаем подлянку!
Помнишь избитую мантру: «В Linux все является файлом»? Pty, как нетрудно догадаться, тоже файл. Поэтому мы можем найти наш pty с помощью команды ps
.
Я запустил несколько терминалов, среди которых Bash, Fish и Terminator. Команда ps
в столбце TTY
вывела номера псевдотерминалов, к которым они подключены. Таким образом, pts/
соответствует /
. Давай пошлем «Привет!» от /
к /
.
В примере выше мы перенаправили STDOUT команды echo
в STDOUT соседнего псевдотерминала (pty). У нас получилось организовать чат из 80-х годов прошлого века! Мы можем передавать не только читаемые символы, но и escape-последовательности ANSI.
В примере выше мы отправили строку «Всем\033[10Cпривет!» из /
в /
. И эмулятор терминала на /
интерпретировал escape-последовательность по всем правилам. В этом случае мы сказали: «Прежде чем вывести второе слово, передвинь курсор на десять колонок вперед».
Припомни коллегам (одноклассникам, одногруппникам) наставления о том, что нельзя подключаться к серверу под одной учеткой. Есть escape-код, делающий курсор невидимым: echo
. Обратного эффекта можно добиться, выполнив команду echo
. Ясное дело, приведенные команды воздействуют на «свой» терминал. Мягко говоря, будет странно, если у всех пользователей в консоли пропадет курсор. Чтобы этого достичь, надо в оболочке Bash запустить нехитрый код:
for i in $(ls /dev/pts | grep -v ptmx); do echo -en "\033[?25l" > /dev/pts/$i; done
Заключение
В этой статье мы в деталях рассмотрели разные части эмулятора терминала, а также то, как они взаимодействуют. Мы написали несколько программ на языке Rust, чтобы продемонстрировать возможности платформы и показать наглядные примеры того, как данные интерпретируются и отображаются на экране.
Я надеюсь, ты узнал что‑то новое или хотя бы углубил имеющиеся знания. Также я надеюсь, что ты вдохновился продолжить разработку приложений для этой платформы.
www
- The TTY demystified — замечательная статья про TTY/pty, историю терминалов в Linux/Unix
- ANSI escape code (Wikipedia) — базовые разъяснения по теме escape-последовательностей ANSI
- Build your own Command Line with ANSI escape codes — великолепное введение в использование escape-кодов вместе с Python
- Valid ANSI Mode Control Sequences — полезный ресурс по той же тематике