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

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 new HelloRust.

В резуль­тате будет соз­дан про­ект с таким содер­жимым.

Содержимое папки с проектом
Со­дер­жимое пап­ки с про­ектом

Ис­ходный код прог­раммы HelloRust содер­жится в фай­ле main.rs, который, в свою оче­редь, находит­ся в пап­ке src.

Файл Cargo.toml содер­жит метадан­ные опи­сания про­екта:

[package]
name = "HelloRust"
version = "0.1.0"
edition = "2022"
[dependencies]

Кро­ме того, в нем ука­зыва­ются биб­лиоте­ки, исполь­зуемые для ком­пиляции прог­раммы (ниже клю­чево­го сло­ва [dependencies]). Все рас­смат­рива­емые в статье при­меры обра­щают­ся к либе nix = "0.17.0".

Что­бы ском­пилиро­вать при­мер, надо выпол­нить коман­ду cargo build, находясь в пап­ке с про­ектом. А что­бы запус­тить про­ект на выпол­нение, нуж­но наб­рать коман­ду cargo run. Она не толь­ко запус­тит прог­рамму на выпол­нение, но и пересо­берет ее, если в исходный код будут вне­сены изме­нения.

Где писать 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.

Процесс ls получает от операционной системы данные и передает их сначала командной оболочке, а та, в свою очередь, по каналу STDOUT pty эмулятору терминала
Про­цесс ls получа­ет от опе­раци­онной сис­темы дан­ные и переда­ет их сна­чала коман­дной обо­лоч­ке, а та, в свою оче­редь, по каналу STDOUT pty эму­лято­ру тер­минала
 

Как эмулятор терминала интерпретирует и отображает данные, полученные из командной оболочки?

Ког­да коман­дная обо­лоч­ка отправ­ляет текст тер­миналу, она исполь­зует весь дос­тупный ей набор инс­трук­ций. Ты мыс­лишь в пра­виль­ном нап­равле­нии, если наз­вал этот набор 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 боль­ше нуля), про­читан­ный буфер прев­раща­ется в век­тор. Затем этот век­тор воз­вра­щает­ся в основную фун­кцию. На моей машине вывод выг­лядит так:

"\u{1b}]0;fish /home/yurembo/rust_projects/spawn_and_read_from_shell\u{7}\u{1b}[30m\u{1b}(B\u{1b}[m\u{1b}[2m⏎\u{1b}(B\u{1b}[m \r⏎ \r\u{1b}[KWelcome to fish, the friendly interactive shell\r\nTypehelpfor instructions on how to use fish\r\n\u{1b}[?2004h\u{1b}]7;file://yurembo-VirtualBox/home/yurembo/rust_projects/spawn_and_read_from_shell\u{7}\u{1b}]0;fish /home/yurembo/rust_projects/spawn_and_read_from_shell\u{7}\u{1b}[30m\u{1b}(B\u{1b}[m\u{1b}[92myurembo\u{1b}(B\u{1b}[m@\u{1b}(B\u{1b}[myurembo-VirtualBox\u{1b}(B\u{1b}[m \u{1b}[32m~/r/spawn_and_read_from_shell\u{1b}(B\u{1b}[m\u{1b}(B\u{1b}[m> \u{1b}[K\u{1b}]0;fish /home/yurembo/rust_projects/spawn_and_read_from_shell\u{7}\u{1b}[30m\u{1b}(B\u{1b}[m\r\u{1b}[92myurembo\u{1b}(B\u{1b}[m@\u{1b}(B\u{1b}[myurembo-VirtualBox\u{1b}(B\u{1b}[m \u{1b}[32m~/r/spawn_and_read_from_shell\u{1b}(B\u{1b}[m\u{1b}(B\u{1b}[m> \u{1b}[K\r\n\u{1b}[30m\u{1b}(B\u{1b}[m"

Вывод первых 65 536 байтов после запуска дефолтной оболочки
Вы­вод пер­вых 65 536 бай­тов пос­ле запус­ка дефол­тной обо­лоч­ки

Fish

В качес­тве дефол­тной коман­дной обо­лоч­ки в моей сис­теме фун­кци­они­рует Fish. Ее уста­нов­ка стан­дар­тна: sudo apt-get install fish. Теперь пос­мотрим, какие обо­лоч­ки уста­нов­лены у нас в сис­теме: cat /etc/shells/.

