Introducción a NCurses en Rust
NCurses es una biblioteca para el desarrollo de interfaces de usuario en terminales. Proporciona una API para crear interfaces textuales eficientes y portátiles.
En Rust, podemos usar el crate pancurses
o ncurses
para interactuar con la biblioteca NCurses. Este manual se enfocará en pancurses
por su mayor facilidad de uso y seguridad.
Nota: NCurses es especialmente útil para aplicaciones que necesitan:
- Interfaces interactivas en terminal
- Juegos basados en texto
- Herramientas de administración de sistemas
- Aplicaciones que deben funcionar en entornos sin GUI
Instalación y configuración
Para comenzar, añade la siguiente dependencia a tu Cargo.toml
:
[dependencies]
pancurses = { version = "0.17", features = ["win32"] } # Incluye soporte para Windows
En sistemas Unix, necesitarás tener instalada la biblioteca NCurses:
Instalación en Linux (Debian/Ubuntu)
sudo apt-get install libncurses5-dev libncursesw5-dev
Instalación en macOS
brew install ncurses
Advertencia: En Windows, pancurses usará la API de consola de Windows en lugar de NCurses nativo. El comportamiento puede diferir ligeramente.
Inicialización básica
El siguiente ejemplo muestra cómo inicializar NCurses y crear una ventana básica:
use pancurses::{initscr, endwin, Input, noecho, curs_set};
fn main() {
// Inicializar NCurses
let window = initscr();
// Configurar la pantalla
noecho(); // No mostrar caracteres tecleados
curs_set(0); // Ocultar el cursor
// Imprimir un mensaje
window.printw("¡Hola, NCurses en Rust!");
window.refresh();
// Esperar entrada del usuario
window.getch();
// Limpiar NCurses
endwin();
}
Funciones clave de inicialización:
Función | Descripción |
---|---|
initscr() |
Inicializa NCurses y devuelve la ventana principal (stdscr) |
endwin() |
Finaliza correctamente la aplicación NCurses |
noecho() |
Desactiva el eco de caracteres tecleados |
curs_set(visibility) |
Controla la visibilidad del cursor (0=invisible, 1=normal, 2=muy visible) |
refresh() |
Actualiza la pantalla para mostrar cambios |
Consejo: Siempre llama a endwin()
antes de salir de tu aplicación para restaurar el estado original del terminal.
Trabajo con ventanas
NCurses permite crear múltiples ventanas independientes. Cada ventana tiene su propio buffer de pantalla.
Creación de ventanas
use pancurses::{newwin, Window};
fn main() {
let main_win = pancurses::initscr();
pancurses::noecho();
// Crear una nueva ventana de 10 filas x 30 columnas
// Comenzando en la posición (5, 10)
let sub_win = newwin(10, 30, 5, 10);
// Dibujar en la ventana principal
main_win.printw("Ventana principal");
main_win.refresh();
// Dibujar en la subventana
sub_win.printw("Subventana");
sub_win.refresh();
main_win.getch();
pancurses::endwin();
}
Métodos comunes de ventana
Método | Descripción |
---|---|
mv(y, x) |
Mueve el cursor a la posición (y, x) |
mvprintw(y, x, text) |
Mueve el cursor e imprime texto |
border(ls, rs, ts, bs, tl, tr, bl, br) |
Dibuja un borde alrededor de la ventana |
clear() |
Limpia la ventana |
erase() |
Borra el contenido de la ventana |
getmaxyx() |
Devuelve las dimensiones de la ventana (filas, columnas) |
Ejemplo avanzado con múltiples ventanas
use pancurses::{initscr, endwin, newwin, Input};
fn main() {
let main_win = initscr();
pancurses::noecho();
pancurses::curs_set(0);
// Obtener dimensiones de la pantalla
let (rows, cols) = main_win.get_max_yx();
// Crear ventanas
let header = newwin(3, cols, 0, 0);
let body = newwin(rows - 6, cols, 3, 0);
let footer = newwin(3, cols, rows - 3, 0);
// Dibujar header
header.border('|', '|', '-', '-', '+', '+', '+', '+');
header.mvprintw(1, 1, "Aplicación NCurses en Rust");
header.refresh();
// Dibujar body
body.mvprintw(1, 1, "Contenido principal");
body.mvprintw(2, 1, &format!("Tamaño: {}x{}", rows, cols));
body.refresh();
// Dibujar footer
footer.border('|', '|', '-', '-', '+', '+', '+', '+');
footer.mvprintw(1, 1, "Presione cualquier tecla para salir");
footer.refresh();
main_win.getch();
endwin();
}
Manejo de entrada del usuario
NCurses proporciona varias formas de capturar entrada del usuario, desde teclas simples hasta eventos de ratón.
Captura básica de teclas
use pancurses::{initscr, endwin, Input};
fn main() {
let window = initscr();
pancurses::noecho();
pancurses::curs_set(0);
window.printw("Presione una tecla (q para salir)");
window.refresh();
loop {
match window.getch() {
Some(Input::Character('q')) => break,
Some(input) => {
window.clear();
window.printw(&format!("Presionaste: {:?}", input));
window.refresh();
},
None => continue,
}
}
endwin();
}
Tipos de entrada comunes
Tipo | Ejemplo | Descripción |
---|---|---|
Input::Character(c) |
'a', '1', '@' | Caracteres alfanuméricos |
Input::KeyUp |
KEY_UP | Flecha arriba |
Input::KeyDown |
KEY_DOWN | Flecha abajo |
Input::KeyLeft |
KEY_LEFT | Flecha izquierda |
Input::KeyRight |
KEY_RIGHT | Flecha derecha |
Input::KeyEnter |
KEY_ENTER | Tecla Enter |
Input::KeyBackspace |
KEY_BACKSPACE | Tecla Retroceso |
Captura de ratón
use pancurses::{initscr, endwin, Input, mousemask, ALL_MOUSE_EVENTS};
fn main() {
let window = initscr();
pancurses::noecho();
pancurses::curs_set(0);
// Habilitar eventos de ratón
mousemask(ALL_MOUSE_EVENTS, None);
window.printw("Haga clic con el ratón (q para salir)");
window.refresh();
loop {
match window.getch() {
Some(Input::Character('q')) => break,
Some(Input::KeyMouse(mouse_event)) => {
window.clear();
window.printw(&format!("Evento de ratón: {:?}", mouse_event));
window.refresh();
},
Some(input) => {
window.clear();
window.printw(&format!("Entrada: {:?}", input));
window.refresh();
},
None => continue,
}
}
endwin();
}
Sistema de colores
NCurses permite trabajar con colores en terminales que los soportan. Primero debemos inicializar el sistema de colores.
Inicialización de colores
use pancurses::{initscr, endwin, start_color, init_pair, COLOR_PAIR, COLOR_BLACK,
COLOR_WHITE, COLOR_RED, COLOR_GREEN, COLOR_BLUE};
fn main() {
let window = initscr();
start_color(); // Inicializar sistema de colores
// Definir pares de colores (ID, foreground, background)
init_pair(1, COLOR_RED, COLOR_BLACK);
init_pair(2, COLOR_GREEN, COLOR_BLACK);
init_pair(3, COLOR_BLUE, COLOR_WHITE);
window.attron(COLOR_PAIR(1));
window.printw("Texto en rojo\n");
window.attroff(COLOR_PAIR(1));
window.attron(COLOR_PAIR(2));
window.printw("Texto en verde\n");
window.attroff(COLOR_PAIR(2));
window.attron(COLOR_PAIR(3));
window.printw("Texto azul sobre blanco\n");
window.attroff(COLOR_PAIR(3));
window.refresh();
window.getch();
endwin();
}
Colores predefinidos
Constante | Color |
---|---|
COLOR_BLACK |
Negro |
COLOR_RED |
Rojo |
COLOR_GREEN |
Verde |
COLOR_YELLOW |
Amarillo |
COLOR_BLUE |
Azul |
COLOR_MAGENTA |
Magenta |
COLOR_CYAN |
Cian |
COLOR_WHITE |
Blanco |
Definición de colores personalizados
En terminales que soportan 256 colores, puedes definir colores personalizados:
use pancurses::{initscr, endwin, start_color, init_color, init_pair, COLOR_PAIR};
fn main() {
let window = initscr();
start_color();
// Definir color personalizado (ID, R, G, B) (0-1000)
init_color(100, 1000, 500, 0); // Naranja
// Crear par de colores usando nuestro color personalizado
init_pair(1, 100, COLOR_BLACK);
window.attron(COLOR_PAIR(1));
window.printw("Texto en color naranja personalizado");
window.attroff(COLOR_PAIR(1));
window.refresh();
window.getch();
endwin();
}
Atributos de texto
Además de colores, NCurses permite aplicar varios atributos de texto:
Atributos comunes
use pancurses::{initscr, endwin, A_BOLD, A_UNDERLINE, A_REVERSE, A_BLINK};
fn main() {
let window = initscr();
window.printw("Texto normal\n");
window.attron(A_BOLD);
window.printw("Texto en negrita\n");
window.attroff(A_BOLD);
window.attron(A_UNDERLINE);
window.printw("Texto subrayado\n");
window.attroff(A_UNDERLINE);
window.attron(A_REVERSE);
window.printw("Texto invertido\n");
window.attroff(A_REVERSE);
window.attron(A_BLINK);
window.printw("Texto parpadeante\n");
window.attroff(A_BLINK);
window.refresh();
window.getch();
endwin();
}
Combinación de atributos
Puedes combinar múltiples atributos usando el operador OR bit a bit:
use pancurses::{initscr, endwin, A_BOLD, A_UNDERLINE, start_color, init_pair, COLOR_PAIR};
fn main() {
let window = initscr();
start_color();
init_pair(1, COLOR_RED, COLOR_BLACK);
// Combinar múltiples atributos
window.attron(COLOR_PAIR(1) | A_BOLD | A_UNDERLINE);
window.printw("Texto rojo, negrita y subrayado");
window.attroff(COLOR_PAIR(1) | A_BOLD | A_UNDERLINE);
window.refresh();
window.getch();
endwin();
}
Formularios y entrada de texto
Para crear formularios interactivos, necesitamos manejar la entrada de texto y validación:
Campo de texto básico
use pancurses::{initscr, endwin, Input, echo, nocbreak, curs_set};
fn get_input(window: &pancurses::Window, prompt: &str, y: i32, x: i32) -> String {
window.mvprintw(y, x, prompt);
window.refresh();
let mut input = String::new();
loop {
match window.getch() {
Some(Input::Character(c)) => {
if c == '\n' {
break;
} else {
input.push(c);
window.addch(c);
}
},
Some(Input::KeyBackspace) if !input.is_empty() => {
input.pop();
let (y, x) = window.get_cur_yx();
window.mv(y, x - 1);
window.delch();
},
Some(Input::Character('q')) => break,
_ => {}
}
}
input
}
fn main() {
let window = initscr();
echo(); // Mostrar caracteres tecleados
nocbreak(); // Permitir entrada línea a línea
curs_set(1); // Mostrar cursor
let name = get_input(&window, "Nombre: ", 5, 5);
let age = get_input(&window, "Edad: ", 7, 5);
window.clear();
window.mvprintw(10, 5, &format!("Hola {}, tienes {} años", name, age));
window.refresh();
window.getch();
endwin();
}
Formulario con múltiples campos
use pancurses::{initscr, endwin, Input, echo, nocbreak, curs_set, A_UNDERLINE};
struct FormField {
label: String,
value: String,
y: i32,
x: i32,
width: i32,
active: bool,
}
impl FormField {
fn new(label: &str, y: i32, x: i32, width: i32) -> Self {
FormField {
label: label.to_string(),
value: String::new(),
y,
x,
width,
active: false,
}
}
fn draw(&self, window: &pancurses::Window) {
window.mvprintw(self.y, self.x, &self.label);
let display_value = if self.value.is_empty() && !self.active {
"_".repeat(self.width as usize)
} else {
self.value.clone()
};
if self.active {
window.attron(A_UNDERLINE);
}
window.mvprintw(self.y, self.x + self.label.len() as i32 + 1, &display_value);
if self.active {
window.attroff(A_UNDERLINE);
window.mv(self.y, self.x + self.label.len() as i32 + 1 + self.value.len() as i32);
}
}
fn handle_input(&mut self, window: &pancurses::Window, input: Input) -> bool {
match input {
Input::Character(c) if c.is_ascii() && self.value.len() < self.width as usize => {
self.value.push(c);
true
},
Input::KeyBackspace if !self.value.is_empty() => {
self.value.pop();
true
},
Input::KeyEnter => false,
_ => true,
}
}
}
fn main() {
let window = initscr();
echo();
nocbreak();
curs_set(1);
let mut fields = vec![
FormField::new("Nombre:", 5, 5, 20),
FormField::new("Email:", 7, 5, 20),
FormField::new("Teléfono:", 9, 5, 15),
];
let mut current_field = 0;
fields[current_field].active = true;
loop {
window.clear();
// Dibujar título
window.mvprintw(2, 5, "Formulario de contacto");
// Dibujar campos
for field in &fields {
field.draw(&window);
}
window.refresh();
match window.getch() {
Some(Input::KeyDown) if current_field < fields.len() - 1 => {
fields[current_field].active = false;
current_field += 1;
fields[current_field].active = true;
},
Some(Input::KeyUp) if current_field > 0 => {
fields[current_field].active = false;
current_field -= 1;
fields[current_field].active = true;
},
Some(input) => {
if !fields[current_field].handle_input(&window, input) {
// Enter presionado
if current_field < fields.len() - 1 {
fields[current_field].active = false;
current_field += 1;
fields[current_field].active = true;
} else {
break; // Fin del formulario
}
}
},
None => {}
}
}
window.clear();
window.mvprintw(12, 5, "Datos ingresados:");
for (i, field) in fields.iter().enumerate() {
window.mvprintw(14 + i as i32, 5, &format!("{}: {}", field.label, field.value));
}
window.refresh();
window.getch();
endwin();
}
Paneles (panels) para interfaces complejas
Los paneles son una extensión de NCurses que permiten manejar ventanas superpuestas de manera más eficiente.
Uso básico de paneles
use pancurses::{initscr, endwin, newwin, Input};
use pancurses::panel::{Panel, new_panel};
fn main() {
let window = initscr();
pancurses::noecho();
pancurses::curs_set(0);
// Crear ventanas base
let main_win = newwin(20, 50, 0, 0);
main_win.printw("Ventana principal - Presione 'p' para mostrar panel, 'q' para salir");
main_win.refresh();
// Crear panel (inicialmente oculto)
let panel_win = newwin(10, 30, 5, 10);
panel_win.border('|', '|', '-', '-', '+', '+', '+', '+');
panel_win.mvprintw(2, 2, "Este es un panel");
panel_win.mvprintw(3, 2, "Presione cualquier tecla");
panel_win.mvprintw(4, 2, "para ocultarlo");
let panel = new_panel(panel_win);
panel.hide();
pancurses::update_panels();
pancurses::doupdate();
loop {
match window.getch() {
Some(Input::Character('p')) => {
panel.show();
pancurses::update_panels();
pancurses::doupdate();
},
Some(Input::Character('q')) => break,
Some(_) => {
panel.hide();
pancurses::update_panels();
pancurses::doupdate();
},
None => {}
}
}
endwin();
}
Sistema de paneles apilados
use pancurses::{initscr, endwin, newwin, Input};
use pancurses::panel::{Panel, new_panel};
fn main() {
let window = initscr();
pancurses::noecho();
pancurses::curs_set(0);
// Crear ventana principal
let main_win = newwin(20, 50, 0, 0);
main_win.printw("Ventana principal - Use 1-3 para mostrar paneles, q para salir");
main_win.refresh();
// Crear tres paneles
let panel1 = new_panel(newwin(8, 25, 3, 5));
panel1.window().border('|', '|', '-', '-', '+', '+', '+', '+');
panel1.window().mvprintw(2, 2, "Panel 1 (Fondo)");
panel1.window().mvprintw(3, 2, "Tecla: 1");
let panel2 = new_panel(newwin(8, 25, 6, 15));
panel2.window().border('|', '|', '-', '-', '+', '+', '+', '+');
panel2.window().mvprintw(2, 2, "Panel 2 (Medio)");
panel2.window().mvprintw(3, 2, "Tecla: 2");
let panel3 = new_panel(newwin(8, 25, 9, 25));
panel3.window().border('|', '|', '-', '-', '+', '+', '+', '+');
panel3.window().mvprintw(2, 2, "Panel 3 (Frente)");
panel3.window().mvprintw(3, 2, "Tecla: 3");
// Inicialmente ocultar todos los paneles
panel1.hide();
panel2.hide();
panel3.hide();
pancurses::update_panels();
pancurses::doupdate();
loop {
match window.getch() {
Some(Input::Character('1')) => {
panel1.show();
panel1.top(); // Mover al frente
pancurses::update_panels();
pancurses::doupdate();
},
Some(Input::Character('2')) => {
panel2.show();
panel2.top();
pancurses::update_panels();
pancurses::doupdate();
},
Some(Input::Character('3')) => {
panel3.show();
panel3.top();
pancurses::update_panels();
pancurses::doupdate();
},
Some(Input::Character('q')) => break,
Some(Input::Character('h')) => {
// Ocultar todos los paneles
panel1.hide();
panel2.hide();
panel3.hide();
pancurses::update_panels();
pancurses::doupdate();
},
_ => {}
}
}
endwin();
}
Ejemplos completos
Editor de texto simple
use pancurses::{initscr, endwin, Input, noecho, cbreak, curs_set, A_UNDERLINE};
struct TextEditor {
content: String,
cursor_pos: usize,
}
impl TextEditor {
fn new() -> Self {
TextEditor {
content: String::new(),
cursor_pos: 0,
}
}
fn insert_char(&mut self, c: char) {
self.content.insert(self.cursor_pos, c);
self.cursor_pos += 1;
}
fn delete_char(&mut self) {
if self.cursor_pos > 0 && !self.content.is_empty() {
self.content.remove(self.cursor_pos - 1);
self.cursor_pos -= 1;
}
}
fn move_left(&mut self) {
if self.cursor_pos > 0 {
self.cursor_pos -= 1;
}
}
fn move_right(&mut self) {
if self.cursor_pos < self.content.len() {
self.cursor_pos += 1;
}
}
fn draw(&self, window: &pancurses::Window, y: i32, x: i32) {
window.mv(y, x);
// Mostrar contenido
window.printw(&self.content);
// Mostrar cursor
let (mut cur_y, mut cur_x) = window.get_cur_yx();
window.mv(cur_y, x + self.cursor_pos as i32);
window.attron(A_UNDERLINE);
window.printw(if self.cursor_pos < self.content.len() {
&self.content[self.cursor_pos..=self.cursor_pos]
} else {
" "
});
window.attroff(A_UNDERLINE);
}
}
fn main() {
let window = initscr();
noecho();
cbreak();
curs_set(0); // Ocultamos el cursor nativo
let mut editor = TextEditor::new();
let mut running = true;
window.mvprintw(1, 1, "Editor de texto simple (Ctrl+X para salir)");
window.mv(3, 1);
while running {
window.clear();
window.mvprintw(1, 1, "Editor de texto simple (Ctrl+X para salir)");
editor.draw(&window, 3, 1);
window.refresh();
match window.getch() {
Some(Input::Character(c)) => {
match c {
'\x18' => running = false, // Ctrl+X
_ => editor.insert_char(c),
}
},
Some(Input::KeyBackspace) => editor.delete_char(),
Some(Input::KeyLeft) => editor.move_left(),
Some(Input::KeyRight) => editor.move_right(),
Some(Input::KeyDC) => { // Tecla Delete
if editor.cursor_pos < editor.content.len() {
editor.cursor_pos += 1;
editor.delete_char();
}
},
Some(Input::KeyHome) => editor.cursor_pos = 0,
Some(Input::KeyEnd) => editor.cursor_pos = editor.content.len(),
_ => {}
}
}
endwin();
}
Juego Snake simple
use pancurses::{initscr, endwin, Input, noecho, cbreak, curs_set, napms};
use std::collections::VecDeque;
#[derive(Debug, Clone, Copy, PartialEq)]
enum Direction {
Up,
Down,
Left,
Right,
}
struct SnakeGame {
snake: VecDeque<(i32, i32)>,
direction: Direction,
food: (i32, i32),
width: i32,
height: i32,
game_over: bool,
}
impl SnakeGame {
fn new(width: i32, height: i32) -> Self {
let mut snake = VecDeque::new();
snake.push_back((width / 2, height / 2));
SnakeGame {
snake,
direction: Direction::Right,
food: (5, 5),
width,
height,
game_over: false,
}
}
fn change_direction(&mut self, new_dir: Direction) {
match (self.direction, new_dir) {
(Direction::Up, Direction::Down) |
(Direction::Down, Direction::Up) |
(Direction::Left, Direction::Right) |
(Direction::Right, Direction::Left) => {},
_ => self.direction = new_dir,
}
}
fn update(&mut self) {
if self.game_over {
return;
}
let head = self.snake.front().unwrap();
let new_head = match self.direction {
Direction::Up => (head.0, head.1 - 1),
Direction::Down => (head.0, head.1 + 1),
Direction::Left => (head.0 - 1, head.1),
Direction::Right => (head.0 + 1, head.1),
};
// Verificar colisiones
if new_head.0 <= 0 || new_head.0 >= self.width - 1 ||
new_head.1 <= 0 || new_head.1 >= self.height - 1 ||
self.snake.contains(&new_head) {
self.game_over = true;
return;
}
// Mover la serpiente
self.snake.push_front(new_head);
// Verificar si comió la comida
if new_head == self.food {
// Generar nueva comida
self.food = (
(rand::random::() % (self.width as u32 - 2) + 1) as i32,
(rand::random::() % (self.height as u32 - 2) + 1) as i32,
);
} else {
self.snake.pop_back();
}
}
fn draw(&self, window: &pancurses::Window) {
// Dibujar bordes
window.border('|', '|', '-', '-', '+', '+', '+', '+');
// Dibujar serpiente
for &(x, y) in &self.snake {
window.mvaddch(y, x, 'O');
}
// Dibujar cabeza
if let Some(&(x, y)) = self.snake.front() {
window.mvaddch(y, x, '@');
}
// Dibujar comida
window.mvaddch(self.food.1, self.food.0, '*');
// Dibujar puntuación
window.mvprintw(0, 2, &format!("Puntuación: {}", self.snake.len() - 1));
if self.game_over {
window.mvprintw(self.height / 2, self.width / 2 - 5, "GAME OVER!");
}
}
}
fn main() {
let window = initscr();
noecho();
cbreak();
curs_set(0);
let (height, width) = window.get_max_yx();
let mut game = SnakeGame::new(width - 2, height - 2);
window.timeout(100); // Establecer tiempo de espera para entrada
while !game.game_over {
window.clear();
game.draw(&window);
window.refresh();
// Manejar entrada
match window.getch() {
Some(Input::KeyUp) => game.change_direction(Direction::Up),
Some(Input::KeyDown) => game.change_direction(Direction::Down),
Some(Input::KeyLeft) => game.change_direction(Direction::Left),
Some(Input::KeyRight) => game.change_direction(Direction::Right),
Some(Input::Character('q')) => break,
_ => {}
}
game.update();
napms(150); // Controlar velocidad del juego
}
window.getch(); // Esperar antes de salir
endwin();
}
Mejores prácticas y consejos
Patrones comunes
1. Estructura básica de una aplicación NCurses:
fn main() {
// 1. Inicialización
let window = initscr();
noecho();
cbreak();
curs_set(0);
// 2. Configuración inicial
start_color();
init_pair(1, COLOR_RED, COLOR_BLACK);
// 3. Bucle principal
let mut running = true;
while running {
// Manejo de entrada
match window.getch() {
Some(Input::Character('q')) => running = false,
_ => {}
}
// Lógica del programa
// Renderizado
window.clear();
window.printw("Contenido");
window.refresh();
}
// 4. Limpieza
endwin();
}
Consejos de rendimiento
- Usa
window.noutrefresh()
seguido depancurses::doupdate()
cuando tengas múltiples ventanas para actualizar. Esto reduce el parpadeo. - Evita llamar a
refresh()
oclear()
más de lo necesario. - Para aplicaciones con mucha actualización, considera usar
window.leaveok(true)
para optimizar el movimiento del cursor.
Manejo de errores
NCurses puede fallar silenciosamente en algunas operaciones. Implementa comprobaciones:
fn safe_init() -> Result {
let window = pancurses::initscr();
if window.is_null() {
return Err("No se pudo inicializar NCurses".to_string());
}
pancurses::noecho();
pancurses::cbreak();
pancurses::curs_set(0);
if pancurses::start_color() != pancurses::OK {
return Err("No se pudo inicializar colores".to_string());
}
Ok(window)
}
Compatibilidad entre plataformas
- Algunas teclas pueden tener códigos diferentes en Windows vs Unix.
- Los colores pueden verse diferente en distintos terminales.
- Prueba tu aplicación en múltiples terminales (al menos en Windows Terminal, Linux console y macOS Terminal).