feat: add a whole lot

This commit is contained in:
Anna 2022-03-06 04:11:58 -05:00
parent 00949fb2b5
commit df94e73e41
Signed by: anna
GPG Key ID: 0B391D8F06FCD9E0
15 changed files with 5614 additions and 125 deletions

1931
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -7,11 +7,19 @@ edition = "2021"
[dependencies]
anyhow = "1"
vmemory = "0.1"
sysinfo = "0.23"
process_list = "0.2"
clipboard = "0.5"
druid = { git = "https://github.com/linebender/druid.git", rev = "fc05e965c85fced8720c655685e02478e0530e94", features = ["im", "serde"] }
druid-widget-nursery = { git = "https://github.com/linebender/druid-widget-nursery" }
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_yaml = "0.8"
maplit = "1"
lazy_static = "1"
sysinfo = "0.23"
vmemory = "0.1"

BIN
img/dustia_mistake_map.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 209 KiB

2711
notes.yaml

File diff suppressed because it is too large Load Diff

309
src/app.rs Executable file
View File

@ -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();
}
}
}

View File

@ -1,21 +1,51 @@
use std::sync::Arc;
use anyhow::{Context, Result};
use druid::Data;
use sysinfo::{PidExt, ProcessExt, ProcessRefreshKind, RefreshKind, System, SystemExt};
use vmemory::ProcessMemory;
#[derive(Clone, Data)]
pub struct GameState {
pub location: u16,
pub location: u32,
pub gil: u32,
pub steps: u32,
pub stage: u16,
pub traveler_steps: u16,
pub chain: u16,
pid: u32,
#[data(ignore)]
base: usize,
#[data(ignore)]
base_size: usize,
mem: Vec<u8>,
proc_mem: ProcessMemory,
#[data(ignore)]
mem: Arc<Vec<u8>>,
#[data(ignore)]
proc_mem: Arc<ProcessMemory>,
#[data(ignore)]
data_pattern: Vec<u8>,
#[data(ignore)]
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 {
@ -23,6 +53,7 @@ impl GameState {
const GIL_OFFSET: isize = 0x8;
const STEPS_OFFSET: isize = 0xC;
const STAGE_OFFSET: isize = 0x200;
const TRAVELER_OFFSET: isize = 0x5B00;
pub fn new() -> Result<Self> {
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 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 {
location: 0,
gil: 0,
steps: 0,
stage: 0,
traveler_steps: 0,
chain: 0,
pid: pid.as_u32(),
base,
base_size,
mem: tza_mem,
proc_mem: mem,
mem: Arc::new(tza_mem),
proc_mem: Arc::new(mem),
data_pattern,
data_addr: 0,
traveler_pattern,
traveler_addr: 0,
chain_pattern,
chain_addr: 0,
};
state.set_addresses()?;
@ -100,25 +141,36 @@ impl GameState {
fn set_addresses(&mut self) -> Result<()> {
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(())
}
fn read_data<const SIZE: usize>(&self, offset: isize) -> [u8; SIZE] {
let addr = self.data_addr as isize + offset;
fn read_data<const SIZE: usize>(&self, addr: usize, offset: isize) -> [u8; SIZE] {
let addr = addr as isize + offset;
let vec = self.proc_mem.read_memory(addr as usize, SIZE, true);
vec.try_into().unwrap()
}
pub fn refresh(&mut self) -> Result<()> {
self.location = u16::from_le_bytes(self.read_data(Self::LOCATION_OFFSET));
self.gil = u32::from_le_bytes(self.read_data(Self::GIL_OFFSET));
self.steps = u32::from_le_bytes(self.read_data(Self::STEPS_OFFSET));
self.stage = u16::from_le_bytes(self.read_data(Self::STAGE_OFFSET));
Ok(())
fn replace<T: PartialEq>(binding: &mut T, value: T) -> bool {
let same = *binding == value;
*binding = value;
same
}
pub fn location_name(&self) -> Option<&'static str> {
crate::util::LOCATIONS.get(&self.location).map(std::ops::Deref::deref)
pub fn refresh(&mut self) -> Result<bool> {
// 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)
}
}

21
src/main.rs Normal file → Executable file
View File

@ -1,24 +1,19 @@
#![windows_subsystem = "windows"]
use anyhow::Result;
use std::fs::File;
use std::thread;
use crate::notes::Notes;
use crate::game_state::GameState;
use crate::notes::Notes;
use crate::notes_state::NotesState;
mod app;
mod notes;
mod game_state;
mod notes_state;
mod util;
mod widget;
fn main() -> Result<()> {
let notes: Notes = serde_yaml::from_reader(File::open("notes.yaml")?)?;
let mut game_state = GameState::new()?;
let mut notes_state = NotesState::new(&notes, &mut game_state)?;
println!("Ready");
loop {
notes_state.tick(&mut game_state)?;
thread::sleep(std::time::Duration::from_millis(16));
}
let app = crate::app::App::default();
app.launch()
}

View File

@ -1,18 +1,19 @@
use druid::{Data, im::Vector};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, Clone, Data)]
pub struct Notes {
pub steps: Vec<Step>,
pub steps: Vector<Step>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, Clone, Data)]
pub struct Step {
pub stage: u16,
pub areas: Vec<Area>,
pub areas: Vector<Area>,
}
#[derive(Debug, Deserialize, Clone)]
#[derive(Debug, Deserialize, Clone, Data)]
pub struct Area {
pub area: u16,
pub area: u32,
pub steps: String,
}

View File

@ -1,35 +1,37 @@
use std::sync::Arc;
use anyhow::Result;
use druid::Data;
use crate::{GameState, Notes};
use crate::notes::{Area, Step};
pub struct NotesState<'a> {
notes: &'a Notes,
last_printed_location: u16,
#[derive(Clone, Data)]
pub struct NotesState {
pub notes: Arc<Notes>,
last_stage: u16,
last_location: u16,
last_location: u32,
step_idx: usize,
area_idx: usize,
pub step_idx: Option<usize>,
pub area_idx: usize,
current_step: &'a Step,
first_step: bool,
force_print: bool,
current_step: Step,
}
impl<'a> NotesState<'a> {
pub fn new(notes: &'a Notes, game: &mut GameState) -> Result<Self> {
impl NotesState {
pub fn new(notes: Notes, game: &mut GameState) -> Result<Self> {
game.refresh()?;
// find first step with a matching story stage
let steps = notes.steps
.iter()
.take_while(|step| step.stage <= game.stage)
.count();
let (step, step_idx, force) = if steps <= 1 {
// nothing previous OR first step
(&notes.steps[0], 0, false)
let (step, step_idx) = if steps == 0 {
// nothing previous
(notes.steps[0].clone(), None)
} else {
// in progress
(&notes.steps[steps - 1], steps - 1, true)
(notes.steps[steps - 1].clone(), Some(steps - 1))
};
// find first area that matches
let area_idx = step.areas
@ -38,19 +40,16 @@ impl<'a> NotesState<'a> {
.unwrap_or(0);
Ok(Self {
notes,
last_printed_location: 0,
notes: Arc::new(notes),
last_stage: 0,
last_location: 0,
step_idx,
area_idx,
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_location = game.location;
game.refresh()?;
@ -59,49 +58,38 @@ impl<'a> NotesState<'a> {
let location_changed = self.last_location != game.location;
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(),
None => return Ok(()),
None => return Ok(highlight_changed),
};
if self.force_print || ((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);
if !step_advanced && location_changed && (game.location == area.area || area.area == 0) {
self.area_idx += 1;
if self.first_step {
self.first_step = false;
self.step_idx += 1;
if !self.force_print {
self.area_idx = 0;
}
}
if self.force_print {
self.force_print = false;
}
highlight_changed = true;
}
Ok(())
Ok(highlight_changed)
}
fn area(&self) -> Option<&Area> {
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 {
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,
None => return false,
};
if next.stage == game.stage {
self.current_step = next;
self.step_idx += 1;
self.current_step = next.clone();
self.step_idx = Some(idx);
self.area_idx = 0;
return true;
}

9
src/util.rs Normal file → Executable file
View File

@ -1,6 +1,9 @@
use std::collections::HashMap;
use itertools::Itertools;
pub mod markdown_renderer;
pub fn parse_pattern(s: &str) -> Option<Vec<u8>> {
let no_whitespace = s.replace(char::is_whitespace, "");
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! {
pub static ref LOCATIONS: HashMap<u16, &'static str> = maplit::hashmap! {
pub static ref LOCATIONS: HashMap<u32, &'static str> = maplit::hashmap! {
12 => "Main Menu",
13 => "End Credits",
48 => "Stockade",

327
src/util/markdown_renderer.rs Executable file
View File

@ -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);
}
}
}
}

3
src/widget/mod.rs Executable file
View File

@ -0,0 +1,3 @@
pub mod notes_viewer;
pub mod notes_viewer_scroll;
pub mod rich_text_display;

125
src/widget/notes_viewer.rs Executable file
View File

@ -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 = &notes.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);
}
}
}

View File

@ -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);
}
}

43
src/widget/rich_text_display.rs Executable file
View File

@ -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);
}
}