Командные оболочки, установленные у меня в системе
Ко­ман­дные обо­лоч­ки, уста­нов­ленные у меня в сис­теме

Тем не менее Fish не Terminator, и сде­лать ее дефол­тной обо­лоч­кой стан­дар­тным при­емом не получит­ся.

В списке отсутствует Fish
В спис­ке отсутс­тву­ет Fish

Меж­ду тем мож­но пой­ти дру­гим путем и отре­дак­тировать перемен­ную окру­жения $SHELL. Что­бы прос­мотреть, какая обо­лоч­ка записа­на в ней в дан­ный момент, дос­таточ­но ввес­ти коман­ду chsh. Нап­ротив, что­бы про­писать новое зна­чение (в нашем слу­чае обо­лоч­ку Fish), надо выпол­нить коман­ду: chsh -s /usr/bin/fish.

В моей системе Fish уже значится оболочкой по умолчанию
В моей сис­теме Fish уже зна­чит­ся обо­лоч­кой по умол­чанию

Unicode

В ори­гиналь­ной статье пред­став­лен вывод, сос­тоящий из ANSI-сим­волов. Одна­ко в нашем слу­чае опе­раци­онная сис­тема выводит текст в Unicode-сим­волах. Мож­но пред­положить, что у авто­ра опе­раци­онка чис­то англий­ская, у меня же опе­раци­онка локали­зиро­вана на рус­ский. К счастью, оста­лись в прош­лом рус­ские ANSI-кодиров­ки, которые из‑за сво­ей несов­мести­мос­ти отоб­ражали докумен­ты кра­козяб­рами. Теперь бал пра­вят Unicode-кодиров­ки, которые прек­расно выводят сим­волы не толь­ко кирил­лицы, но и всех осталь­ных язы­ков мира. На тему борь­бы с кодиров­ками десять лет назад я написал статью. Наде­юсь, будет инте­рес­но и поз­наватель­но.

В выводе мы можем наб­людать обыч­ный текст, обиль­но прип­равлен­ный escape-пос­ледова­тель­нос­тями Unicode. Рань­ше мы обсужда­ли escape-пос­ледова­тель­нос­ти ANSI. Unicode-пос­ледова­тель­нос­ти отли­чают­ся от рас­смот­ренных тем, что могут содер­жать боль­шее раз­нооб­разие сим­волов, одна­ко такой под­ход не исполь­зует­ся из‑за необ­ходимос­ти обес­печить сов­мести­мость с уста­рев­шим аппа­рат­ным и прог­рам­мным обес­печени­ем. И вряд ли ког­да будет исполь­зовать­ся даже в новом обо­рудо­вании или прог­рам­мном обес­печении, ведь любую escape-пос­ледова­тель­ность мож­но сос­тавить из ANSI-сим­волов.

Да­вай пос­мотрим, как эму­лятор тер­минала интер­пре­тиру­ет escape-пос­ледова­тель­нос­ти. Escape-сим­вол \u{1b} исполь­зует­ся для обоз­начения начала или кон­ца escape-пос­ледова­тель­нос­ти. Два началь­ных сим­вола \u ука­зыва­ют на то, что дан­ный сим­вол отно­сит­ся к Unicode-сим­волам. У ANSI-сим­волов такого нет.

Не­зави­симо от исполь­зуемо­го для вывода язы­ка любой тек­сту­аль­ный интерфейс, который выводит­ся в тер­минале, работа­ет оди­нако­вым обра­зом.

Ис­поль­зуя коман­ду echo, мож­но выпол­нить в тер­минале такую инс­трук­цию:

echo -e "I am some \033[38;5;9mred text!»

