Skip to main content

foch_cli/tui/browser/
mod.rs

1//! The read-only analysis browser that bare `foch` opens: inspect the current
2//! EU4 input, run the complete N-way merge analysis, and browse every review
3//! unit. It never records decisions, builds artifacts, or writes output.
4
5mod app;
6mod quiet_stderr;
7mod render;
8mod session;
9
10pub use app::{
11	AnalysisSettings, AnalysisView, App, DISPOSITIONS, Effect, Focus, HitAreas, InputPane,
12	KEY_HELP, OPTION_COUNT, Phase, Screen, disposition_label,
13};
14pub use render::{draw, selected_text, unit_detail};
15pub use session::{
16	AnalysisInput, BrowserSource, CurrentEu4Source, Inspection, Session, analyze_input,
17};
18
19use std::io::{self, IsTerminal, Write};
20use std::sync::Arc;
21use std::time::Duration;
22
23use crossterm::cursor;
24use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event};
25use crossterm::execute;
26use crossterm::terminal::{
27	EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
28};
29use ratatui::Terminal;
30use ratatui::backend::CrosstermBackend;
31
32const TICK: Duration = Duration::from_millis(100);
33
34/// Open the browser on the current EU4 input. Returns the process exit code.
35pub fn run() -> io::Result<i32> {
36	if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
37		eprintln!(
38			"error: bare `foch` opens an interactive terminal browser and needs a TTY; run `foch --help` for subcommands"
39		);
40		return Ok(2);
41	}
42	// Dropped last, after the terminal is restored.
43	let _quiet_stderr = quiet_stderr::QuietStderr::new();
44	let mut session = Session::new(Arc::new(CurrentEu4Source));
45
46	enable_raw_mode()?;
47	let _guard = TerminalGuard;
48	let mut stdout = io::stdout();
49	execute!(
50		stdout,
51		EnterAlternateScreen,
52		EnableMouseCapture,
53		cursor::Hide
54	)?;
55	let mut terminal = Terminal::new(CrosstermBackend::new(stdout))?;
56
57	loop {
58		let drawn = terminal.draw(|frame| draw(frame, session.app()))?;
59		if let Some(text) = session.take_selection(drawn.buffer)
60			&& let Err(error) = copy_to_clipboard(&text, terminal.backend_mut())
61		{
62			session.effect_failed(error);
63		}
64		if event::poll(TICK)? {
65			match event::read()? {
66				Event::Key(key) if !session.handle_key(key) => return Ok(0),
67				Event::Mouse(mouse) => session.handle_mouse(mouse),
68				Event::Resize(..) => terminal.clear()?,
69				_ => {}
70			}
71		}
72		if let Some(effect) = session.take_effect()
73			&& let Err(error) = carry_out(effect, terminal.backend_mut())
74		{
75			session.effect_failed(error);
76		}
77		session.pump(Duration::ZERO);
78	}
79}
80
81fn carry_out(effect: Effect, out: &mut impl Write) -> Result<(), String> {
82	match effect {
83		Effect::Copy(text) => copy_to_clipboard(&text, out),
84		Effect::OpenWorkshop(targets) => crate::cli::handler::input::open_workshop_pages(&targets)
85			.map(|_| ())
86			.map_err(|error| format!("could not open Steam: {error}")),
87		Effect::MouseCapture(true) => {
88			execute!(out, EnableMouseCapture).map_err(|error| error.to_string())
89		}
90		Effect::MouseCapture(false) => {
91			execute!(out, DisableMouseCapture).map_err(|error| error.to_string())
92		}
93	}
94}
95
96/// Copy through the terminal (OSC 52), which also works over SSH. The legacy
97/// Windows console has no OSC 52, so Windows falls back to `clip.exe`.
98fn copy_to_clipboard(text: &str, out: &mut impl Write) -> Result<(), String> {
99	use crossterm::clipboard::CopyToClipboard;
100	match execute!(out, CopyToClipboard::to_clipboard_from(text)) {
101		Ok(()) => Ok(()),
102		Err(_) if cfg!(windows) => copy_with_clip_exe(text),
103		Err(error) => Err(format!("could not copy: {error}")),
104	}
105}
106
107fn copy_with_clip_exe(text: &str) -> Result<(), String> {
108	use std::process::{Command, Stdio};
109	let mut child = Command::new("clip")
110		.stdin(Stdio::piped())
111		.stdout(Stdio::null())
112		.stderr(Stdio::null())
113		.spawn()
114		.map_err(|error| format!("could not copy: {error}"))?;
115	// clip.exe reads UTF-16LE text that starts with a byte order mark.
116	let bytes = std::iter::once(0xFEFF_u16)
117		.chain(text.encode_utf16())
118		.flat_map(u16::to_le_bytes)
119		.collect::<Vec<_>>();
120	let written = match child.stdin.take() {
121		Some(mut stdin) => stdin.write_all(&bytes),
122		None => Ok(()),
123	};
124	let waited = child.wait();
125	written
126		.and(waited)
127		.map(|_| ())
128		.map_err(|error| format!("could not copy: {error}"))
129}
130
131struct TerminalGuard;
132
133impl Drop for TerminalGuard {
134	fn drop(&mut self) {
135		let _ = disable_raw_mode();
136		let mut stdout = io::stdout();
137		let _ = execute!(
138			stdout,
139			DisableMouseCapture,
140			cursor::Show,
141			LeaveAlternateScreen
142		);
143	}
144}