Читання введення користувача в Ruby є простим за допомогою gets
, але це блокує виконання програми, поки не буде натиснуто клавішу enter/return — не ідеально для інтерактивної оболонки. Щоб обробляти натискання клавіш в реальному часі, нам потрібно використовувати інший підхід.
У цій статті ми побудуємо інтерактивну оболонку з нуля, вивчаючи /dev/tty, raw-режим та ANSI escape-послідовності для миттєвого захоплення і обробки натискання клавіш.
Починаємо просто
Насамперед, при створенні інтерактивної оболонки в Ruby, можна досягти чималих результатів, використовуючи лише наступний код:
loop do
print "$ "
input = gets
puts input
end
Це створить оболонку, яка додаватиме перед курсором символ "$"
і виводитиме введені дані назад.
В середині, gets
фактично є STDIN.gets("\n")
— подивіться документацію ruby. Викликаючий метод постійно читає з стандартного введення, поки не зустріне "\n"
(символ нового рядка). Це фактично блокує виконання програми, поки ви не натиснете enter або return на клавіатурі, і не передає "\n"
до потоку введення для gets
.
Після того як символ нового рядка зустрічається, gets
повертає його разом з попередніми символами, що потім виводяться за допомогою puts
.
Просто, але не інтерактивно! Ми хочемо реагувати на введення користувача, поки він набирає текст! Це точно не можна зробити за допомогою gets
— нам потрібні інші інструменти для кращого контролю! ⚡
Збираємо наші інструменти
У наступних розділах ми зберемо три основні інструменти для кращого контролю введення! Після цього ми поєднаємо їх для створення інтерактивної оболонки.
1. Читання введення користувача: Стандартне введення проти /dev/tty
Перш ніж почати, нам потрібно зрозуміти, звідки ми повинні читати введення користувача. Раніше ми використовували STDIN
— але це можна перенаправити. У системах Unix є краща альтернатива: /dev/tty
, яка завжди читає безпосередньо з терміналу — подивіться Stackoverflow.
Експеримент 1: Використання STDIN з і без перенаправлення
# stdin.rb
c = 0
loop do
print "$ "
input = STDIN.gets
puts input
c < 3 ? (c += 1) : exit
end
Запуск без і з перенаправленням:
ruby stdin.rb
# ваше введення користувача
echo "hello\nworld\nfoo" | ruby stdin.rb
$ hello
$ world
$
Перший випадок працює як очікується! 👌 Що стосується другого, скрипт не чекав на моє введення, перш ніж вивести результат! Ми бачимо, що STDIN був заповнений автоматично командою echo
через перенаправлення за допомогою |
. Це робить STDIN ненадійним для інтерактивного введення.
Експеримент 2: Використання /dev/tty замість цього
Тепер спробуємо /dev/tty:
# tty.rb
ftty = File.new '/dev/tty'
loop do
print "$ "
input = ftty.gets
puts input
end
Спробуйте запустити це з і без перенаправлення. /dev/tty завжди читає безпосередньо з терміналу, що робить його ідеальним для нашого випадку 😃
Експеримент 3: Порівняння файлових дескрипторів
# tty_test.rb
puts "STDIN: fd #{STDIN.fileno}, tty? #{STDIN.tty?}"
ftty = File.new("/dev/tty")
puts "/dev/tty: fd #{ftty.fileno}, tty? #{ftty.tty?}"
Запустіть його звичайно:
ruby tty_test.rb
# STDIN: fd 0, tty? true
# /dev/tty: fd 6, tty? true
echo "hello world" | ruby tty_test.rb
# STDIN: fd 0, tty? false # <---- тепер false!
# /dev/tty: fd 6, tty? true
Різниця видна завдяки методу .tty?
— документація ruby. Коли ми виконуємо скрипт з перенаправленням, ми бачимо, що STDIN більше не пов’язаний з терміналом, а /dev/tty залишає його пов'язаним!👌
Отже, для надійної інтерактивної оболонки — ми використовуємо /dev/tty замість STDIN.
2.
Отримання одиничних натискань клавіш: .getch
Тепер, коли ми знаємо, звідки читати введення користувача, у нас є ще одна проблема: як захоплювати введення під час набору тексту? Нам потрібен спосіб реагувати на кожне натискання клавіші мігновенно, а не чекати, поки користувач натисне Enter.
Ось тут і приходить на допомогу бібліотека Ruby io/console
, а саме метод .getch
— документація Ruby.
Експеримент: Захоплення натискань клавіш
Спробуйте запустити цей скрипт і ввести hello:
# getch.rb
require 'io/console'
ftty = File.new('/dev/tty')
c = 0
loop do
char = ftty.getch
print char + " (#{char.ord}) " + "\n"
c < ("hello".length - 1) ? (c += 1) : exit
end
Ви побачите такий вивід:
ruby /tmp/getch.rb
(104) $ h
(101) $ e
(108) $ l
(108) $ l
(111) $ o
Замість того, щоб чекати на натискання Enter, ми захоплюємо кожне натискання клавіші під час його виконання! Вивід показує як символ, так і його ASCII-код, даючи нам повний контроль над введенням користувача.
Але перед тим, як ми збудуємо нашу оболонку, нам потрібен ще один інструмент. Обіцяю, всього один! 🤞
3. Увімкнення raw-режиму за допомогою .raw
За замовчуванням термінал обробляє введення та виведення для нас — він буферизує натискання клавіш і т. д. Однак, для побудови інтерактивної оболонки нам потрібен повний контроль над введенням та виведенням.
Згідно з GNU.org:
Термінальний порт у raw-режимі вимикає всю цю обробку. У raw-режимі символи читаються безпосередньо з пристрою та записуються без будь-якого перетворення або інтерпретації операційною системою.
Це означає, що не буде автоматичної обробки нових рядків, спеціальних клавіш — тільки сире введення та виведення! (як додаткове зауваження: причина, чому ми можемо отримувати одиничні натискання клавіш з .getch
це тому, що він тимчасово вмикає raw-режим, щоб вимкнути будь-яке буферизування)
Експеримент: Використання raw-режиму
Давайте змінемо наш попередній скрипт, щоб він працював у raw-режимі:
# raw.rb
require 'io/console'
ftty = File.new('/dev/tty')
c = 0
ftty.raw do
loop do
char = ftty.getch
print char + "\n"
c < ("hello".length - 1) ? (c += 1) : exit
end
end
Запустіть це і введіть hello:
ruby /tmp/raw.rb
h
e
l
l
o
Зачекайте… чому текст зміщується праворуч?
Розуміння проблеми
Давайте розберемо, що відбувається на рівні байтів. Наш скрипт надсилає таку послідовність в термінал:
~~no\n
```
Ось що відбувається крок за кроком:
- Ми набираємо “h”. Нічого особливого — він виводиться звичайно.
- Потім ми надсилаємо “\n” (новий рядок). Це переміщає курсор на новий рядок, але залишає його в тій самій колонці.
- Далі ми набираємо “e”. Оскільки курсор ще зсунений після того, як надрукувалась “h”, “e” з’являється зсунуто.
- Шаблон продовжується, створюючи ефект сходинок у виводі.
Проблема в тому, що ми ніколи не вказали терміналу повернутися на початок рядка після кожного нового рядка. Тож давайте зробимо це!
Виправлення виводу за допомогоюn"
raw послідовність ###
h\r\ne\r\nl\r\nl\r\no\r\n
видимий вивід ###
h
e
l
l
o
Чудово! Це виправляє проблему! Тепер, сказавши терміналу: (1) Перемістити курсор назад на початок — \r (2) Перемістити курсор на новий рядок — \n — ми отримуємо читаємий вивід!
З увімкненим raw-режимом ми тепер маємо **повний** контроль над введенням та виведенням — без втручання операційної системи. **Ми готові створити нашу інтерактивну оболонку!**
> До речі: ми знаємо, що термінал походить від друкарських машинок.
To visualize the “\r\n” combination from a physical type writer — check out this [youtube video](https://youtu.be/FkUXn5bOwzk?si=EReKdf0BVDF5YTwn&t=90)!
## Будуємо Shell!
Уф! Ми зібрали всі інструменти — тепер час зібрати їх разом і побудувати наш інтерактивний shell.
Ось базова версія, яка включає все, що ми вивчили:
script.rb
require "io/console"
buffer = ""
prefix = "\r\e[K" + "$ "
ftty = File.new('/dev/tty')
c = 0
ftty.raw do
print prefix
loop do
char = ftty.getch
case char
when "\r"
print "\r\n"
buffer = ""
else
buffer << char
end
debug = ENV.fetch("DEBUG", "") == "1" ? " (#{ char.ord })" : ""
print prefix + buffer + debug
# if removed, there's no mechanism to exit the process!
c < 20 ? (c += 1) : exit
end
end
```
Так… це, безумовно, складніше за нашу оригінальну 5-рядкову версію з gets
— але ми отримали те, що хотіли: більше контролю! ⚡️
Давайте перевіримо це. Є два способи запустити скрипт:
ruby script.rb
# $ aa
# $ bbb
# $ abcd
# $ cba
DEBUG=1 ruby script.rb
# $ aa (97)
# $ bbb (98)
# $ abcd (101)
# $ cba (97)
Як круто! Давайте розглянемо це детальніше 🔍
ANSI Escape Sequences
Перш за все, ми визначаємо дві ключові змінні:
prefix = "\r\e[K" + "$ "
buffer = ""
prefix
містить статичні символи, які визначають, як наш shell відображає введення, а buffer
містить динамічне введення користувача.
Що містить префікс?
Ключ до того, як це працює, полягає в ANSI escape sequences — github gist. Ось що відбувається:
"\r" - Carriage return - переміщає курсор на початок рядка
"\e" - позначає початок ANSI послідовності
"[K" - стирає все від курсора до кінця рядка
"$ " - підказка shell → простий візуальний знак
Тепер кожен раз, коли ми виводимо префікс перед оновленням буфера, ефективно ми скидаємо рядок введення до чистого стану.
Як працює виведення (Крок за кроком)
Припустимо, ми вводимо "hi" у нашому shell. Ось що відбувається:
ruby script.rb
# Початкова підказка:
$
# Користувач вводить "h":
# 1. Зберігаємо "h" в буфері.
# 2. Переміщаємо курсор на початок (`\r`).
# 3. Очищаємо рядок (`\e[K`).
# 4. Виводимо "$ " + буфер ("h").
# Виведення:
$ h
# Користувач вводить "i":
# 1. Додаємо "i" до буфера ("hi").
# 2. Переміщаємо курсор на початок.
# 3. Очищаємо рядок.
# 4. Виводимо "$ " + буфер ("hi").
# Виведення:
$ hi
Цей процес повторюється для кожного натискання клавіші — ми фактично "переробляємо" рядок кожного разу, це нагадує React і повторне рендерування DOM! — стаття.
Основна логіка Shell
Ось спрощена версія нашої основної логіки:
char = IO.console.getch
case char
when "\r"
print "\r\n"
buffer = ""
else
buffer << char
end
print prefix + buffer
Більшість символів додаються до буфера та перерисовуються. Але спеціальні випадки (як "return") викликають іншу поведінку — у нашому випадку ми скидаємо буфер і переходимо на новий рядок.
Щоб додати нову поведінку, ми можемо просто додати нову умову when
! Спробуємо зробити це!
Нова функція: Коректне завершення нашого shell
Зараз наш shell завершується після 20 натискань клавіш — не найкраще!
c < 20 ? (c += 1) : exit
На macOS та Linux натискання Ctrl + C (SIGINT) зазвичай перериває виконання процесу.
Щоб побачити, як це представлено, давайте запустимо script.rb
в DEBUG режимі.
DEBUG=1 ruby script.rb
# Виведення при натисканні Ctrl + C:
$ (3)
Це (3) означає, що ОС надсилає \x03 (ASCII 3) при натисканні Ctrl + C! Давайте змінемо наш скрипт, щоб виходити при натисканні Ctrl + C замість після 20 натискань клавіш:
case char
when "\r"
print "\r\n"
buffer = ""
when "\x03" # Ctrl + C
exit
else
buffer << char
end
Тепер натискання Ctrl + C коректно завершує роботу shell — жодних обмежень за кількістю натискань клавіші! 👌
Останні думки
Уф! Ми це зробили!
У цій статті ми розглянули, як побудувати інтерактивний shell на Ruby з нуля. Ми почали з базового методу gets
, але швидко зрозуміли, що цього недостатньо. Потім ми дослідили різницю між STDIN та /dev/tty, дізналися, як raw-mode терміналу дає нам прямий контроль над натисканнями клавіш, і розібрали ANSI escape sequences для керування виведенням.
Ось виклик: як би ви обробляли клавіші видалення (backspaces)? або ще краще, ми припустили, що натискання клавіші — це один байт, як би ви обробляли багатобайтові сигнали, такі як клавіші стрілок? ⬆️⬇️⬅️➡️
Насамкінець, 9 разів із 10 вам краще використовувати фреймворки, такі як вбудований модуль readline. Але де ж у цьому задоволення? 😁
Перекладено з: Building an interactive shell from scratch (with ruby)