feat(city): app loop, terminal guard, save format, gen CLI
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
190
crates/city/src/app.rs
Normal file
190
crates/city/src/app.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
//! Application state, the screen trait and the fixed-rate frame loop.
|
||||
|
||||
use crate::term::{TerminalGuard, install_panic_hook};
|
||||
use anyhow::Result;
|
||||
use citygen::{City, GenParams};
|
||||
use crossterm::event::{self, Event, KeyEvent, KeyEventKind};
|
||||
use ratatui::Frame;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::{Alignment, Rect};
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::widgets::Paragraph;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const MIN_W: u16 = 100;
|
||||
pub const MIN_H: u16 = 30;
|
||||
pub const FRAME: Duration = Duration::from_micros(33_333);
|
||||
|
||||
/// State shared by every screen.
|
||||
pub struct AppState {
|
||||
pub params: GenParams,
|
||||
pub city: Option<City>,
|
||||
pub tick: u64,
|
||||
status: Option<(String, u64)>,
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
AppState {
|
||||
params: GenParams::default(),
|
||||
city: None,
|
||||
tick: 0,
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a one-line message for `ttl` ticks.
|
||||
pub fn set_status(&mut self, msg: impl Into<String>, ttl: u64) {
|
||||
self.status = Some((msg.into(), self.tick + ttl));
|
||||
}
|
||||
|
||||
pub fn status_text(&self) -> Option<&str> {
|
||||
match &self.status {
|
||||
Some((msg, until)) if *until > self.tick => Some(msg.as_str()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a screen change is animated. Replaced by `ui::transition` later.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TransitionKind {
|
||||
None,
|
||||
Glitch,
|
||||
SlideUp,
|
||||
}
|
||||
|
||||
/// Placeholder transition: inactive until the ui module lands.
|
||||
pub struct Transition;
|
||||
|
||||
impl Transition {
|
||||
pub fn new(_kind: TransitionKind, _from: Buffer, _start: u64) -> Self {
|
||||
Transition
|
||||
}
|
||||
pub fn active(&self, _tick: u64) -> bool {
|
||||
false
|
||||
}
|
||||
pub fn apply(&self, _f: &mut Frame, _tick: u64) {}
|
||||
}
|
||||
|
||||
/// What a screen asks the loop to do after an update.
|
||||
pub enum ScreenCmd {
|
||||
Stay,
|
||||
Switch {
|
||||
to: Box<dyn Screen>,
|
||||
fx: TransitionKind,
|
||||
},
|
||||
Quit,
|
||||
}
|
||||
|
||||
pub trait Screen {
|
||||
fn update(&mut self, st: &mut AppState, keys: &[KeyEvent]) -> ScreenCmd;
|
||||
fn draw(&self, st: &AppState, f: &mut Frame);
|
||||
}
|
||||
|
||||
/// Centered notice for terminals under the minimum size. Never panics, even at 1x1.
|
||||
pub fn draw_too_small(f: &mut Frame) {
|
||||
let area = f.area();
|
||||
let msg = format!(
|
||||
"resize to at least {MIN_W}x{MIN_H} (now {}x{})",
|
||||
area.width, area.height
|
||||
);
|
||||
let y = area.height / 2;
|
||||
let line = Rect::new(area.x, area.y + y, area.width, 1.min(area.height));
|
||||
f.render_widget(
|
||||
Paragraph::new(msg)
|
||||
.alignment(Alignment::Center)
|
||||
.style(Style::default().fg(Color::Rgb(255, 80, 120))),
|
||||
line,
|
||||
);
|
||||
}
|
||||
|
||||
/// The fixed-rate loop: poll input, tick, update, draw.
|
||||
pub fn run(mut screen: Box<dyn Screen>, mut st: AppState) -> Result<()> {
|
||||
install_panic_hook();
|
||||
let (_guard, mut term) = TerminalGuard::enter()?;
|
||||
let mut transition: Option<Transition> = None;
|
||||
loop {
|
||||
let start = Instant::now();
|
||||
let mut keys = Vec::new();
|
||||
while event::poll(Duration::ZERO)? {
|
||||
if let Event::Key(k) = event::read()?
|
||||
&& k.kind == KeyEventKind::Press
|
||||
{
|
||||
keys.push(k);
|
||||
}
|
||||
}
|
||||
st.tick += 1;
|
||||
if transition.as_ref().is_some_and(|t| t.active(st.tick)) && !keys.is_empty() {
|
||||
transition = None;
|
||||
}
|
||||
match screen.update(&mut st, &keys) {
|
||||
ScreenCmd::Stay => {}
|
||||
ScreenCmd::Switch { to, fx } => {
|
||||
let snapshot = term.current_buffer_mut().clone();
|
||||
screen = to;
|
||||
transition = Some(Transition::new(fx, snapshot, st.tick));
|
||||
}
|
||||
ScreenCmd::Quit => break,
|
||||
}
|
||||
term.draw(|f| {
|
||||
if f.area().width < MIN_W || f.area().height < MIN_H {
|
||||
draw_too_small(f);
|
||||
return;
|
||||
}
|
||||
screen.draw(&st, f);
|
||||
if let Some(t) = &transition {
|
||||
t.apply(f, st.tick);
|
||||
}
|
||||
})?;
|
||||
if let Some(rest) = FRAME.checked_sub(start.elapsed()) {
|
||||
std::thread::sleep(rest);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
fn text(t: &Terminal<TestBackend>) -> String {
|
||||
let b = t.backend().buffer();
|
||||
let mut s = String::new();
|
||||
for y in 0..b.area.height {
|
||||
for x in 0..b.area.width {
|
||||
s.push_str(b[(x, y)].symbol());
|
||||
}
|
||||
s.push('\n');
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_small_message_never_panics() {
|
||||
for (w, h) in [(1u16, 1u16), (99, 29), (10, 2)] {
|
||||
let mut t = Terminal::new(TestBackend::new(w, h)).unwrap();
|
||||
t.draw(draw_too_small).unwrap();
|
||||
}
|
||||
let mut t = Terminal::new(TestBackend::new(99, 29)).unwrap();
|
||||
t.draw(draw_too_small).unwrap();
|
||||
assert!(text(&t).contains("100"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_state_status_expires() {
|
||||
let mut st = AppState::new();
|
||||
st.set_status("hi", 10);
|
||||
assert_eq!(st.status_text(), Some("hi"));
|
||||
st.tick += 11;
|
||||
assert_eq!(st.status_text(), None);
|
||||
}
|
||||
}
|
||||
132
crates/city/src/cli.rs
Normal file
132
crates/city/src/cli.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
//! Command line: interactive app by default, `gen` for headless generation.
|
||||
|
||||
use crate::app::{self, AppState};
|
||||
use crate::save::save_city;
|
||||
use anyhow::{Context, Result, bail};
|
||||
use citygen::{GenParams, Generator, SizePreset, WaterMode};
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "city",
|
||||
about = "Generate, explore and walk a cyberpunk city in the terminal"
|
||||
)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
cmd: Option<Cmd>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Cmd {
|
||||
/// Generate a city headlessly and write it to a file.
|
||||
Gen {
|
||||
/// Seed; random when omitted.
|
||||
#[arg(long)]
|
||||
seed: Option<u64>,
|
||||
/// small, medium, large or WxH.
|
||||
#[arg(long, default_value = "medium")]
|
||||
size: String,
|
||||
#[arg(long)]
|
||||
districts: Option<u8>,
|
||||
/// none, bay or river.
|
||||
#[arg(long)]
|
||||
water: Option<String>,
|
||||
#[arg(long)]
|
||||
density: Option<f32>,
|
||||
/// Output path (.city, or .json with --json).
|
||||
#[arg(long)]
|
||||
out: PathBuf,
|
||||
/// Write pretty JSON instead of the binary format.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn parse_size(s: &str) -> Result<SizePreset> {
|
||||
match s {
|
||||
"small" => Ok(SizePreset::Small),
|
||||
"medium" => Ok(SizePreset::Medium),
|
||||
"large" => Ok(SizePreset::Large),
|
||||
other => {
|
||||
let (w, h) = other
|
||||
.split_once('x')
|
||||
.with_context(|| format!("size `{other}` is not small/medium/large or WxH"))?;
|
||||
Ok(SizePreset::Custom {
|
||||
w: w.parse().context("bad width")?,
|
||||
h: h.parse().context("bad height")?,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_water(s: &str) -> Result<WaterMode> {
|
||||
match s {
|
||||
"none" => Ok(WaterMode::None),
|
||||
"bay" => Ok(WaterMode::Bay),
|
||||
"river" => Ok(WaterMode::River),
|
||||
other => bail!("water `{other}` is not none/bay/river"),
|
||||
}
|
||||
}
|
||||
|
||||
fn random_seed() -> u64 {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
citygen::rng::hash64(nanos, std::process::id() as u64)
|
||||
}
|
||||
|
||||
pub fn run() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
match cli.cmd {
|
||||
None => app::run(Box::new(crate::screens::initial()), AppState::new()),
|
||||
Some(Cmd::Gen {
|
||||
seed,
|
||||
size,
|
||||
districts,
|
||||
water,
|
||||
density,
|
||||
out,
|
||||
json,
|
||||
}) => {
|
||||
let mut params = GenParams {
|
||||
seed: seed.unwrap_or_else(random_seed),
|
||||
size: parse_size(&size)?,
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(d) = districts {
|
||||
params.districts = d;
|
||||
}
|
||||
if let Some(w) = water {
|
||||
params.water = parse_water(&w)?;
|
||||
}
|
||||
if let Some(d) = density {
|
||||
params.density = d;
|
||||
}
|
||||
params.validate()?;
|
||||
let city = Generator::run(params)?;
|
||||
if json {
|
||||
let file = std::fs::File::create(&out)
|
||||
.with_context(|| format!("creating {}", out.display()))?;
|
||||
serde_json::to_writer_pretty(file, &city)?;
|
||||
} else {
|
||||
save_city(&out, &city)?;
|
||||
}
|
||||
let bytes = std::fs::metadata(&out).map(|m| m.len()).unwrap_or(0);
|
||||
println!(
|
||||
"{} seed {} {}x{} districts {} buildings {} nodes {} -> {} ({} bytes)",
|
||||
city.name,
|
||||
city.seed,
|
||||
city.size.w,
|
||||
city.size.h,
|
||||
city.districts.len(),
|
||||
city.buildings.len(),
|
||||
city.net.nodes.len(),
|
||||
out.display(),
|
||||
bytes
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,14 @@
|
||||
fn main() {
|
||||
println!("city");
|
||||
//! `city`: generate, explore and walk a cyberpunk city in the terminal.
|
||||
|
||||
// Screens, widgets and overlays land task by task; lifted once the app is wired up.
|
||||
#![allow(dead_code)]
|
||||
|
||||
mod app;
|
||||
mod cli;
|
||||
mod save;
|
||||
mod screens;
|
||||
mod term;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
cli::run()
|
||||
}
|
||||
|
||||
198
crates/city/src/save.rs
Normal file
198
crates/city/src/save.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
//! The `.city` file format: magic, version, header, postcard body.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use citygen::{City, Size};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
pub const MAGIC: &[u8; 4] = b"CITY";
|
||||
pub const FORMAT_VERSION: u16 = 1;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct Header {
|
||||
name: String,
|
||||
seed: u64,
|
||||
w: u32,
|
||||
h: u32,
|
||||
}
|
||||
|
||||
/// What the file list shows for one saved city.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CityFileInfo {
|
||||
pub path: PathBuf,
|
||||
pub name: String,
|
||||
pub seed: u64,
|
||||
pub size: Size,
|
||||
pub modified: SystemTime,
|
||||
}
|
||||
|
||||
/// Where cities are saved by default.
|
||||
pub fn default_dir() -> PathBuf {
|
||||
PathBuf::from("cities")
|
||||
}
|
||||
|
||||
pub fn save_city(path: &Path, city: &City) -> Result<()> {
|
||||
let header = Header {
|
||||
name: city.name.clone(),
|
||||
seed: city.seed,
|
||||
w: city.size.w,
|
||||
h: city.size.h,
|
||||
};
|
||||
let header_bytes = postcard::to_allocvec(&header)?;
|
||||
let body = postcard::to_allocvec(city)?;
|
||||
let mut out = Vec::with_capacity(10 + header_bytes.len() + body.len());
|
||||
out.extend_from_slice(MAGIC);
|
||||
out.extend_from_slice(&FORMAT_VERSION.to_le_bytes());
|
||||
out.extend_from_slice(&(header_bytes.len() as u32).to_le_bytes());
|
||||
out.extend_from_slice(&header_bytes);
|
||||
out.extend_from_slice(&body);
|
||||
if let Some(parent) = path.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("creating {}", parent.display()))?;
|
||||
}
|
||||
std::fs::write(path, out).with_context(|| format!("writing {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Splits a file into (header, body) after checking magic and version.
|
||||
fn split<'a>(bytes: &'a [u8], path: &Path) -> Result<(Header, &'a [u8])> {
|
||||
if bytes.len() < 10 || &bytes[0..4] != MAGIC {
|
||||
bail!("{} is not a .city file", path.display());
|
||||
}
|
||||
let version = u16::from_le_bytes([bytes[4], bytes[5]]);
|
||||
if version != FORMAT_VERSION {
|
||||
bail!(
|
||||
"{} is .city version {version}; this build reads version {FORMAT_VERSION}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
let header_len = u32::from_le_bytes([bytes[6], bytes[7], bytes[8], bytes[9]]) as usize;
|
||||
let header_end = 10 + header_len;
|
||||
if bytes.len() < header_end {
|
||||
bail!("{} is truncated", path.display());
|
||||
}
|
||||
let header: Header = postcard::from_bytes(&bytes[10..header_end])
|
||||
.with_context(|| format!("{}: corrupt header", path.display()))?;
|
||||
Ok((header, &bytes[header_end..]))
|
||||
}
|
||||
|
||||
pub fn load_city(path: &Path) -> Result<City> {
|
||||
let bytes = std::fs::read(path).with_context(|| format!("reading {}", path.display()))?;
|
||||
let (_, body) = split(&bytes, path)?;
|
||||
let city: City = postcard::from_bytes(body)
|
||||
.with_context(|| format!("{}: corrupt or truncated city data", path.display()))?;
|
||||
Ok(city)
|
||||
}
|
||||
|
||||
/// Lists `.city` files in `dir`, newest first. A missing directory lists as empty.
|
||||
pub fn list_cities(dir: &Path) -> Result<Vec<CityFileInfo>> {
|
||||
let mut out = Vec::new();
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
|
||||
Err(e) => return Err(e).with_context(|| format!("listing {}", dir.display())),
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("city") {
|
||||
continue;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Ok((header, _)) = split(&bytes, &path) else {
|
||||
continue;
|
||||
};
|
||||
let modified = entry
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
out.push(CityFileInfo {
|
||||
path,
|
||||
name: header.name,
|
||||
seed: header.seed,
|
||||
size: Size {
|
||||
w: header.w,
|
||||
h: header.h,
|
||||
},
|
||||
modified,
|
||||
});
|
||||
}
|
||||
out.sort_by(|a, b| b.modified.cmp(&a.modified).then(a.path.cmp(&b.path)));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use citygen::{GenParams, Generator, SizePreset};
|
||||
|
||||
fn tiny() -> citygen::City {
|
||||
Generator::run(GenParams {
|
||||
seed: 5,
|
||||
size: SizePreset::Custom { w: 80, h: 40 },
|
||||
districts: 4,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("city-test-{}-{name}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_and_header() {
|
||||
let c = tiny();
|
||||
let dir = tmp("rt");
|
||||
let path = dir.join("a.city");
|
||||
save_city(&path, &c).unwrap();
|
||||
let back = load_city(&path).unwrap();
|
||||
assert_eq!(
|
||||
postcard::to_allocvec(&c).unwrap(),
|
||||
postcard::to_allocvec(&back).unwrap()
|
||||
);
|
||||
let list = list_cities(&dir).unwrap();
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].seed, 5);
|
||||
assert_eq!(list[0].name, c.name);
|
||||
assert_eq!(list[0].size.w, 80);
|
||||
assert!(list_cities(&dir.join("missing")).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_magic_version_and_truncation() {
|
||||
let dir = tmp("bad");
|
||||
let p = dir.join("x.city");
|
||||
std::fs::write(&p, b"NOPE\x01\x00").unwrap();
|
||||
assert!(
|
||||
load_city(&p)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("not a .city file")
|
||||
);
|
||||
let mut bytes = b"CITY".to_vec();
|
||||
bytes.extend_from_slice(&99u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&0u32.to_le_bytes());
|
||||
std::fs::write(&p, &bytes).unwrap();
|
||||
assert!(
|
||||
load_city(&p)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("version 99")
|
||||
);
|
||||
let c = tiny();
|
||||
save_city(&p, &c).unwrap();
|
||||
let full = std::fs::read(&p).unwrap();
|
||||
std::fs::write(&p, &full[..full.len() / 2]).unwrap();
|
||||
assert!(load_city(&p).is_err());
|
||||
std::fs::write(&p, b"CI").unwrap();
|
||||
assert!(load_city(&p).is_err());
|
||||
}
|
||||
}
|
||||
48
crates/city/src/screens/mod.rs
Normal file
48
crates/city/src/screens/mod.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
//! Screens: one module per screen, plus shared test helpers.
|
||||
|
||||
use crate::app::{AppState, Screen, ScreenCmd};
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use ratatui::Frame;
|
||||
|
||||
/// Draws nothing; stands in until the title screen lands.
|
||||
pub struct Blank;
|
||||
|
||||
impl Screen for Blank {
|
||||
fn update(&mut self, _st: &mut AppState, keys: &[KeyEvent]) -> ScreenCmd {
|
||||
if keys
|
||||
.iter()
|
||||
.any(|k| matches!(k.code, KeyCode::Char('q') | KeyCode::Esc))
|
||||
{
|
||||
ScreenCmd::Quit
|
||||
} else {
|
||||
ScreenCmd::Stay
|
||||
}
|
||||
}
|
||||
fn draw(&self, _st: &AppState, _f: &mut Frame) {}
|
||||
}
|
||||
|
||||
/// The screen the app starts on.
|
||||
pub fn initial() -> impl Screen {
|
||||
Blank
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod testutil {
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::buffer::Buffer;
|
||||
|
||||
pub fn key(code: KeyCode) -> KeyEvent {
|
||||
KeyEvent::new(code, KeyModifiers::NONE)
|
||||
}
|
||||
|
||||
pub fn buffer_text(b: &Buffer) -> String {
|
||||
let mut s = String::new();
|
||||
for y in 0..b.area.height {
|
||||
for x in 0..b.area.width {
|
||||
s.push_str(b[(x, y)].symbol());
|
||||
}
|
||||
s.push('\n');
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
40
crates/city/src/term.rs
Normal file
40
crates/city/src/term.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
//! Raw-mode terminal setup that always restores the shell, even on panic.
|
||||
|
||||
use anyhow::Result;
|
||||
use crossterm::{cursor, execute, terminal};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use std::io::{Stdout, stdout};
|
||||
|
||||
/// Restores the terminal when dropped.
|
||||
pub struct TerminalGuard;
|
||||
|
||||
impl TerminalGuard {
|
||||
pub fn enter() -> Result<(Self, Terminal<CrosstermBackend<Stdout>>)> {
|
||||
terminal::enable_raw_mode()?;
|
||||
execute!(stdout(), terminal::EnterAlternateScreen, cursor::Hide)?;
|
||||
let term = Terminal::new(CrosstermBackend::new(stdout()))?;
|
||||
Ok((TerminalGuard, term))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalGuard {
|
||||
fn drop(&mut self) {
|
||||
restore();
|
||||
}
|
||||
}
|
||||
|
||||
/// Leaves raw mode and the alternate screen. Safe to call more than once.
|
||||
pub fn restore() {
|
||||
let _ = terminal::disable_raw_mode();
|
||||
let _ = execute!(stdout(), terminal::LeaveAlternateScreen, cursor::Show);
|
||||
}
|
||||
|
||||
/// Makes a panic restore the terminal before printing its message.
|
||||
pub fn install_panic_hook() {
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
restore();
|
||||
previous(info);
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user