use std::fs::File; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::thread::JoinHandle; use anyhow::{Context, Result}; use clipboard::ClipboardProvider; use druid::{AppDelegate, AppLauncher, Command, Data, DelegateCtx, Env, FileDialogOptions, FileSpec, Handled, Lens, Menu, MenuItem, RawMods, Selector, Target, Widget, WindowDesc, WindowId}; use druid::widget::{Align, Padding}; use num_format::Locale; use crate::{GameState, Notes, NotesState}; use crate::widget::notes_viewer_scroll::NotesViewerScroll; #[derive(Clone, Data, Lens)] pub struct App { pub notes: Option<(Notes, String)>, pub game_state: Option, pub notes_state: Option, #[data(same_fn = "PartialEq::eq")] locale: Locale, change_counter: u64, update_thread: Option>>, update_thread_continue: Arc, } impl App { fn changed(&mut self) { self.change_counter = self.change_counter.wrapping_add(1); } } impl Default for App { fn default() -> Self { Self { notes: None, game_state: None, notes_state: None, locale: Locale::en_GB, change_counter: 0, update_thread: None, update_thread_continue: Arc::new(AtomicBool::new(true)), } } } impl App { const CHANGED: Selector<()> = Selector::new("changed"); const CONNECT: Selector<()> = Selector::new("connect"); const DISCONNECT: Selector<()> = Selector::new("disconnect"); const REFRESH: Selector<()> = Selector::new("refresh"); pub fn launch(mut self) -> Result<()> { let main_window = App::build_window(); let launcher = AppLauncher::with_window(main_window); let event_sink = launcher.get_external_handle(); let should_continue = Arc::clone(&self.update_thread_continue); self.update_thread = Some(Arc::new(std::thread::spawn(move || { while should_continue.load(Ordering::SeqCst) { event_sink.submit_command( Self::REFRESH, (), Target::Auto, ).ok(); std::thread::sleep(std::time::Duration::from_millis(16)); } }))); launcher .delegate(Delegate) .launch(self) .context("could not launch app") } fn build_window() -> WindowDesc { WindowDesc::new(Self::build_root_widget()) .title("FFXII: TZA Auto Notes") .menu(Self::build_menu) .window_size((400.0, 800.0)) } fn build_root_widget() -> impl Widget { // let location = Label::new(|data: &Option, _: &Env| { // match &data { // Some(game_state) => format!( // "{} ({})", // game_state.location_name(), // game_state.location, // ), // None => "Not connected".into(), // } // }) // .with_line_break_mode(LineBreaking::WordWrap) // .lens(App::game_state); // // let stage = Label::new(|data: &Option, _: &Env| { // match &data { // Some(game_state) => game_state.stage.to_string(), // None => "Not connected".into(), // } // }) // .with_line_break_mode(LineBreaking::WordWrap) // .lens(App::game_state); // // let notes_state = Label::new(|data: &Option, _: &Env| { // match data { // Some(notes_state) => format!("Step {:?}, area {}", notes_state.step_idx, notes_state.area_idx), // None => "Not connected".into(), // } // }) // .with_line_break_mode(LineBreaking::WordWrap) // .lens(App::notes_state); // // let buttons = Flex::row() // .with_flex_child( // Button::new("Stage") // .on_click(|ctx, data: &mut Option, env: &Env| { // let game_state = match data { // Some(game_state) => game_state.clone(), // None => return, // }; // // if let Ok(mut clipboard) = clipboard::ClipboardContext::new() { // clipboard.set_contents(format!( // " - stage: {stage} // areas: // # {area_id}: {area_name} // - area: {area_id} // steps: |- // - ", // stage = game_state.stage, // area_id = game_state.location, // area_name = game_state.location_name(), // )).ok(); // } // }) // .lens(App::game_state), // 1.0, // ) // .with_flex_child( // Button::new("Area") // .on_click(|ctx, data: &mut Option, env: &Env| { // let game_state = match data { // Some(game_state) => game_state.clone(), // None => return, // }; // // if let Ok(mut clipboard) = clipboard::ClipboardContext::new() { // clipboard.set_contents(format!( // " # {area_id}: {area_name} // - area: {area_id} // steps: |- // - ", // area_id = game_state.location, // area_name = game_state.location_name(), // )).ok(); // } // }) // .lens(App::game_state), // 1.0, // ); // let info = Flex::column() // .with_child(location) // .with_child(Separator::new().with_orientation(Orientation::Horizontal)) // .with_child(stage) // .with_child(Separator::new().with_orientation(Orientation::Horizontal)) // .with_child(notes_state) // .with_child(Separator::new().with_orientation(Orientation::Horizontal)) // .with_child(buttons); // let layout = Split::columns(NotesViewerScroll::new(), info) // .draggable(true) // .split_point(0.75); Align::centered(Padding::new((8.0, 8.0, 8.0, 0.0), NotesViewerScroll::new())) } fn build_menu(_: Option, _data: &App, _env: &Env) -> Menu { Menu::empty() .entry(Self::build_file_menu()) } fn build_file_menu() -> Menu { let connect: MenuItem = MenuItem::new("Connect") .command(Command::new(Self::CONNECT, (), Target::Auto)) .enabled_if(|data: &App, _: &Env| data.game_state.is_none()); let disconnect: MenuItem = MenuItem::new("Disconnect") .command(Command::new(Self::DISCONNECT, (), Target::Auto)) .enabled_if(|data: &App, _: &Env| data.game_state.is_some()); let yaml = FileSpec::new("Auto notes", &["yml", "yaml"]); let open_options = FileDialogOptions::new() .allowed_types(vec![yaml]) .default_type(yaml) .name_label("Notes") .title("Open auto notes") .button_text("Open"); let open_notes: MenuItem = MenuItem::new("Open notes") .command(Command::new( druid::commands::SHOW_OPEN_PANEL, open_options, Target::Auto)) .hotkey(RawMods::Ctrl, "o"); let exit: MenuItem = MenuItem::new("Exit") .command(druid::commands::CLOSE_ALL_WINDOWS); Menu::new("File") .entry(connect) .entry(disconnect) .separator() .entry(open_notes) .separator() .entry(exit) } fn set_up_notes_state(&mut self) { let notes = match self.notes.clone() { Some((notes, _)) => notes, None => return, }; if let Some(game_state) = &mut self.game_state { match NotesState::new(notes, game_state) { Ok(notes_state) => self.notes_state = Some(notes_state), Err(e) => eprintln!("could not set up notes state: {:#?}", e), } } } } struct Delegate; impl AppDelegate for Delegate { fn command(&mut self, _ctx: &mut DelegateCtx, _target: Target, cmd: &Command, data: &mut App, _env: &Env) -> Handled { if let Some(()) = cmd.get(App::CHANGED) { data.changed(); } if let Some(()) = cmd.get(App::CONNECT) { match GameState::new() { Ok(game_state) => { data.game_state = Some(game_state); data.set_up_notes_state(); data.changed(); } Err(e) => eprintln!("could not connect: {:#?}", e), } return Handled::Yes; } if let Some(()) = cmd.get(App::DISCONNECT) { data.game_state = None; data.notes_state = None; } if let Some(()) = cmd.get(App::REFRESH) { if let Some(game_state) = &mut data.game_state { match &mut data.notes_state { Some(notes_state) => { // this will refresh gamestate if let Err(e) = notes_state.tick(game_state) { eprintln!("could not refresh notes: {:#?}", e); } } None => if let Err(e) = game_state.refresh() { eprintln!("could not refresh game state: {:#?}", e); } } } } if let Some(info) = cmd.get(druid::commands::OPEN_FILE) { match File::open(info.path()) .context("could not open notes file") .and_then(|file| serde_yaml::from_reader::<_, Notes>(file).context("could not parse notes file")) { Ok(notes) => { data.notes = Some((notes.clone(), info.path().to_string_lossy().to_string())); data.set_up_notes_state(); data.changed(); } Err(err) => { eprintln!("{:#?}", err); } } return Handled::Yes; } Handled::No } fn window_removed(&mut self, _id: WindowId, data: &mut App, _env: &Env, _ctx: &mut DelegateCtx) { let thread = match data.update_thread.take() { Some(thread) => thread, None => return, }; data.update_thread_continue.store(false, Ordering::SeqCst); if let Ok(thread) = Arc::try_unwrap(thread) { thread.join().ok(); } } }