Skip to main content

foch_cli/tui/browser/
session.rs

1use std::any::Any;
2use std::panic::{AssertUnwindSafe, catch_unwind};
3use std::path::PathBuf;
4use std::sync::Arc;
5use std::sync::mpsc::{self, Receiver, Sender};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use crossterm::event::KeyEvent;
9use foch::game::eu4::base::snapshot::BaseBuildObserver;
10use foch::input::{Config, CurrentEu4Input, inspect_current_eu4_input};
11use foch::merge::{CancellationToken, MergeError, MergeProgress, ProgressObserver, analyze_merge};
12
13use super::app::{AnalysisView, App, AppCommand, Effect, Phase};
14use crate::cli::arg::MergeArgs;
15use crate::cli::handler::merge::{PreparedMerge, prepare_merge};
16
17/// What an analysis of an inspected input reads, as `foch merge` takes it.
18#[derive(Clone, Debug)]
19pub enum AnalysisInput {
20	/// The current EU4 playset, frozen at inspection. `foch merge` without
21	/// INPUT_SOURCE inspects and prepares it the same way.
22	Current(Box<CurrentEu4Input>),
23	/// An explicit `foch merge` INPUT_SOURCE with its configuration.
24	Path { path: PathBuf, config: Config },
25}
26
27/// One read-only look at the current input: what to show, and what an
28/// analysis of exactly that input reads.
29pub struct Inspection {
30	pub input: CurrentEu4Input,
31	/// `None` when nothing about this input can be analyzed, whatever is
32	/// excluded.
33	pub analysis: Option<AnalysisInput>,
34	/// Whether this source can supply the analyzed EU4 base as the merge
35	/// ancestor.
36	pub game_base_available: bool,
37}
38
39/// Where the browser reads its input from. The product source inspects the
40/// installed EU4 game and its launcher playset; tests supply fixtures.
41pub trait BrowserSource: Send + Sync {
42	fn inspect(&self) -> Inspection;
43}
44
45/// The installed EU4 game and its current launcher playset, inspected without
46/// initializing configuration, as `foch input inspect` reports it.
47#[derive(Clone, Copy, Debug, Default)]
48pub struct CurrentEu4Source;
49
50impl BrowserSource for CurrentEu4Source {
51	fn inspect(&self) -> Inspection {
52		let input = inspect_current_eu4_input();
53		let analysis = input
54			.can_select_mods(false)
55			.then(|| AnalysisInput::Current(Box::new(input.clone())));
56		let game_base_available = input_base_ready(&input);
57		Inspection {
58			input,
59			analysis,
60			game_base_available,
61		}
62	}
63}
64
65/// Whether the inspected base data is usable as the merge ancestor.
66fn input_base_ready(input: &CurrentEu4Input) -> bool {
67	input.can_select_mods(true)
68}
69
70enum WorkerMessage {
71	Inspected {
72		generation: u64,
73		inspection: Box<Inspection>,
74	},
75	Progress {
76		generation: u64,
77		progress: MergeProgress,
78	},
79	Analyzed {
80		generation: u64,
81		result: Result<Box<AnalysisView>, String>,
82	},
83	Built {
84		generation: u64,
85		result: Result<Vec<String>, String>,
86	},
87	BuildStage {
88		generation: u64,
89		name: String,
90		done: bool,
91	},
92}
93
94struct ChannelProgress {
95	generation: u64,
96	tx: Sender<WorkerMessage>,
97}
98
99impl ProgressObserver for ChannelProgress {
100	fn update(&self, progress: MergeProgress) {
101		let _ = self.tx.send(WorkerMessage::Progress {
102			generation: self.generation,
103			progress,
104		});
105	}
106}
107
108/// The browser state plus the background work feeding it. Every inspection
109/// and analysis carries a generation, so results from work a refresh replaced
110/// are dropped instead of mixed into the new snapshot.
111pub struct Session {
112	app: App,
113	source: Arc<dyn BrowserSource>,
114	tx: Sender<WorkerMessage>,
115	rx: Receiver<WorkerMessage>,
116	generation: u64,
117	pending: Option<AnalysisInput>,
118	cancellation: Option<CancellationToken>,
119	busy: bool,
120}
121
122impl Session {
123	pub fn new(source: Arc<dyn BrowserSource>) -> Self {
124		let (tx, rx) = mpsc::channel();
125		let mut session = Self {
126			app: App::default(),
127			source,
128			tx,
129			rx,
130			generation: 0,
131			pending: None,
132			cancellation: None,
133			busy: false,
134		};
135		session.start_inspection(false);
136		session
137	}
138
139	pub fn app(&self) -> &App {
140		&self.app
141	}
142
143	/// Whether an inspection or analysis is still running.
144	pub fn is_busy(&self) -> bool {
145		self.busy
146	}
147
148	/// Apply one key press. Returns `false` once the user asked to quit.
149	pub fn handle_key(&mut self, key: KeyEvent) -> bool {
150		match self.app.handle_key(key) {
151			Some(AppCommand::Quit) => {
152				self.cancel_running();
153				return false;
154			}
155			Some(AppCommand::Analyze) => self.start_analysis(),
156			Some(AppCommand::BuildBaseData) => self.start_build(),
157			Some(AppCommand::Refresh) => {
158				// Refreshing a browsed analysis replaces both the input
159				// snapshot and the analysis; before one, it only re-inspects.
160				let analyzed = matches!(self.app.phase, Phase::Reviewed(_) | Phase::Failed(_));
161				self.start_inspection(analyzed);
162			}
163			None => {}
164		}
165		true
166	}
167
168	/// Take what the last key asked the run loop to do outside the browser.
169	pub fn take_effect(&mut self) -> Option<Effect> {
170		self.app.effect.take()
171	}
172
173	/// After a drag ends, the text it covered in the frame just drawn, to
174	/// copy.
175	pub fn take_selection(&mut self, buffer: &ratatui::buffer::Buffer) -> Option<String> {
176		if !std::mem::take(&mut self.app.copy_selection) {
177			return None;
178		}
179		let text =
180			super::render::selected_text(self.app.selection?, self.app.selection_bounds, buffer);
181		self.app.notice = Some(format!(
182			"Copied {} selected characters.",
183			text.chars().count()
184		));
185		Some(text)
186	}
187
188	/// Report that carrying out an effect failed.
189	pub fn effect_failed(&mut self, error: String) {
190		self.app.notice = None;
191		self.app.refusal = Some(error);
192	}
193
194	pub fn handle_mouse(&mut self, mouse: crossterm::event::MouseEvent) {
195		self.app.handle_mouse(mouse);
196	}
197
198	/// Apply every finished worker message, waiting up to `timeout` for the
199	/// first one. Returns whether anything changed.
200	pub fn pump(&mut self, timeout: Duration) -> bool {
201		let mut changed = false;
202		let mut next = self.rx.recv_timeout(timeout).ok();
203		while let Some(message) = next {
204			changed |= self.apply(message);
205			next = self.rx.try_recv().ok();
206		}
207		changed
208	}
209
210	/// Block until the current inspection or analysis finishes.
211	pub fn wait_idle(&mut self) {
212		while self.busy {
213			self.pump(Duration::from_millis(50));
214		}
215	}
216
217	fn apply(&mut self, message: WorkerMessage) -> bool {
218		match message {
219			WorkerMessage::Inspected {
220				generation,
221				inspection,
222			} if generation == self.generation => {
223				let Inspection {
224					input,
225					analysis,
226					game_base_available,
227				} = *inspection;
228				self.busy = false;
229				self.app
230					.inspected(input, analysis.is_some(), game_base_available);
231				if let Some(AnalysisInput::Path { path, .. }) = &analysis {
232					self.app.source_path = Some(path.clone());
233				}
234				self.pending = analysis;
235				if self.app.take_analyze_after_inspection() {
236					self.start_analysis();
237				}
238				true
239			}
240			WorkerMessage::Progress {
241				generation,
242				progress,
243			} if generation == self.generation => {
244				self.app.progress(progress);
245				true
246			}
247			WorkerMessage::BuildStage {
248				generation,
249				name,
250				done,
251			} if generation == self.generation => {
252				self.app.build_stage(name, done);
253				true
254			}
255			WorkerMessage::Built { generation, result } if generation == self.generation => {
256				self.busy = false;
257				self.app.built(result);
258				// The new base data changes what can be analyzed.
259				self.start_inspection(false);
260				true
261			}
262			WorkerMessage::Analyzed { generation, result } if generation == self.generation => {
263				self.busy = false;
264				self.cancellation = None;
265				self.app.analyzed(result.map(|view| *view));
266				true
267			}
268			_ => false,
269		}
270	}
271
272	fn start_inspection(&mut self, analyze_after: bool) {
273		self.cancel_running();
274		self.generation += 1;
275		self.pending = None;
276		self.busy = true;
277		self.app.inspecting(analyze_after);
278		let generation = self.generation;
279		let source = Arc::clone(&self.source);
280		let tx = self.tx.clone();
281		std::thread::spawn(move || {
282			let message = match catch_unwind(AssertUnwindSafe(|| source.inspect())) {
283				Ok(inspection) => WorkerMessage::Inspected {
284					generation,
285					inspection: Box::new(inspection),
286				},
287				Err(panic) => WorkerMessage::Analyzed {
288					generation,
289					result: Err(format!(
290						"input inspection panicked: {}",
291						panic_text(&*panic)
292					)),
293				},
294			};
295			let _ = tx.send(message);
296		});
297	}
298
299	/// Build base data through the same `foch data build` code the CLI runs.
300	fn start_build(&mut self) {
301		let Some(args) = self.app.data_build_args() else {
302			return;
303		};
304		self.cancel_running();
305		self.generation += 1;
306		self.busy = true;
307		self.app.building();
308		let generation = self.generation;
309		let tx = self.tx.clone();
310		let stages = self.tx.clone();
311		spawn_with_merge_stack(move || {
312			let observer = BaseBuildObserver::silent(&args.game_name).with_stage_listener(
313				Box::new(move |name: &str, done: bool| {
314					let _ = stages.send(WorkerMessage::BuildStage {
315						generation,
316						name: name.to_string(),
317						done,
318					});
319				}),
320			);
321			let result = catch_unwind(AssertUnwindSafe(|| {
322				let config =
323					foch::input::load_config_read_only().map_err(|error| error.to_string())?;
324				crate::cli::handler::data::run_data_build(&args, &config, observer)
325					.map_err(|error| error.to_string())
326			}))
327			.unwrap_or_else(|panic| {
328				Err(format!("base data build panicked: {}", panic_text(&*panic)))
329			});
330			let _ = tx.send(WorkerMessage::Built { generation, result });
331		});
332	}
333
334	fn start_analysis(&mut self) {
335		// An input snapshot is analyzed once; a later analysis needs a new
336		// snapshot from an explicit refresh.
337		let Some(input) = self.pending.take() else {
338			return;
339		};
340		let out_dir = unused_analysis_target();
341		let merge_args = self.app.merge_args(out_dir);
342		self.generation += 1;
343		self.busy = true;
344		self.app.analyzing();
345		let generation = self.generation;
346		let cancellation = CancellationToken::new();
347		self.cancellation = Some(cancellation.clone());
348		let tx = self.tx.clone();
349		spawn_with_merge_stack(move || {
350			let progress = ChannelProgress {
351				generation,
352				tx: tx.clone(),
353			};
354			let result = catch_unwind(AssertUnwindSafe(|| {
355				analyze_input(&merge_args, input, &progress, &cancellation)
356			}))
357			.unwrap_or_else(|panic| Err(format!("analysis panicked: {}", panic_text(&*panic))))
358			.map(Box::new);
359			let _ = tx.send(WorkerMessage::Analyzed { generation, result });
360		});
361	}
362
363	fn cancel_running(&mut self) {
364		if let Some(cancellation) = self.cancellation.take() {
365			cancellation.cancel();
366		}
367	}
368}
369
370impl Drop for Session {
371	fn drop(&mut self) {
372		self.cancel_running();
373	}
374}
375
376/// Analysis walks deeply nested Clausewitz trees; give it the same stack the
377/// CLI's main thread gets.
378fn spawn_with_merge_stack(work: impl FnOnce() + Send + 'static) {
379	std::thread::Builder::new()
380		.name("foch-tui-analysis".to_string())
381		.stack_size(64 * 1024 * 1024)
382		.spawn(work)
383		.expect("spawn analysis thread");
384}
385
386/// Run the complete N-way analysis `foch merge` runs for `merge_args`, over
387/// the inspected input. The browser never commits, so `merge_args.out` only
388/// names where a commit would have gone; it is a fresh path that analysis
389/// leaves untouched.
390pub fn analyze_input(
391	merge_args: &MergeArgs,
392	input: AnalysisInput,
393	progress: &dyn ProgressObserver,
394	cancellation: &CancellationToken,
395) -> Result<AnalysisView, String> {
396	let (config, current) = match input {
397		AnalysisInput::Current(current) => (Config::default(), Some(*current)),
398		AnalysisInput::Path { config, .. } => (config, None),
399	};
400	let PreparedMerge { request, options } =
401		prepare_merge(merge_args, config, current).map_err(|error| error.to_string())?;
402	let analyzed =
403		analyze_merge(request, options, progress, cancellation).map_err(|error| match error {
404			MergeError::Cancelled => "analysis cancelled".to_string(),
405			other => other.to_string(),
406		})?;
407	debug_assert!(
408		!merge_args.out.exists(),
409		"analysis must not create its target"
410	);
411	Ok(AnalysisView::from_analyzed(&analyzed))
412}
413
414fn unused_analysis_target() -> PathBuf {
415	let nanos = SystemTime::now()
416		.duration_since(UNIX_EPOCH)
417		.map_or(0, |elapsed| elapsed.as_nanos());
418	std::env::temp_dir().join(format!("foch-tui-analysis-{}-{nanos}", std::process::id()))
419}
420
421/// A worker panic surfaces as a failed result instead of a browser that waits
422/// forever.
423fn panic_text(panic: &(dyn Any + Send)) -> String {
424	panic
425		.downcast_ref::<&str>()
426		.map(|text| (*text).to_string())
427		.or_else(|| panic.downcast_ref::<String>().cloned())
428		.unwrap_or_else(|| "unknown panic".to_string())
429}