feat: add a whole lot
This commit is contained in:
parent
00949fb2b5
commit
df94e73e41
File diff suppressed because it is too large
Load Diff
18
Cargo.toml
18
Cargo.toml
|
@ -7,11 +7,19 @@ edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
vmemory = "0.1"
|
clipboard = "0.5"
|
||||||
sysinfo = "0.23"
|
druid = { git = "https://github.com/linebender/druid.git", rev = "fc05e965c85fced8720c655685e02478e0530e94", features = ["im", "serde"] }
|
||||||
process_list = "0.2"
|
druid-widget-nursery = { git = "https://github.com/linebender/druid-widget-nursery" }
|
||||||
itertools = "0.10"
|
itertools = "0.10"
|
||||||
|
image = "0.24"
|
||||||
|
lazy_static = "1"
|
||||||
|
maplit = "1"
|
||||||
|
num-format = "0.4"
|
||||||
|
parking_lot = "0.12"
|
||||||
|
process_list = "0.2"
|
||||||
|
pulldown-cmark = "0.9"
|
||||||
|
#rfd = "0.6"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_yaml = "0.8"
|
serde_yaml = "0.8"
|
||||||
maplit = "1"
|
sysinfo = "0.23"
|
||||||
lazy_static = "1"
|
vmemory = "0.1"
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 209 KiB |
2709
notes.yaml
2709
notes.yaml
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,309 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,21 +1,51 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
|
use druid::Data;
|
||||||
use sysinfo::{PidExt, ProcessExt, ProcessRefreshKind, RefreshKind, System, SystemExt};
|
use sysinfo::{PidExt, ProcessExt, ProcessRefreshKind, RefreshKind, System, SystemExt};
|
||||||
use vmemory::ProcessMemory;
|
use vmemory::ProcessMemory;
|
||||||
|
|
||||||
|
#[derive(Clone, Data)]
|
||||||
pub struct GameState {
|
pub struct GameState {
|
||||||
pub location: u16,
|
pub location: u32,
|
||||||
pub gil: u32,
|
pub gil: u32,
|
||||||
pub steps: u32,
|
pub steps: u32,
|
||||||
pub stage: u16,
|
pub stage: u16,
|
||||||
|
pub traveler_steps: u16,
|
||||||
|
pub chain: u16,
|
||||||
|
|
||||||
pid: u32,
|
pid: u32,
|
||||||
|
#[data(ignore)]
|
||||||
base: usize,
|
base: usize,
|
||||||
|
#[data(ignore)]
|
||||||
base_size: usize,
|
base_size: usize,
|
||||||
mem: Vec<u8>,
|
#[data(ignore)]
|
||||||
proc_mem: ProcessMemory,
|
mem: Arc<Vec<u8>>,
|
||||||
|
#[data(ignore)]
|
||||||
|
proc_mem: Arc<ProcessMemory>,
|
||||||
|
|
||||||
|
#[data(ignore)]
|
||||||
data_pattern: Vec<u8>,
|
data_pattern: Vec<u8>,
|
||||||
|
#[data(ignore)]
|
||||||
data_addr: usize,
|
data_addr: usize,
|
||||||
|
|
||||||
|
#[data(ignore)]
|
||||||
|
traveler_pattern: Vec<u8>,
|
||||||
|
#[data(ignore)]
|
||||||
|
traveler_addr: usize,
|
||||||
|
|
||||||
|
#[data(ignore)]
|
||||||
|
chain_pattern: Vec<u8>,
|
||||||
|
#[data(ignore)]
|
||||||
|
chain_addr: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! replace {
|
||||||
|
($binding: expr, $value: expr) => {{
|
||||||
|
let same = *$binding == $value;
|
||||||
|
*$binding = $value;
|
||||||
|
same
|
||||||
|
}};
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GameState {
|
impl GameState {
|
||||||
|
@ -23,6 +53,7 @@ impl GameState {
|
||||||
const GIL_OFFSET: isize = 0x8;
|
const GIL_OFFSET: isize = 0x8;
|
||||||
const STEPS_OFFSET: isize = 0xC;
|
const STEPS_OFFSET: isize = 0xC;
|
||||||
const STAGE_OFFSET: isize = 0x200;
|
const STAGE_OFFSET: isize = 0x200;
|
||||||
|
const TRAVELER_OFFSET: isize = 0x5B00;
|
||||||
|
|
||||||
pub fn new() -> Result<Self> {
|
pub fn new() -> Result<Self> {
|
||||||
let sys = System::new_with_specifics(RefreshKind::new().with_processes(ProcessRefreshKind::new()));
|
let sys = System::new_with_specifics(RefreshKind::new().with_processes(ProcessRefreshKind::new()));
|
||||||
|
@ -50,21 +81,31 @@ impl GameState {
|
||||||
|
|
||||||
let tza_mem = mem.read_memory(base, base_size, false);
|
let tza_mem = mem.read_memory(base, base_size, false);
|
||||||
let data_pattern = crate::util::parse_pattern("48 8D 0D ?? ?? ?? ?? 41 B8 ?? ?? ?? ?? E8 ?? ?? ?? ?? 33 C9 E8").unwrap();
|
let data_pattern = crate::util::parse_pattern("48 8D 0D ?? ?? ?? ?? 41 B8 ?? ?? ?? ?? E8 ?? ?? ?? ?? 33 C9 E8").unwrap();
|
||||||
|
let traveler_pattern = crate::util::parse_pattern("48 8B 05 ?? ?? ?? ?? 48 85 C0 75 28 E8").unwrap();
|
||||||
|
let chain_pattern = crate::util::parse_pattern("89 05 ?? ?? ?? ?? 89 1D ?? ?? ?? ?? 48 8B 87").unwrap();
|
||||||
|
|
||||||
let mut state = Self {
|
let mut state = Self {
|
||||||
location: 0,
|
location: 0,
|
||||||
gil: 0,
|
gil: 0,
|
||||||
steps: 0,
|
steps: 0,
|
||||||
stage: 0,
|
stage: 0,
|
||||||
|
traveler_steps: 0,
|
||||||
|
chain: 0,
|
||||||
|
|
||||||
pid: pid.as_u32(),
|
pid: pid.as_u32(),
|
||||||
base,
|
base,
|
||||||
base_size,
|
base_size,
|
||||||
mem: tza_mem,
|
mem: Arc::new(tza_mem),
|
||||||
proc_mem: mem,
|
proc_mem: Arc::new(mem),
|
||||||
|
|
||||||
data_pattern,
|
data_pattern,
|
||||||
data_addr: 0,
|
data_addr: 0,
|
||||||
|
|
||||||
|
traveler_pattern,
|
||||||
|
traveler_addr: 0,
|
||||||
|
|
||||||
|
chain_pattern,
|
||||||
|
chain_addr: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
state.set_addresses()?;
|
state.set_addresses()?;
|
||||||
|
@ -100,25 +141,36 @@ impl GameState {
|
||||||
|
|
||||||
fn set_addresses(&mut self) -> Result<()> {
|
fn set_addresses(&mut self) -> Result<()> {
|
||||||
self.data_addr = self.find_address(&self.data_pattern)?;
|
self.data_addr = self.find_address(&self.data_pattern)?;
|
||||||
|
self.traveler_addr = self.find_address_indirect(&self.traveler_pattern)?;
|
||||||
|
self.chain_addr = self.find_address(&self.chain_pattern)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_data<const SIZE: usize>(&self, offset: isize) -> [u8; SIZE] {
|
fn read_data<const SIZE: usize>(&self, addr: usize, offset: isize) -> [u8; SIZE] {
|
||||||
let addr = self.data_addr as isize + offset;
|
let addr = addr as isize + offset;
|
||||||
let vec = self.proc_mem.read_memory(addr as usize, SIZE, true);
|
let vec = self.proc_mem.read_memory(addr as usize, SIZE, true);
|
||||||
vec.try_into().unwrap()
|
vec.try_into().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh(&mut self) -> Result<()> {
|
fn replace<T: PartialEq>(binding: &mut T, value: T) -> bool {
|
||||||
self.location = u16::from_le_bytes(self.read_data(Self::LOCATION_OFFSET));
|
let same = *binding == value;
|
||||||
self.gil = u32::from_le_bytes(self.read_data(Self::GIL_OFFSET));
|
*binding = value;
|
||||||
self.steps = u32::from_le_bytes(self.read_data(Self::STEPS_OFFSET));
|
same
|
||||||
self.stage = u16::from_le_bytes(self.read_data(Self::STAGE_OFFSET));
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn location_name(&self) -> Option<&'static str> {
|
pub fn refresh(&mut self) -> Result<bool> {
|
||||||
crate::util::LOCATIONS.get(&self.location).map(std::ops::Deref::deref)
|
// boolean or short-circuits
|
||||||
|
let any_changed = replace!(&mut self.location, u32::from_le_bytes(self.read_data(self.data_addr, Self::LOCATION_OFFSET)))
|
||||||
|
| replace!(&mut self.gil, u32::from_le_bytes(self.read_data(self.data_addr, Self::GIL_OFFSET)))
|
||||||
|
| replace!(&mut self.steps, u32::from_le_bytes(self.read_data(self.data_addr, Self::STEPS_OFFSET)))
|
||||||
|
| replace!(&mut self.stage, u16::from_le_bytes(self.read_data(self.data_addr, Self::STAGE_OFFSET)))
|
||||||
|
| replace!(&mut self.traveler_steps, u16::from_le_bytes(self.read_data(self.traveler_addr, Self::TRAVELER_OFFSET)))
|
||||||
|
| replace!(&mut self.chain, u16::from_le_bytes(self.read_data(self.chain_addr, 0)));
|
||||||
|
|
||||||
|
Ok(any_changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn location_name(&self) -> &'static str {
|
||||||
|
crate::util::location_name(self.location)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,24 +1,19 @@
|
||||||
|
#![windows_subsystem = "windows"]
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::fs::File;
|
|
||||||
use std::thread;
|
|
||||||
use crate::notes::Notes;
|
|
||||||
use crate::game_state::GameState;
|
use crate::game_state::GameState;
|
||||||
|
use crate::notes::Notes;
|
||||||
use crate::notes_state::NotesState;
|
use crate::notes_state::NotesState;
|
||||||
|
|
||||||
|
mod app;
|
||||||
mod notes;
|
mod notes;
|
||||||
mod game_state;
|
mod game_state;
|
||||||
mod notes_state;
|
mod notes_state;
|
||||||
mod util;
|
mod util;
|
||||||
|
mod widget;
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let notes: Notes = serde_yaml::from_reader(File::open("notes.yaml")?)?;
|
let app = crate::app::App::default();
|
||||||
let mut game_state = GameState::new()?;
|
app.launch()
|
||||||
let mut notes_state = NotesState::new(¬es, &mut game_state)?;
|
|
||||||
|
|
||||||
println!("Ready");
|
|
||||||
|
|
||||||
loop {
|
|
||||||
notes_state.tick(&mut game_state)?;
|
|
||||||
thread::sleep(std::time::Duration::from_millis(16));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
13
src/notes.rs
13
src/notes.rs
|
@ -1,18 +1,19 @@
|
||||||
|
use druid::{Data, im::Vector};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, Clone, Data)]
|
||||||
pub struct Notes {
|
pub struct Notes {
|
||||||
pub steps: Vec<Step>,
|
pub steps: Vector<Step>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, Clone, Data)]
|
||||||
pub struct Step {
|
pub struct Step {
|
||||||
pub stage: u16,
|
pub stage: u16,
|
||||||
pub areas: Vec<Area>,
|
pub areas: Vector<Area>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Clone)]
|
#[derive(Debug, Deserialize, Clone, Data)]
|
||||||
pub struct Area {
|
pub struct Area {
|
||||||
pub area: u16,
|
pub area: u32,
|
||||||
pub steps: String,
|
pub steps: String,
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,35 +1,37 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
use druid::Data;
|
||||||
|
|
||||||
use crate::{GameState, Notes};
|
use crate::{GameState, Notes};
|
||||||
use crate::notes::{Area, Step};
|
use crate::notes::{Area, Step};
|
||||||
|
|
||||||
pub struct NotesState<'a> {
|
#[derive(Clone, Data)]
|
||||||
notes: &'a Notes,
|
pub struct NotesState {
|
||||||
last_printed_location: u16,
|
pub notes: Arc<Notes>,
|
||||||
last_stage: u16,
|
last_stage: u16,
|
||||||
last_location: u16,
|
last_location: u32,
|
||||||
|
|
||||||
step_idx: usize,
|
pub step_idx: Option<usize>,
|
||||||
area_idx: usize,
|
pub area_idx: usize,
|
||||||
|
|
||||||
current_step: &'a Step,
|
current_step: Step,
|
||||||
first_step: bool,
|
|
||||||
force_print: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> NotesState<'a> {
|
impl NotesState {
|
||||||
pub fn new(notes: &'a Notes, game: &mut GameState) -> Result<Self> {
|
pub fn new(notes: Notes, game: &mut GameState) -> Result<Self> {
|
||||||
game.refresh()?;
|
game.refresh()?;
|
||||||
// find first step with a matching story stage
|
// find first step with a matching story stage
|
||||||
let steps = notes.steps
|
let steps = notes.steps
|
||||||
.iter()
|
.iter()
|
||||||
.take_while(|step| step.stage <= game.stage)
|
.take_while(|step| step.stage <= game.stage)
|
||||||
.count();
|
.count();
|
||||||
let (step, step_idx, force) = if steps <= 1 {
|
let (step, step_idx) = if steps == 0 {
|
||||||
// nothing previous OR first step
|
// nothing previous
|
||||||
(¬es.steps[0], 0, false)
|
(notes.steps[0].clone(), None)
|
||||||
} else {
|
} else {
|
||||||
// in progress
|
// in progress
|
||||||
(¬es.steps[steps - 1], steps - 1, true)
|
(notes.steps[steps - 1].clone(), Some(steps - 1))
|
||||||
};
|
};
|
||||||
// find first area that matches
|
// find first area that matches
|
||||||
let area_idx = step.areas
|
let area_idx = step.areas
|
||||||
|
@ -38,19 +40,16 @@ impl<'a> NotesState<'a> {
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
notes,
|
notes: Arc::new(notes),
|
||||||
last_printed_location: 0,
|
|
||||||
last_stage: 0,
|
last_stage: 0,
|
||||||
last_location: 0,
|
last_location: 0,
|
||||||
step_idx,
|
step_idx,
|
||||||
area_idx,
|
area_idx,
|
||||||
current_step: step,
|
current_step: step,
|
||||||
first_step: true,
|
|
||||||
force_print: force,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tick(&mut self, game: &mut GameState) -> Result<()> {
|
pub fn tick(&mut self, game: &mut GameState) -> Result<bool> {
|
||||||
self.last_stage = game.stage;
|
self.last_stage = game.stage;
|
||||||
self.last_location = game.location;
|
self.last_location = game.location;
|
||||||
game.refresh()?;
|
game.refresh()?;
|
||||||
|
@ -59,49 +58,38 @@ impl<'a> NotesState<'a> {
|
||||||
let location_changed = self.last_location != game.location;
|
let location_changed = self.last_location != game.location;
|
||||||
|
|
||||||
let step_advanced = stage_changed && self.change_step(game);
|
let step_advanced = stage_changed && self.change_step(game);
|
||||||
let area = match self.area() {
|
let mut highlight_changed = step_advanced;
|
||||||
|
let area = match self.next_area() {
|
||||||
Some(area) => area.clone(),
|
Some(area) => area.clone(),
|
||||||
None => return Ok(()),
|
None => return Ok(highlight_changed),
|
||||||
};
|
};
|
||||||
|
|
||||||
if self.force_print || ((step_advanced || location_changed) && (game.location == area.area || area.area == 0)) {
|
if !step_advanced && location_changed && (game.location == area.area || area.area == 0) {
|
||||||
if self.last_printed_location != game.location {
|
|
||||||
self.last_printed_location = game.location;
|
|
||||||
println!("{}", game.location_name().unwrap_or("Unknown Location"))
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("{}", area.steps);
|
|
||||||
self.area_idx += 1;
|
self.area_idx += 1;
|
||||||
|
highlight_changed = true;
|
||||||
if self.first_step {
|
|
||||||
self.first_step = false;
|
|
||||||
self.step_idx += 1;
|
|
||||||
if !self.force_print {
|
|
||||||
self.area_idx = 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.force_print {
|
Ok(highlight_changed)
|
||||||
self.force_print = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn area(&self) -> Option<&Area> {
|
fn area(&self) -> Option<&Area> {
|
||||||
self.current_step.areas.get(self.area_idx)
|
self.current_step.areas.get(self.area_idx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn next_area(&self) -> Option<&Area> {
|
||||||
|
self.current_step.areas.get(self.area_idx + 1)
|
||||||
|
}
|
||||||
|
|
||||||
fn change_step(&mut self, game: &mut GameState) -> bool {
|
fn change_step(&mut self, game: &mut GameState) -> bool {
|
||||||
let next = match self.notes.steps.get(self.step_idx) {
|
let idx = self.step_idx.map(|x| x + 1).unwrap_or(0);
|
||||||
|
let next = match self.notes.steps.get(idx) {
|
||||||
Some(step) => step,
|
Some(step) => step,
|
||||||
None => return false,
|
None => return false,
|
||||||
};
|
};
|
||||||
|
|
||||||
if next.stage == game.stage {
|
if next.stage == game.stage {
|
||||||
self.current_step = next;
|
self.current_step = next.clone();
|
||||||
self.step_idx += 1;
|
self.step_idx = Some(idx);
|
||||||
self.area_idx = 0;
|
self.area_idx = 0;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
|
|
||||||
|
pub mod markdown_renderer;
|
||||||
|
|
||||||
pub fn parse_pattern(s: &str) -> Option<Vec<u8>> {
|
pub fn parse_pattern(s: &str) -> Option<Vec<u8>> {
|
||||||
let no_whitespace = s.replace(char::is_whitespace, "");
|
let no_whitespace = s.replace(char::is_whitespace, "");
|
||||||
if no_whitespace.len() % 2 == 1 {
|
if no_whitespace.len() % 2 == 1 {
|
||||||
|
@ -65,8 +68,12 @@ pub fn get_static_address(mem: &[u8], mut addr: usize, base: usize) -> Option<us
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn location_name(id: u32) -> &'static str {
|
||||||
|
LOCATIONS.get(&id).map(std::ops::Deref::deref).unwrap_or("???")
|
||||||
|
}
|
||||||
|
|
||||||
lazy_static::lazy_static! {
|
lazy_static::lazy_static! {
|
||||||
pub static ref LOCATIONS: HashMap<u16, &'static str> = maplit::hashmap! {
|
pub static ref LOCATIONS: HashMap<u32, &'static str> = maplit::hashmap! {
|
||||||
12 => "Main Menu",
|
12 => "Main Menu",
|
||||||
13 => "End Credits",
|
13 => "End Credits",
|
||||||
48 => "Stockade",
|
48 => "Stockade",
|
||||||
|
|
|
@ -0,0 +1,327 @@
|
||||||
|
use std::ops::RangeBounds;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use druid::{Color, Env, FontFamily, FontStyle, FontWeight, ImageBuf, Widget, WidgetExt};
|
||||||
|
use druid::piet::ImageFormat;
|
||||||
|
use druid::text::RichTextBuilder;
|
||||||
|
use druid::widget::{FillStrat, Flex};
|
||||||
|
use druid_widget_nursery::table::{ComplexTableColumnWidth, FlexTable, TableCellVerticalAlignment, TableColumnWidth, TableRow};
|
||||||
|
use image::GenericImageView;
|
||||||
|
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag};
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::NotesState;
|
||||||
|
use crate::widget::rich_text_display::RichTextDisplay;
|
||||||
|
|
||||||
|
pub struct MarkdownRenderer<'e, 'p> {
|
||||||
|
src: &'e str,
|
||||||
|
path: &'p str,
|
||||||
|
pos: (usize, usize),
|
||||||
|
add_newline: bool,
|
||||||
|
current_pos: usize,
|
||||||
|
builder: RichTextBuilder,
|
||||||
|
tag_stack: Vec<(usize, Tag<'e>)>,
|
||||||
|
list_depth: usize,
|
||||||
|
in_table: Option<usize>,
|
||||||
|
in_image: Option<(String, String)>,
|
||||||
|
rendered: Vec<Box<dyn Widget<App>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'e, 'p> MarkdownRenderer<'e, 'p> {
|
||||||
|
const BULLET: &'static str = "• ";
|
||||||
|
|
||||||
|
pub fn new(src: &'e str, path: &'p str, pos: (usize, usize)) -> Self {
|
||||||
|
Self {
|
||||||
|
src,
|
||||||
|
path,
|
||||||
|
pos,
|
||||||
|
add_newline: false,
|
||||||
|
current_pos: 0,
|
||||||
|
builder: RichTextBuilder::new(),
|
||||||
|
tag_stack: Vec::new(),
|
||||||
|
list_depth: 0,
|
||||||
|
in_table: None,
|
||||||
|
in_image: None,
|
||||||
|
rendered: Vec::with_capacity(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render(mut self) -> Vec<Box<dyn Widget<App>>> {
|
||||||
|
let mut events = Parser::new_ext(self.src, Options::ENABLE_TABLES);
|
||||||
|
while let Some(event) = events.next() {
|
||||||
|
self.event(event);
|
||||||
|
self.image(&mut events);
|
||||||
|
self.table(&mut events);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.render_builder();
|
||||||
|
self.rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_builder(&mut self) {
|
||||||
|
let pos = self.pos.clone();
|
||||||
|
|
||||||
|
let mut builder = RichTextBuilder::new();
|
||||||
|
std::mem::swap(&mut builder, &mut self.builder);
|
||||||
|
self.current_pos = 0;
|
||||||
|
|
||||||
|
let built = builder.build();
|
||||||
|
if built.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.rendered.push(RichTextDisplay::new(built)
|
||||||
|
.disabled_if(move |data: &Option<NotesState>, _env: &Env| {
|
||||||
|
let highlight_pos = data
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|state| state.step_idx.map(|step_idx| (step_idx, state.area_idx)));
|
||||||
|
highlight_pos != Some(pos)
|
||||||
|
})
|
||||||
|
.lens(App::notes_state)
|
||||||
|
.boxed());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn builder_push(&mut self, s: &str) {
|
||||||
|
self.builder.push(s);
|
||||||
|
self.current_pos += s.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_attribute_for_tag(&mut self, tag: &Tag, range: impl RangeBounds<usize>) {
|
||||||
|
let mut attrs = self.builder.add_attributes_for_range(range);
|
||||||
|
match tag {
|
||||||
|
Tag::Heading(lvl, _, _) => {
|
||||||
|
let font_size = match lvl {
|
||||||
|
HeadingLevel::H1 => 38.0,
|
||||||
|
HeadingLevel::H2 => 32.0,
|
||||||
|
HeadingLevel::H3 => 26.0,
|
||||||
|
HeadingLevel::H4 => 20.0,
|
||||||
|
HeadingLevel::H5 => 16.0,
|
||||||
|
HeadingLevel::H6 => 12.0,
|
||||||
|
_ => 12.0,
|
||||||
|
};
|
||||||
|
attrs.size(font_size).weight(FontWeight::BOLD);
|
||||||
|
}
|
||||||
|
Tag::BlockQuote => {
|
||||||
|
attrs.style(FontStyle::Italic).text_color(Color::GRAY);
|
||||||
|
}
|
||||||
|
Tag::CodeBlock(_) => {
|
||||||
|
attrs.font_family(FontFamily::MONOSPACE);
|
||||||
|
}
|
||||||
|
Tag::Emphasis => {
|
||||||
|
attrs.style(FontStyle::Italic);
|
||||||
|
}
|
||||||
|
Tag::Strong => {
|
||||||
|
attrs.weight(FontWeight::BOLD);
|
||||||
|
}
|
||||||
|
Tag::Strikethrough => {
|
||||||
|
attrs.strikethrough(true);
|
||||||
|
}
|
||||||
|
Tag::Link(_link_ty, _target, _title) => {
|
||||||
|
attrs
|
||||||
|
.underline(true)
|
||||||
|
.text_color(Color::AQUA);
|
||||||
|
// .link(OPEN_LINK.with(target.to_string()));
|
||||||
|
}
|
||||||
|
// ignore other tags for now
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_newline_after_tag(tag: &Tag) -> bool {
|
||||||
|
!matches!(
|
||||||
|
tag,
|
||||||
|
Tag::Emphasis | Tag::Strong | Tag::Strikethrough | Tag::Link(..),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event(&mut self, event: Event<'e>) {
|
||||||
|
match event {
|
||||||
|
Event::Start(tag) => {
|
||||||
|
if let Tag::Table(aligns) = &tag {
|
||||||
|
self.in_table = Some(aligns.len());
|
||||||
|
self.add_newline = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Tag::Image(_, url, title) = &tag {
|
||||||
|
self.in_image = Some((url.to_string(), title.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(&tag, Tag::List(..)) {
|
||||||
|
if self.list_depth > 0 {
|
||||||
|
self.add_newline = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.list_depth += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.add_newline {
|
||||||
|
self.builder_push("\n");
|
||||||
|
self.add_newline = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(&tag, Tag::Item) {
|
||||||
|
for _ in 0..self.list_depth {
|
||||||
|
self.builder_push(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
self.builder_push(Self::BULLET);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.tag_stack.push((self.current_pos, tag));
|
||||||
|
}
|
||||||
|
Event::Text(text) => {
|
||||||
|
self.builder_push(&text);
|
||||||
|
}
|
||||||
|
Event::End(end_tag) => {
|
||||||
|
if matches!(&end_tag, Tag::Table(..)) {
|
||||||
|
self.in_table = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if matches!(&end_tag, Tag::Image(..)) {
|
||||||
|
self.in_image = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (start_off, tag) = self.tag_stack
|
||||||
|
.pop()
|
||||||
|
.expect("parser does not return unbalanced tags");
|
||||||
|
assert_eq!(end_tag, tag, "mismatched tags");
|
||||||
|
self.add_attribute_for_tag(&tag, start_off..self.current_pos);
|
||||||
|
if Self::add_newline_after_tag(&tag) {
|
||||||
|
self.add_newline = true;
|
||||||
|
}
|
||||||
|
if matches!(&tag, Tag::List(..)) {
|
||||||
|
self.list_depth -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Code(text) => {
|
||||||
|
self.builder.push(&text).font_family(FontFamily::MONOSPACE);
|
||||||
|
self.current_pos += text.len();
|
||||||
|
}
|
||||||
|
Event::Html(text) => {
|
||||||
|
self.builder
|
||||||
|
.push(&text)
|
||||||
|
.font_family(FontFamily::MONOSPACE)
|
||||||
|
.text_color(Color::RED);
|
||||||
|
self.current_pos += text.len();
|
||||||
|
}
|
||||||
|
Event::HardBreak => {
|
||||||
|
self.add_newline = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn table(&mut self, mut events: impl Iterator<Item=Event<'e>>) {
|
||||||
|
let cols = match self.in_table {
|
||||||
|
Some(cols) => cols,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
// handle any text yet to render
|
||||||
|
self.render_builder();
|
||||||
|
let mut start = self.rendered.len();
|
||||||
|
let mut table = FlexTable::new()
|
||||||
|
.default_vertical_alignment(TableCellVerticalAlignment::Top);
|
||||||
|
let widths: Vec<ComplexTableColumnWidth> = (0..cols)
|
||||||
|
.map(|i| if i == cols - 1 {
|
||||||
|
TableColumnWidth::Flex(1.0).into()
|
||||||
|
} else {
|
||||||
|
TableColumnWidth::Intrinsic.into()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
table.set_column_widths(&widths);
|
||||||
|
let mut row = None;
|
||||||
|
|
||||||
|
while self.in_table.is_some() {
|
||||||
|
if let Some(event) = events.next() {
|
||||||
|
match &event {
|
||||||
|
Event::Start(Tag::TableRow | Tag::TableHead) => {
|
||||||
|
row = Some(TableRow::new());
|
||||||
|
}
|
||||||
|
Event::Start(Tag::TableCell) => {
|
||||||
|
start = self.rendered.len();
|
||||||
|
}
|
||||||
|
Event::End(Tag::TableRow | Tag::TableHead) => {
|
||||||
|
if let Some(row) = row.take() {
|
||||||
|
table.add_row(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
row = None;
|
||||||
|
}
|
||||||
|
Event::End(Tag::TableCell) => {
|
||||||
|
self.render_builder();
|
||||||
|
let mut widgets: Vec<_> = self.rendered.drain(start..).collect();
|
||||||
|
if let Some(row) = &mut row {
|
||||||
|
if widgets.len() == 1 {
|
||||||
|
row.add_child(widgets.remove(0).padding(2.0));
|
||||||
|
} else if widgets.len() > 1 {
|
||||||
|
let mut flex = Flex::column();
|
||||||
|
for widget in widgets {
|
||||||
|
flex.add_child(widget.padding(2.0));
|
||||||
|
}
|
||||||
|
|
||||||
|
row.add_child(flex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.event(event);
|
||||||
|
self.add_newline = false; // never add newlines in a table
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.rendered.push(table.boxed());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image(&mut self, mut events: impl Iterator<Item=Event<'e>>) {
|
||||||
|
let url = match &self.in_image {
|
||||||
|
Some((url, _)) => url.clone(),
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.render_builder();
|
||||||
|
let start = self.rendered.len();
|
||||||
|
|
||||||
|
while self.in_image.is_some() {
|
||||||
|
if let Some(event) = events.next() {
|
||||||
|
self.event(event);
|
||||||
|
self.add_newline = false; // never add newlines in an image
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.render_builder();
|
||||||
|
let alt: Vec<_> = self.rendered.drain(start..).collect();
|
||||||
|
|
||||||
|
let image_path = Path::new(self.path)
|
||||||
|
.parent()
|
||||||
|
.map(|parent| parent.join(&url))
|
||||||
|
.unwrap_or_else(|| Path::new(&url).to_owned());
|
||||||
|
match image::open(image_path) {
|
||||||
|
Ok(image) => {
|
||||||
|
let image = image.into_rgba8();
|
||||||
|
let width = image.width() as usize;
|
||||||
|
let height = image.height() as usize;
|
||||||
|
let image_buf = ImageBuf::from_raw(
|
||||||
|
image.into_flat_samples().samples,
|
||||||
|
ImageFormat::RgbaSeparate,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
);
|
||||||
|
let widget = druid::widget::Image::new(image_buf)
|
||||||
|
.fill_mode(FillStrat::ScaleDown)
|
||||||
|
.fix_width(width as f64);
|
||||||
|
self.rendered.push(widget.boxed());
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("error loading image: {:?}", err);
|
||||||
|
self.rendered.extend(alt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
pub mod notes_viewer;
|
||||||
|
pub mod notes_viewer_scroll;
|
||||||
|
pub mod rich_text_display;
|
|
@ -0,0 +1,125 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use druid::{BoxConstraints, Data, Env, Event, EventCtx, LayoutCtx, LensExt, LifeCycle, LifeCycleCtx, PaintCtx, Point, Rect, RenderContext, Size, UpdateCtx, Widget, WidgetExt, WidgetPod};
|
||||||
|
use druid::widget::{CrossAxisAlignment, Flex, MainAxisAlignment};
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::Notes;
|
||||||
|
use crate::util::markdown_renderer::MarkdownRenderer;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct NotesViewer {
|
||||||
|
children: Vec<((usize, usize), WidgetPod<App, Box<dyn Widget<App>>>)>,
|
||||||
|
rects: HashMap<(usize, usize), Rect>,
|
||||||
|
highlighted: Option<(usize, usize)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NotesViewer {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rect_for_step(&self, step: usize, area: usize) -> Option<Rect> {
|
||||||
|
self.rects.get(&(step, area)).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rerender(&mut self, notes: &Option<(Notes, String)>) {
|
||||||
|
self.children.clear();
|
||||||
|
|
||||||
|
let (notes, path) = match notes {
|
||||||
|
Some(notes) => notes,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
for step_idx in 0..notes.steps.len() {
|
||||||
|
let step = ¬es.steps[step_idx];
|
||||||
|
|
||||||
|
for area_idx in 0..step.areas.len() {
|
||||||
|
let area = &step.areas[area_idx];
|
||||||
|
|
||||||
|
let mut flex: Flex<App> = Flex::column()
|
||||||
|
.main_axis_alignment(MainAxisAlignment::Start)
|
||||||
|
.cross_axis_alignment(CrossAxisAlignment::Start);
|
||||||
|
let location_name = crate::util::location_name(area.area);
|
||||||
|
for child in Self::render_markdown(&format!("#### {}\n{}", location_name, area.steps), path, (step_idx, area_idx)) {
|
||||||
|
flex.add_child(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.children.push((
|
||||||
|
(step_idx, area_idx),
|
||||||
|
WidgetPod::new(flex).boxed(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_markdown(src: &str, path: &str, pos: (usize, usize)) -> Vec<Box<dyn Widget<App>>> {
|
||||||
|
MarkdownRenderer::new(src, path, pos).render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget<App> for NotesViewer {
|
||||||
|
fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut App, env: &Env) {
|
||||||
|
for (_, child) in &mut self.children {
|
||||||
|
child.event(ctx, event, data, env);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &App, env: &Env) {
|
||||||
|
for (_, child) in &mut self.children {
|
||||||
|
child.lifecycle(ctx, event, data, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
match event {
|
||||||
|
LifeCycle::WidgetAdded => {
|
||||||
|
self.rerender(&data.notes);
|
||||||
|
ctx.children_changed();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &mut UpdateCtx, old_data: &App, data: &App, env: &Env) {
|
||||||
|
for (_, child) in &mut self.children {
|
||||||
|
child.update(ctx, data, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the notes have changed, we need to rerender them
|
||||||
|
if !old_data.notes.same(&data.notes) {
|
||||||
|
self.rerender(&data.notes);
|
||||||
|
ctx.children_changed();
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the note state has changed, a new section needs to be highlighted
|
||||||
|
if !old_data.notes_state.same(&data.notes_state) {
|
||||||
|
self.highlighted = data.notes_state
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|state| state.step_idx.map(|step_idx| (step_idx, state.area_idx)));
|
||||||
|
ctx.request_paint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &App, env: &Env) -> Size {
|
||||||
|
self.rects.clear();
|
||||||
|
|
||||||
|
let mut pos = Point::ZERO;
|
||||||
|
let mut size = bc.min();
|
||||||
|
|
||||||
|
for ((step, area), child) in &mut self.children {
|
||||||
|
let child_size = child.layout(ctx, bc, data, env);
|
||||||
|
child.set_origin(ctx, data, env, pos);
|
||||||
|
self.rects.insert((*step, *area), Rect::from_origin_size(pos, child_size));
|
||||||
|
pos.y += child_size.height;
|
||||||
|
size.height += child_size.height;
|
||||||
|
size.width = child_size.width.max(size.width);
|
||||||
|
}
|
||||||
|
|
||||||
|
size
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(&mut self, ctx: &mut PaintCtx, data: &App, env: &Env) {
|
||||||
|
for (_, child) in &mut self.children {
|
||||||
|
child.paint(ctx, data, env);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,67 @@
|
||||||
|
use druid::{BoxConstraints, Data, Env, Event, EventCtx, LayoutCtx, LifeCycle, LifeCycleCtx, PaintCtx, Rect, Size, UpdateCtx, Widget, WidgetPod};
|
||||||
|
use druid::widget::Scroll;
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::widget::notes_viewer::NotesViewer;
|
||||||
|
|
||||||
|
pub struct NotesViewerScroll {
|
||||||
|
scroll: WidgetPod<App, Scroll<App, NotesViewer>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NotesViewerScroll {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
scroll: WidgetPod::new(Scroll::new(NotesViewer::new()).vertical()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget<App> for NotesViewerScroll {
|
||||||
|
fn event(&mut self, ctx: &mut EventCtx, event: &Event, data: &mut App, env: &Env) {
|
||||||
|
self.scroll.event(ctx, event, data, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, data: &App, env: &Env) {
|
||||||
|
self.scroll.lifecycle(ctx, event, data, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &mut UpdateCtx, old_data: &App, data: &App, env: &Env) {
|
||||||
|
self.scroll.update(ctx, data, env);
|
||||||
|
|
||||||
|
let mut should_scroll = !old_data.notes_state.same(&data.notes_state)
|
||||||
|
|| (old_data.game_state.is_none() && data.game_state.is_some());
|
||||||
|
|
||||||
|
if let (Some(old), Some(new)) = (&old_data.notes_state, &data.notes_state) {
|
||||||
|
if should_scroll && old.step_idx == new.step_idx && old.area_idx == new.area_idx {
|
||||||
|
should_scroll = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scroll to new section
|
||||||
|
if should_scroll {
|
||||||
|
if let Some((step_idx, area_idx)) = data.notes_state
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|state| state.step_idx.map(|step_idx| (step_idx, state.area_idx)))
|
||||||
|
{
|
||||||
|
let rect = self.scroll.widget().child().rect_for_step(step_idx, area_idx);
|
||||||
|
println!("scroll to step {} area {}: {:?}", step_idx, area_idx, rect);
|
||||||
|
if let Some(rect) = rect {
|
||||||
|
let step_height = rect.height();
|
||||||
|
let scroll_size = self.scroll.layout_rect();
|
||||||
|
let mid_scroll = scroll_size.height() / 2.0;
|
||||||
|
let mid_step = mid_scroll - (step_height / 2.0);
|
||||||
|
let scroll_to = Rect::new(rect.x0, rect.y0, rect.x1, rect.y1 + mid_step);
|
||||||
|
self.scroll.widget_mut().scroll_to(scroll_to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, data: &App, env: &Env) -> Size {
|
||||||
|
self.scroll.layout(ctx, bc, data, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(&mut self, ctx: &mut PaintCtx, data: &App, env: &Env) {
|
||||||
|
self.scroll.paint(ctx, data, env);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
use std::marker::PhantomData;
|
||||||
|
|
||||||
|
use druid::{BoxConstraints, Env, Event, EventCtx, LayoutCtx, LifeCycle, LifeCycleCtx, PaintCtx, Size, UpdateCtx, Widget, WidgetPod};
|
||||||
|
use druid::text::RichText;
|
||||||
|
use druid::widget::{LineBreaking, RawLabel};
|
||||||
|
|
||||||
|
pub struct RichTextDisplay<T> {
|
||||||
|
text: RichText,
|
||||||
|
label: WidgetPod<RichText, RawLabel<RichText>>,
|
||||||
|
_phantom: PhantomData<T>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> RichTextDisplay<T> {
|
||||||
|
pub fn new(text: RichText) -> Self {
|
||||||
|
Self {
|
||||||
|
text,
|
||||||
|
label: WidgetPod::new(RawLabel::new().with_line_break_mode(LineBreaking::WordWrap)),
|
||||||
|
_phantom: PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Widget<T> for RichTextDisplay<T> {
|
||||||
|
fn event(&mut self, ctx: &mut EventCtx, event: &Event, _data: &mut T, env: &Env) {
|
||||||
|
self.label.event(ctx, event, &mut self.text, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lifecycle(&mut self, ctx: &mut LifeCycleCtx, event: &LifeCycle, _data: &T, env: &Env) {
|
||||||
|
self.label.lifecycle(ctx, event, &self.text, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &mut UpdateCtx, _old_data: &T, _data: &T, env: &Env) {
|
||||||
|
self.label.update(ctx, &self.text, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints, _data: &T, env: &Env) -> Size {
|
||||||
|
self.label.layout(ctx, bc, &self.text, env)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(&mut self, ctx: &mut PaintCtx, _data: &T, env: &Env) {
|
||||||
|
self.label.paint(ctx, &self.text, env);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue