ffxii-tza-auto-notes/src/app.rs

310 lines
11 KiB
Rust
Executable File

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<GameState>,
pub notes_state: Option<NotesState>,
#[data(same_fn = "PartialEq::eq")]
locale: Locale,
change_counter: u64,
update_thread: Option<Arc<JoinHandle<()>>>,
update_thread_continue: Arc<AtomicBool>,
}
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<App> {
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<App> {
// let location = Label::new(|data: &Option<GameState>, _: &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<GameState>, _: &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<NotesState>, _: &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<GameState>, 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<GameState>, 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<WindowId>, _data: &App, _env: &Env) -> Menu<App> {
Menu::empty()
.entry(Self::build_file_menu())
}
fn build_file_menu() -> Menu<App> {
let connect: MenuItem<App> = MenuItem::new("Connect")
.command(Command::new(Self::CONNECT, (), Target::Auto))
.enabled_if(|data: &App, _: &Env| data.game_state.is_none());
let disconnect: MenuItem<App> = 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<App> = MenuItem::new("Open notes")
.command(Command::new(
druid::commands::SHOW_OPEN_PANEL,
open_options,
Target::Auto))
.hotkey(RawMods::Ctrl, "o");
let exit: MenuItem<App> = 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<App> 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();
}
}
}