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#[derive(Clone, Debug)]
19pub enum AnalysisInput {
20 Current(Box<CurrentEu4Input>),
23 Path { path: PathBuf, config: Config },
25}
26
27pub struct Inspection {
30 pub input: CurrentEu4Input,
31 pub analysis: Option<AnalysisInput>,
34 pub game_base_available: bool,
37}
38
39pub trait BrowserSource: Send + Sync {
42 fn inspect(&self) -> Inspection;
43}
44
45#[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
65fn 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
108pub 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 pub fn is_busy(&self) -> bool {
145 self.busy
146 }
147
148 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 let analyzed = matches!(self.app.phase, Phase::Reviewed(_) | Phase::Failed(_));
161 self.start_inspection(analyzed);
162 }
163 None => {}
164 }
165 true
166 }
167
168 pub fn take_effect(&mut self) -> Option<Effect> {
170 self.app.effect.take()
171 }
172
173 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 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 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 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 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 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 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
376fn 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
386pub 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
421fn 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}