Crea il tuo text editor in C++23: raw mode con RAII
Disattiviamo l'eco e la modalità canonica del terminale usando termios, incapsulando tutto in una classe RAII in terminal/raw_mode.hpp.
crea il tuo text editor
Parte 2 di 8
Nella parte precedente abbiamo visto che il terminale, per default, lavora in modalità canonica. Ora lo mettiamo in raw mode.
Struttura del progetto a fine parte:
ted/
├── Makefile
└── src/
├── main.cpp
└── terminal/
└── raw_mode.hpp ← nuovoGli attributi del terminale: termios#
Su POSIX gli attributi di un terminale si leggono con tcgetattr() e si scrivono con tcsetattr(), usando la struct termios. Il flusso è sempre lo stesso:
- leggi gli attributi originali e salvali;
- copia la struct e modifica i flag;
- applica la copia modificata;
- all'uscita, ripristina gli originali.
Il punto 4 è cruciale: se il programma termina senza ripristinare, la tua shell resta in raw mode, senza eco, e devi chiudere il terminale. In C si usa atexit(). In C++ abbiamo uno strumento molto migliore: RAII.
Una classe RAII per la raw mode#
Crea src/terminal/raw_mode.hpp:
#pragma once
#include <cerrno>
#include <expected>
#include <system_error>
#include <utility>
#include <termios.h>
#include <unistd.h>
namespace ted {
class RawMode {
public:
static std::expected<RawMode, std::error_code> enable() {
termios orig{};
if (tcgetattr(STDIN_FILENO, &orig) == -1)
return std::unexpected{std::error_code{errno, std::generic_category()}};
termios raw = orig;
raw.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON);
raw.c_oflag &= ~(OPOST);
raw.c_cflag |= CS8;
raw.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG);
raw.c_cc[VMIN] = 0; // read() ritorna appena c'è qualcosa...
raw.c_cc[VTIME] = 1; // ...oppure dopo 100 ms
if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) == -1)
return std::unexpected{std::error_code{errno, std::generic_category()}};
return RawMode{orig};
}
RawMode(RawMode&& other) noexcept
: orig_{other.orig_}, active_{std::exchange(other.active_, false)} {}
RawMode& operator=(RawMode&&) = delete;
RawMode(const RawMode&) = delete;
RawMode& operator=(const RawMode&) = delete;
~RawMode() {
if (active_) tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_);
}
private:
explicit RawMode(const termios& orig) : orig_{orig}, active_{true} {}
termios orig_;
bool active_;
};
} // namespace tedTutto il codice del progetto vive nel namespace ted. Alcune scelte di design:
std::expectedal posto di eccezioni o codici di ritorno:enable()o ti restituisce unRawModevalido, o unstd::error_code. Non esiste uno stato "mezzo costruito".- Il costruttore è privato: l'unico modo per ottenere un
RawModeèenable(), quindi se ce l'hai, la raw mode è davvero attiva. - Il move constructor trasferisce la responsabilità del ripristino: l'oggetto "svuotato" ha
active_ == falsee il suo distruttore non fa nulla. Copia e assegnazione sono vietate perché ripristinare due volte non avrebbe senso.
Cosa fa ogni flag#
ECHO: il terminale non stampa più quello che digiti.ICANON: legge byte per byte invece che riga per riga.ISIG: Ctrl-C e Ctrl-Z non generano più segnali (SIGINT,SIGTSTP).IXON: Ctrl-S e Ctrl-Q non fermano/riprendono più l'output. Ci servono liberi per salvare e uscire.IEXTEN: disattiva Ctrl-V (e Ctrl-O su macOS).ICRNL: il terminale non converte più\r(Invio) in\n. Così Invio e Ctrl-J sono distinguibili.OPOST: il terminale non traduce più\nin\r\nin output. D'ora in poi dobbiamo scrivere\r\na mano.BRKINT,INPCK,ISTRIP,CS8: flag storici della raw mode "classica"; su un terminale moderno spesso non cambiano nulla, ma è buona prassi.
VMIN e VTIME sono nel campo c_cc: con VMIN = 0 e VTIME = 1, read() ritorna dopo al massimo un decimo di secondo anche se non è arrivato nulla. Ci tornerà utile per distinguere il tasto Esc dalle sequenze di escape delle frecce.
Usarla da main.cpp#
Sostituisci il contenuto di src/main.cpp con questo programma di prova:
#include <cctype>
#include <cerrno>
#include <print>
#include <unistd.h>
#include "terminal/raw_mode.hpp"
int main() {
auto raw = ted::RawMode::enable();
if (!raw) {
std::println(stderr, "errore: {}", raw.error().message());
return 1;
}
for (;;) {
char c = '\0';
ssize_t n = read(STDIN_FILENO, &c, 1);
if (n == -1 && errno != EAGAIN) {
std::println(stderr, "errore di lettura");
return 1;
}
if (std::iscntrl(static_cast<unsigned char>(c)))
std::print("{}\r\n", static_cast<int>(c));
else
std::print("{} ('{}')\r\n", static_cast<int>(c), c);
if (c == 'q') break;
}
}Nota che qualsiasi uscita da main (anche il return 1) distrugge raw, quindi il terminale torna normale. Con atexit o con un exit() sparso nel codice non avresti questa garanzia.
Esperimenti#
Esegui make && ./ted. Ora ogni tasto stampa subito il suo codice:
- Le lettere stampano il codice ASCII (
aè 97). - Ctrl-A stampa
1, Ctrl-B stampa2, e così via: Ctrl più una lettera azzera i bit alti del carattere. Ci servirà tra poco. - Invio stampa
13, Backspace127, Esc27. - Le frecce stampano tre byte:
27,91, e una lettera (65su,66giù,67destra,68sinistra). Sono sequenze di escape: le gestiremo nella parte 4. - Ctrl-C, Ctrl-Z, Ctrl-S, Ctrl-Q, Ctrl-V non fanno più nulla di speciale.
- Ogni tanto compare uno
0: è il timeout diread(), nessun tasto premuto.
Esci con q. Nella prossima parte disegniamo lo schermo.
Potrebbe interessarti anche
Crea il tuo text editor in C++23: setup e struttura del progetto
Prima parte della serie: costruiamo da zero un editor di testo da terminale in C++23, senza librerie esterne. Struttura a cartelle con header, Makefile e un main.cpp pulito.
Crea il tuo text editor in C++23: disegnare lo schermo
Sequenze di escape VT100, buffer di output, dimensioni del terminale e la prima classe Editor con le tildi stile vim, divisa in header.