Crea il tuo text editor in C++23: modificare e salvare
Inserimento e cancellazione di caratteri, ritorno a capo, salvataggio su disco, barra dei messaggi, flag di modifica e prompt di input in editor.hpp.
crea il tuo text editor
Parte 6 di 8
Il momento che stavamo aspettando: far scrivere l'editor. Vediamo le operazioni sulle righe, poi il salvataggio.
In questa parte il lavoro è tutto in src/editor/editor.hpp. Nessun file nuovo, nessuna modifica a main.cpp.
Barra dei messaggi#
Ci serve un posto dove dare feedback all'utente ("salvato", "attenzione"...). Riserviamo una seconda riga in fondo allo schermo, che mostra un messaggio per 5 secondi. Aggiungi gli include <chrono>, <functional>, <optional>, <string_view> e <utility>, e dopo gli include:
using namespace std::chrono_literals;Nuovi membri di Editor:
std::string status_msg;
std::chrono::steady_clock::time_point status_time;
bool dirty = false; // modifiche non salvate
int quit_times = 3;Nel costruttore passiamo da 1 a 2 righe riservate e mostriamo un aiuto iniziale:
screen_rows -= 2;
set_status("Ctrl-S = salva | Ctrl-Q = esci");set_status usa std::format_string, quindi la stringa di formato è controllata a tempo di compilazione: se sbagli un {} hai un errore di compilazione, non un crash a runtime.
template <typename... Args>
void set_status(std::format_string<Args...> fmt, Args&&... args) {
status_msg = std::format(fmt, std::forward<Args>(args)...);
status_time = std::chrono::steady_clock::now();
}
void draw_message_bar() {
out += "\x1b[K";
auto len = std::min(status_msg.size(), static_cast<size_t>(screen_cols));
if (len > 0 && std::chrono::steady_clock::now() - status_time < 5s)
out.append(status_msg, 0, len);
}In draw_status_bar() aggiungiamo l'indicatore di modifica e un \r\n finale, e in refresh_screen() chiamiamo draw_message_bar() dopo la barra di stato:
auto left = std::format("{:.20} - {} righe {}",
filename.empty() ? "[senza nome]" : filename,
rows.size(),
dirty ? "(modificato)" : "");
// ... resto invariato ...
out += "\x1b[m\r\n";draw_rows();
draw_status_bar();
draw_message_bar();Operazioni sulle righe#
Sostituiamo append_row con una più generale insert_row(at, ...), che serve per il ritorno a capo, e aggiungiamo le altre operazioni. Ogni modifica imposta dirty = true:
void insert_row(int at, std::string s) {
if (at < 0 || at > static_cast<int>(rows.size())) return;
Row r;
r.chars = std::move(s);
update_row(r);
rows.insert(rows.begin() + at, std::move(r));
dirty = true;
}
void row_insert_char(Row& r, int at, char c) {
at = std::clamp(at, 0, static_cast<int>(r.chars.size()));
r.chars.insert(r.chars.begin() + at, c);
update_row(r);
dirty = true;
}In open() sostituiamo append_row(line) con insert_row(rows.size(), line) e, alla fine, rimettiamo dirty = false: aprire un file non è una modifica.
Operazioni sull'editor#
void insert_char(char c) {
if (cy == static_cast<int>(rows.size())) insert_row(rows.size(), "");
row_insert_char(rows[cy], cx, c);
++cx;
}
void insert_newline() {
if (cx == 0) {
insert_row(cy, "");
} else {
auto& r = rows[cy];
std::string tail = r.chars.substr(cx);
r.chars.resize(cx);
update_row(r);
insert_row(cy + 1, std::move(tail)); // attenzione: invalida r
}
++cy;
cx = 0;
}
void del_char() {
if (cy == static_cast<int>(rows.size())) return;
if (cx == 0 && cy == 0) return;
if (cx > 0) {
rows[cy].chars.erase(cx - 1, 1);
update_row(rows[cy]);
--cx;
} else {
// inizio riga: unisci la riga corrente alla precedente
cx = static_cast<int>(rows[cy - 1].chars.size());
rows[cy - 1].chars += rows[cy].chars;
update_row(rows[cy - 1]);
rows.erase(rows.begin() + cy);
--cy;
}
dirty = true;
}Un dettaglio che in C++ morde: insert_row fa un rows.insert(...), che può riallocare il vettore e invalidare i riferimenti alle righe. Per questo in insert_newline ho finito di usare r prima di chiamarla.
Salvare#
Prima trasformiamo le righe in un'unica stringa:
std::string rows_to_string() const {
std::string s;
for (const auto& r : rows) {
s += r.chars;
s += '\n';
}
return s;
}Poi il salvataggio vero e proprio. Se il file non ha ancora un nome, lo chiediamo con un prompt:
void save() {
if (filename.empty()) {
auto name = prompt("Salva come: ");
if (!name) {
set_status("Salvataggio annullato");
return;
}
filename = *name;
}
auto data = rows_to_string();
std::ofstream f{filename, std::ios::binary | std::ios::trunc};
if (f) f.write(data.data(), static_cast<std::streamsize>(data.size()));
if (!f) {
set_status("Errore di salvataggio su '{}'", filename);
return;
}
dirty = false;
set_status("{} byte scritti su disco", data.size());
}Il prompt#
Il prompt mostra un'etichetta nella barra dei messaggi, raccoglie caratteri in un buffer e ritorna std::optional<std::string>: nullopt se l'utente preme Esc, il testo se preme Invio. Ha anche un callback opzionale, chiamato a ogni tasto: nella parte 7 lo useremo per la ricerca incrementale.
std::optional<std::string> prompt(
std::string_view label,
std::function<void(const std::string&, int)> cb = {}) {
std::string buf;
for (;;) {
set_status("{}{}", label, buf);
refresh_screen();
int c = read_key();
if (c == Del || c == ctrl('h') || c == Backspace) {
if (!buf.empty()) buf.pop_back();
} else if (c == '\x1b') {
set_status("");
if (cb) cb(buf, c);
return std::nullopt;
} else if (c == '\r') {
if (!buf.empty()) {
set_status("");
if (cb) cb(buf, c);
return buf;
}
} else if (c >= 32 && c < 127) {
buf += static_cast<char>(c);
}
if (cb) cb(buf, c);
}
}Collegare i tasti#
Il nuovo process_keypress() completo. Aggiungiamo anche la protezione contro l'uscita accidentale: se ci sono modifiche non salvate, Ctrl-Q va premuto 3 volte.
bool process_keypress() {
int c = read_key();
switch (c) {
case '\r':
insert_newline();
break;
case ctrl('q'):
if (dirty && quit_times > 0) {
set_status("ATTENZIONE: modifiche non salvate. "
"Premi Ctrl-Q ancora {} volte per uscire.", quit_times);
--quit_times;
return true;
}
return false;
case ctrl('s'):
save();
break;
case Home: cx = 0; break;
case End:
if (cy < static_cast<int>(rows.size()))
cx = static_cast<int>(rows[cy].chars.size());
break;
case Backspace:
case ctrl('h'):
case Del:
if (c == Del) move_cursor(ArrowRight);
del_char();
break;
case PageUp:
case PageDown:
if (c == PageUp) cy = row_off;
else cy = std::min(row_off + screen_rows - 1, static_cast<int>(rows.size()));
for (int n = screen_rows; n > 0; --n)
move_cursor(c == PageUp ? ArrowUp : ArrowDown);
break;
case ArrowLeft:
case ArrowRight:
case ArrowUp:
case ArrowDown:
move_cursor(c);
break;
case ctrl('l'):
case '\x1b':
break;
default:
if (c == '\t' || (c >= 32 && c < 127))
insert_char(static_cast<char>(c));
break;
}
quit_times = 3;
return true;
}Il tasto Del è implementato come "freccia destra + backspace": spostiamo il cursore avanti di uno e cancelliamo all'indietro.
Limiti attuali#
Per ora accettiamo solo caratteri ASCII stampabili e tab. Sul testo UTF-8 il cursore si muoverebbe byte per byte, il che con le lettere accentate dà risultati strani: gestirlo bene richiede di ragionare in code point o grapheme cluster, ed è un ottimo esercizio per dopo.
Provalo#
make run FILE=prova.txtScrivi, cancella, vai a capo, salva con Ctrl-S. Esci con Ctrl-Q: se hai modifiche non salvate l'editor ti avvisa. Prova anche senza argomento (make run): Ctrl-S ti chiede il nome del file.
Nella prossima parte aggiungiamo la ricerca.
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: 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.