033 — вось­мерич­ное пред­став­ление escape-сим­вола. Запись \u{1b} пред­став­ляет его в шес­тнад­цатерич­ном виде. Нет­рудно догадать­ся, что в десятич­ном виде сим­вол будет выг­лядеть как 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[6
D`-.-`[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;1
5H|_=_ =-_-_ = =_|[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;1
7H_=-___=__=__- =-_ -=_[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|_ = |=/[3
3;6H| _[33;18H|= -[33;37H|=_=[33;49H|_=- |/[34;6H|=_ = | =_-_[34;37H|
=_ | -_ |[35;6H|_=-_ |=_=[35;37H|=_= |=- |[36;1H[38;2;1
63;189;141m^^^^^^^^^^`^`^^`^`^`^^^»"""""""^`^^``^^`^^`^^`^`^``^`^``^``^^

Ес­ли ее пра­виль­но интер­пре­тиро­вать, мы получим такое изоб­ражение.

Замок
За­мок

Од­нако не спе­ши! Если схва­тить при­веден­ную пос­ледова­тель­ность и скор­мить ее коман­де echo или cat, резуль­тат не оправда­ет ожи­дания, ина­че говоря, пос­ледова­тель­ность не будет интер­пре­тиро­вана. Почему же это про­исхо­дит?

Де­ло в том, что веб‑стра­ницы пло­хо под­ходят для отоб­ражения сим­волов escape-пос­ледова­тель­нос­тей. И при копиро­вании через буфер обме­на мно­го цен­ной информа­ции теря­ется. Но выход есть: ты можешь вос­поль­зовать­ся фай­лом Castle из пап­ки raw_castle_ansi, находя­щей­ся сре­ди при­меров к этой статье. Кло­нируй или ска­чай в zip-архи­ве этот файл, нат­рави на него коман­ду cat, и в тер­минале отоб­разит­ся то, что надо.

Escape-коды ANSI исполь­зуют­ся во мно­гих областях. Наибо­лее извес­тные из них: CSI (Control Sequence Introducers — про­вод­ники кон­троль­ного ряда), OSC (Operating System Commands — коман­ды опе­раци­онной сис­темы) и пря­мые escape-пос­ледова­тель­нос­ти. Опи­сание их основ и выпол­няемых опе­раций выходит за рам­ки этой статьи, одна­ко вни­зу я оставлю ссыл­ки на матери­алы, с помощью которых ты сам во всем раз­берешь­ся.

 

Создание пользовательского интерфейса внутри терминала

Для мно­гих язы­ков прог­рамми­рова­ния сущес­тву­ет боль­шое количес­тво замеча­тель­ных биб­лиотек поль­зователь­ских эле­мен­тов управле­ния. Они поз­воля­ют соз­давать тек­сту­аль­ные при­ложе­ния, выпол­няемые в тер­минале. В этом раз­деле мы под­робно рас­смот­рим фун­кци­они­рова­ние таких либ. Поэто­му матери­ал будет полезен, даже если ты собира­ешь­ся исполь­зовать либы высоко­го уров­ня.

 

Специальный набор символов

Ког­да мы рас­смат­ривали вывод обо­лоч­ки Fish, то обра­тили прис­таль­ное вни­мание на инс­трук­цию \u{1b}. Мы опре­дели­ли этот escape-сим­вол как гра­ницу escape-пос­ледова­тель­нос­ти. Дру­гими сло­вами, за этим сим­волом escape-пос­ледова­тель­нос­ти обра­баты­вают­ся по‑дру­гому. Сна­чала тек­сто­вый поток может читать­ся и выводить­ся как обыч­ный текст, а встре­тив этот стоп‑сим­вол, интер­пре­татор будет обра­баты­вать сим­волы осо­бым обра­зом, обра­зуя спе­циаль­ный набор сим­волов. Он может исполь­зовать­ся, нап­ример, для рисова­ния тек­сту­аль­ных интерфей­сов. Escape-сим­вол \u{1b} пред­став­лен в шес­тнад­цатерич­ном виде, и, как ска­зано выше, в вось­мерич­ном пред­став­лении он име­ет вид 033.

Рас­смот­рим при­мер. Выпол­ни в тер­минале сле­дующую коман­ду:

echo -e "\033(0lqqqk\nx x\nmqqqj»
Квадрат, начерченный с помощью escape-последовательности
Квад­рат, начер­ченный с помощью escape-пос­ледова­тель­нос­ти

Пос­ле перек­лючения на исполь­зование «спе­циаль­ных сим­волов» мы можем рисовать фигуры пос­ложнее, а во вре­мя рисова­ния — перек­лючать вывод обратно для отоб­ражения обыч­ных сим­волов. Загони в тер­минал такой набор тек­ста:

echo -en "\033(0\
lq \033(BTests \033(0qqqqqk lq lq \033(BAssertion error\033(0 qk\n\
x x x x x\n\
x x x x \033(B! Should equal !\033(0 x\n\
x \033(B[ #1 Fail ]\033(0 tqqqqqqqu x x\n\
l k x x x x \033(BLeft: 50\033(0 x\n\
x\033(B Rerunning in 10s\033(0 tqqqqqqu \033(B#2 Fail\033(0 x x x x\n\
m j x x x x \033(BRight: 48\033(0 x\n\
x \033(B#3 Pass\033(0 x x x x\n\
x x x x \033(B[test.rs line 25]\033(0 x\n\
x x x x x\n\
mqqqqqqqqqqqqqj mq mqqqqqqqqqqqqqqqqqqqj\

Он интер­пре­тиру­ется в такой вывод.

В пре­дыду­щем при­мере мы перек­люча­емся с обыч­ного набора сим­волов на спе­циаль­ный и обратно.

Ни­же пред­став­лена таб­лица спе­циаль­ных сим­волов, которые мож­но исполь­зовать в тек­сту­аль­ном рисова­нии.

 

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.
Raw Mode
Raw Mode

Пос­ле запус­ка при­ложе­ния нажатие любой кла­виши будет дуб­лировать стро­ку воп­роса: «Вы хотите завер­шить прог­рамму? [д/н]», нажатие кла­виши с бук­вой д завер­шит при­ложе­ние.

 

Используя приобретенные знания, сделаем подлянку!

Пом­нишь изби­тую ман­тру: «В Linux все явля­ется фай­лом»? Pty, как нет­рудно догадать­ся, тоже файл. Поэто­му мы можем най­ти наш pty с помощью коман­ды ps.

Я запус­тил нес­коль­ко тер­миналов, сре­ди которых Bash, Fish и Terminator. Коман­да ps в стол­бце TTY вывела номера псев­дотер­миналов, к которым они под­клю­чены. Таким обра­зом, pts/1 соот­ветс­тву­ет /dev/pts/1. Давай пош­лем «При­вет!» от /dev/pts/1 к /dev/pts/4.

В при­мере выше мы перенап­равили STDOUT коман­ды echo в STDOUT сосед­него псев­дотер­минала (pty). У нас получи­лось орга­низо­вать чат из 80-х годов прош­лого века! Мы можем переда­вать не толь­ко чита­емые сим­волы, но и escape-пос­ледова­тель­нос­ти ANSI.

В при­мере выше мы отпра­вили стро­ку «Всем\033[10Cпри­вет!» из /dev/pts/4 в /dev/pts/0. И эму­лятор тер­минала на /dev/pts/0 интер­пре­тиро­вал escape-пос­ледова­тель­ность по всем пра­вилам. В этом слу­чае мы ска­зали: «Преж­де чем вывес­ти вто­рое сло­во, перед­винь кур­сор на десять колонок впе­ред».

При­пом­ни кол­легам (одноклас­сни­кам, одногруп­пни­кам) нас­тавле­ния о том, что нель­зя под­клю­чать­ся к сер­веру под одной учет­кой. Есть escape-код, дела­ющий кур­сор невиди­мым: echo -en "\033[?25l". Обратно­го эффекта мож­но добить­ся, выпол­нив коман­ду echo -en «\033[?25h". Ясное дело, при­веден­ные коман­ды воз­дей­ству­ют на «свой» тер­минал. Мяг­ко говоря, будет стран­но, если у всех поль­зовате­лей в кон­соли про­падет кур­сор. Что­бы это­го дос­тичь, надо в обо­лоч­ке Bash запус­тить нехит­рый код:

for i in $(ls /dev/pts | grep -v ptmx); do echo -en "\033[?25l" > /dev/pts/$i; done
Обрати внимание, ни в одном терминале не отображается текстовой курсор
Об­рати вни­мание, ни в одном тер­минале не отоб­ража­ется тек­сто­вой кур­сор
 

Заключение

В этой статье мы в деталях рас­смот­рели раз­ные час­ти эму­лято­ра тер­минала, а так­же то, как они вза­имо­дей­ству­ют. Мы написа­ли нес­коль­ко прог­рамм на язы­ке Rust, что­бы про­демонс­три­ровать воз­можнос­ти плат­формы и показать наг­лядные при­меры того, как дан­ные интер­пре­тиру­ются и отоб­ража­ются на экра­не.

Я наде­юсь, ты узнал что‑то новое или хотя бы углу­бил име­ющиеся зна­ния. Так­же я наде­юсь, что ты вдох­новил­ся про­дол­жить раз­работ­ку при­ложе­ний для этой плат­формы.

www

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    10 комментариев
    Старые
    Новые Популярные
    Межтекстовые Отзывы
    Посмотреть все комментарии