From 6d0da542ce53b5e79b4a8c5ad995f7746c250cdd Mon Sep 17 00:00:00 2001 From: Anna Date: Sat, 26 Mar 2022 02:46:27 -0400 Subject: [PATCH] feat: do more --- src/app.rs | 527 +++++++++++++++++++++++++++++++++++--------- src/config.rs | 14 +- src/lib.rs | 10 +- src/main.rs | 5 +- src/splits_logic.rs | 74 +++++++ 5 files changed, 510 insertions(+), 120 deletions(-) create mode 100755 src/splits_logic.rs diff --git a/src/app.rs b/src/app.rs index 656d585..2594114 100755 --- a/src/app.rs +++ b/src/app.rs @@ -1,28 +1,35 @@ -use anyhow::Result; -use chrono::{Duration, Local, Utc, TimeZone, FixedOffset, DateTime}; use std::{ - cell::RefCell, + cell::{Cell, RefCell}, collections::HashMap, io::Cursor, sync::{ Arc, atomic::{AtomicBool, Ordering}, - mpsc::{SyncSender, Receiver}, + mpsc::{Receiver, SyncSender}, }, }; -use livesplit::{ - Run, - model::Attempt, -}; -use nom::Finish; -use url::Url; +use std::collections::HashSet; +use std::sync::atomic::AtomicUsize; + +use anyhow::Result; +use chrono::{DateTime, Duration, FixedOffset, Local, TimeZone, Utc}; use eframe::{ - egui, epi, - egui::{CtxRef, Sense, Rgba, Widget}, + egui, egui::{CtxRef, Rgba, Sense, Widget}, + epi, epi::{Frame, RepaintSignal, Storage}, }; -use serde::Deserialize; +use eframe::egui::Ui; use itertools::Itertools; +use livesplit::{ + model::Attempt, + Run, +}; +use livesplit::model::Segment; +use nom::Finish; +use serde::Deserialize; +use url::Url; + +use crate::splits_logic::{SegmentLogic, SplitsLogic}; #[derive(Default)] #[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))] @@ -37,14 +44,19 @@ pub struct App { struct State { last_error: Option, + selected_tab: AtomicUsize, + picking_splits: bool, processing_splits: bool, splits_file_name: Option, - splits: Option, + splits: Option<(Run, RunStats)>, processing_videos: AtomicBool, videos: Option>, highlight_urls: RefCell>, + no_video: RefCell>, + + attempt_to_show: Cell>, channel: (SyncSender, Receiver), } @@ -54,6 +66,8 @@ impl Default for State { Self { last_error: Default::default(), + selected_tab: Default::default(), + picking_splits: Default::default(), processing_splits: Default::default(), splits_file_name: Default::default(), @@ -62,6 +76,9 @@ impl Default for State { processing_videos: Default::default(), videos: Default::default(), highlight_urls: Default::default(), + no_video: Default::default(), + + attempt_to_show: Default::default(), channel: std::sync::mpsc::sync_channel(100), } @@ -69,7 +86,7 @@ impl Default for State { } enum Message { - Run(Result), + Run(Result<(Run, RunStats)>), Videos(u32, Result>), SplitsPath(Option<(String, Vec)>), } @@ -82,11 +99,14 @@ impl epi::App for App { self.state.processing_splits = false; match run { - Ok(run) => { - self.state.splits = Some(run); + Ok((run, stats)) => { + self.state.splits = Some((run, stats)); self.state.last_error = None; self.state.highlight_urls.borrow_mut().clear(); + self.state.no_video.borrow_mut().clear(); + self.state.attempt_to_show.set(None); + // TODO: clear comparisons self.state.videos = None; } Err(e) => { @@ -100,9 +120,11 @@ impl epi::App for App { match videos { Ok(videos) => { - if let Some(splits) = &self.state.splits { + if let Some((splits, _)) = &self.state.splits { if let Some(attempt) = splits.attempt_history.attempts.iter().find(|attempt| attempt.id == id) { - self.create_highlight_link(attempt, &*videos); + if !self.create_highlight_link(attempt, &*videos) { + self.state.no_video.borrow_mut().insert(attempt.id); + } } } @@ -125,10 +147,11 @@ impl epi::App for App { let repaint_signal = frame.repaint_signal(); let sender = self.state.channel.0.clone(); self.spawn(async move { - let res: Result = try { + let res: Result<(Run, RunStats)> = try { let bytes = Cursor::new(data); let run: Run = quick_xml::de::from_reader(bytes)?; - run + let stats = RunStats::from(&run); + (run, stats) }; sender.send(Message::Run(res)).ok(); repaint_signal.request_repaint(); @@ -143,7 +166,13 @@ impl epi::App for App { ui.separator(); - ui.collapsing("Client", |ui| { + ui.collapsing("Twitch", |ui| { + ui.label("Channel"); + let mut s = self.config.twitch.channel.clone().unwrap_or_default(); + ui.text_edit_singleline(&mut s); + let s = s.trim(); + self.config.twitch.channel = if s.is_empty() { None } else { Some(s.to_string()) }; + ui.horizontal(|ui| { ui.label("Client ID"); let question_mark = egui::Label::new("❓") @@ -176,34 +205,20 @@ impl epi::App for App { ui.separator(); - ui.collapsing("Twitch", |ui| { - ui.label("Channel"); - let mut s = self.config.twitch.channel.clone().unwrap_or_default(); - ui.text_edit_singleline(&mut s); - let s = s.trim(); - self.config.twitch.channel = if s.is_empty() { None } else { Some(s.to_string()) }; - }); + ui.collapsing("Highlighter", |ui| { + ui.checkbox(&mut self.config.highlighter.show_incomplete, "Show incomplete runs"); - ui.separator(); - - ui.collapsing("Padding", |ui| { - ui.label("Start"); + ui.label("Start padding"); ui.add(egui::Slider::new(&mut self.config.padding.start, 0..=30)); - ui.label("End"); + ui.label("End padding"); ui.add(egui::Slider::new(&mut self.config.padding.end, 0..=30)); - }); - - ui.separator(); - - ui.collapsing("Display", |ui| { - ui.checkbox(&mut self.config.display.show_incomplete, "Show incomplete runs"); }) }); egui::CentralPanel::default().show(ctx, |ui| { ui.horizontal(|ui| { - if !self.config.ready() || self.state.picking_splits { + if self.state.picking_splits { ui.set_enabled(false); } if ui.button("Open splits").clicked() && !self.state.picking_splits { @@ -219,12 +234,6 @@ impl epi::App for App { } }); - if !self.config.ready() { - egui::Label::new("Fill out the configuration first.") - .text_color(Rgba::from_rgb(1.0, 0.0, 0.0)) - .ui(ui); - } - if let Some(e) = &self.state.last_error { egui::Label::new(format!("Error: {}", e)) .text_color(Rgba::from_rgb(1.0, 0.0, 0.0)) @@ -236,68 +245,38 @@ impl epi::App for App { }); } - if let Some(run) = &self.state.splits { + if let Some((run, stats)) = &self.state.splits { ui.separator(); ui.heading(&run.game_name); ui.label(&run.category_name); ui.add_space(ui.spacing().item_spacing.y); - egui::ScrollArea::auto_sized().show(ui, |ui| { - for (key, attempts) in &run.attempt_history.attempts.iter().rev().group_by(|attempt| Local.from_utc_datetime(&attempt.started).date()) { - let attempts: Vec<&Attempt> = attempts - .filter(|attempt| self.config.display.show_incomplete || attempt.real_time.or(attempt.game_time).is_some()) - .collect(); - if attempts.is_empty() { - continue; + let mut items: Vec<(&str, Box)> = vec![ + ("Runs", Box::new(|ui: &mut Ui| self.show_attempts(ui, ctx, frame, run))), + ("Average", Box::new(|ui: &mut Ui| self.show_simple_stats(ui, run, &stats.means, &stats.sub_means))), + ("Median", Box::new(|ui: &mut Ui| self.show_simple_stats(ui, run, &stats.medians, &stats.sub_medians))), + ]; + + if self.state.attempt_to_show.get().is_some() { + items.insert(1, ("Run", Box::new(|ui: &mut Ui| self.show_attempt(ui, run)))); + } + + ui.horizontal_wrapped(|ui| { + for i in 0..items.len() { + let name = items[i].0; + if ui.selectable_label(self.state.selected_tab.load(Ordering::SeqCst) == i, name).clicked() { + self.state.selected_tab.store(i, Ordering::SeqCst); } - - ui.collapsing(key.format("%d %B %Y"), |ui| { - for attempt in attempts { - let mut output = vec![Local.from_utc_datetime(&attempt.started).format("%H:%M:%S").to_string()]; - if let Some(real) = attempt.real_time { - output.push(format!("RTA: {}", to_human(&real))); - } - if let Some(igt) = attempt.game_time { - output.push(format!("IGT: {}", to_human(&igt))); - } - if attempt.real_time.or(attempt.game_time).is_none() { - output.push(format!("Length: {}", to_human(&(attempt.ended - attempt.started)))) - } - - let sender = self.state.channel.0.clone(); - let channel = self.config.twitch.channel.clone(); - let id = attempt.id; - if ui.selectable_label(false, output.join(" / ")).clicked() && channel.is_some() && !self.state.processing_videos.load(Ordering::SeqCst) { - match &self.state.videos { - Some(videos) => { - self.create_highlight_link(attempt, &**videos); - ctx.request_repaint(); - } - None => { - self.state.processing_videos.store(true, Ordering::SeqCst); - - let repaint_signal = frame.repaint_signal(); - let client_id = self.config.client.id.clone(); - let client_secret = self.config.client.secret.clone(); - self.spawn(async move { - let res = get_videos(&channel.unwrap(), client_id, client_secret).await; - sender.send(Message::Videos(id, res)).ok(); - repaint_signal.request_repaint(); - }); - } - } - } - - if let Some(url) = self.state.highlight_urls.borrow().get(&attempt.id) { - ui.indent(format!("attempt-{}-links", attempt.id), |ui| { - ui.hyperlink_to("Create highlight", url); - }); - } - } - }); } }); + + ui.separator(); + + egui::ScrollArea::auto_sized().show(ui, |ui| { + let func = &mut items[self.state.selected_tab.load(Ordering::SeqCst)].1; + func(ui); + }); } }); } @@ -321,15 +300,17 @@ impl epi::App for App { } impl App { - fn create_highlight_link(&self, attempt: &Attempt, videos: &[VideosResponseVideo]) { + fn create_highlight_link(&self, attempt: &Attempt, videos: &[VideosResponseVideo]) -> bool { + self.state.highlight_urls.borrow_mut().remove(&attempt.id); + let channel = match &self.config.twitch.channel { Some(c) => c, - None => return, + None => return false, }; let splits = match &self.state.splits { - Some(s) => s, - None => return, + Some((s, _)) => s, + None => return false, }; let video = videos @@ -343,7 +324,7 @@ impl App { let (video, duration) = match video { Some(v) => v, - None => return, + None => return false, }; let start = (Utc.from_utc_datetime(&attempt.started) - video.created_at.with_timezone(&Utc)).num_seconds(); @@ -363,7 +344,7 @@ impl App { }; let mut url = match url { Ok(u) => u, - Err(_) => return, + Err(_) => return false, }; let run_time = match attempt.game_time.or(attempt.real_time) { Some(t) => t, @@ -374,6 +355,234 @@ impl App { .append_pair("end", &end.to_string()) .append_pair("title", &format!("{} PB in {}", splits.game_name, to_string(&run_time))); self.state.highlight_urls.borrow_mut().insert(attempt.id, url); + true + } + + fn show_attempts<'a>(&self, ui: &mut Ui, ctx: &CtxRef, frame: &mut Frame<'a>, run: &Run) { + for (key, attempts) in &run.attempt_history.attempts.iter().rev().group_by(|attempt| Local.from_utc_datetime(&attempt.started).date()) { + let attempts: Vec<&Attempt> = attempts + .filter(|attempt| self.config.highlighter.show_incomplete || attempt.real_time.or(attempt.game_time).is_some()) + .collect(); + if attempts.is_empty() { + continue; + } + + ui.collapsing(key.format("%d %B %Y"), |ui| { + ui.columns(2, |ui| { + ui[0].label("Date"); + ui[1].label("Duration"); + }); + + ui.separator(); + + for attempt in attempts { + ui.columns(2, |ui| { + let channel = self.config.twitch.channel.clone(); + ui[0].collapsing(Local.from_utc_datetime(&attempt.started).format("%H:%M:%S"), |ui| { + if ui.button("Use run").clicked() { + self.state.attempt_to_show.set(Some(attempt.id)); + } + + if let Some(id) = self.state.attempt_to_show.get() { + if id != attempt.id { + if ui.button("Compare").clicked() { + // self.state.attempt_to_compare.set(Some(attempt.id)); + } + } + } + + if ui.button("Attempt to create highlighter link").clicked() && !self.state.processing_videos.load(Ordering::SeqCst) { + match &self.state.videos { + Some(videos) => { + if !self.create_highlight_link(attempt, &**videos) { + self.state.no_video.borrow_mut().insert(attempt.id); + } + ctx.request_repaint(); + } + None => { + self.state.processing_videos.store(true, Ordering::SeqCst); + + let id = attempt.id; + let sender = self.state.channel.0.clone(); + let repaint_signal = frame.repaint_signal(); + let client_id = self.config.client.id.clone(); + let client_secret = self.config.client.secret.clone(); + self.spawn(async move { + let res = get_videos(&channel.unwrap(), client_id, client_secret).await; + sender.send(Message::Videos(id, res)).ok(); + repaint_signal.request_repaint(); + }); + } + } + } + + if let Some(url) = self.state.highlight_urls.borrow().get(&attempt.id) { + ui.hyperlink_to("Create highlight", url); + } + + if self.state.no_video.borrow().contains(&attempt.id) { + egui::Label::new("Could not find a VOD for this run") + .text_color(Rgba::from_rgb(1.0, 0.0, 0.0)) + .ui(ui); + } + }); + + let mut output = vec![]; + if let Some(real) = attempt.real_time { + output.push(format!("RTA: {}", to_human(&real))); + } + if let Some(igt) = attempt.game_time { + output.push(format!("IGT: {}", to_human(&igt))); + } + if attempt.real_time.or(attempt.game_time).is_none() { + output.push(format!("Length: {}", to_human(&(attempt.ended - attempt.started)))) + } + + ui[1].label(output.join(" / ")); + }); + } + }); + } + } + + fn show_attempt(&self, ui: &mut Ui, run: &Run) { + let attempt_id = match self.state.attempt_to_show.get() { + Some(id) => id, + None => return, + }; + + let attempt = match run.attempt_history.attempts.iter().find(|attempt| attempt.id == attempt_id) { + Some(attempt) => attempt, + None => return, + }; + + let mut running = Duration::seconds(0); + + let mut show_segment = |ui: &mut Ui, segment: &Segment| { + ui.columns(3, |ui| { + ui[0].label(segment.clean_name()); + if let Some(time) = segment.segment_history.times.iter().find(|time| time.id == attempt.id) { + if let Some(dur) = time.real_time.or(time.game_time) { + running = running + dur; + + ui[1].label(to_human(&dur)); + ui[2].label(to_human(&running)); + } + } + }); + }; + + ui.columns(3, |ui| { + ui[0].label("Split"); + ui[1].label("Duration"); + ui[2].label("Finished"); + }); + + ui.separator(); + + egui::ScrollArea::auto_sized().show(ui, |ui| { + match run.subsplits() { + Some(subs) => { + let mut sub_finish = Duration::seconds(0); + + for sub in subs { + ui.columns(3, |ui| { + let mut sub_time = Duration::seconds(0); + + for segment in &sub.splits { + if let Some(time) = segment.segment_history.times.iter().find(|time| time.id == attempt.id) { + if let Some(dur) = time.real_time.or(time.game_time) { + sub_time = sub_time + dur; + sub_finish = sub_finish + dur; + } + } + } + + egui::Label::new(sub.name) + .strong() + .ui(&mut ui[0]); + + egui::Label::new(to_human(&sub_time)) + .strong() + .ui(&mut ui[1]); + + egui::Label::new(to_human(&sub_finish)) + .strong() + .ui(&mut ui[2]); + }); + // ui.heading(sub.name); + for segment in &sub.splits { + show_segment(ui, segment); + } + } + } + None => { + for segment in &run.segments.segments { + show_segment(ui, segment); + } + } + } + }); + } + + fn show_simple_stats(&self, ui: &mut Ui, run: &Run, stats: &HashMap, sub_stats: &HashMap) { + let mut running = Duration::seconds(0); + + let mut show_segment = |ui: &mut Ui, segment: &Segment| { + ui.columns(3, |ui| { + ui[0].label(segment.clean_name()); + if let Some(&median) = stats.get(&segment.name) { + let dur = Duration::nanoseconds((median * 1_000_000.0) as i64); + running = running + dur; + ui[1].label(to_human(&dur)); + ui[2].label(to_human(&running)); + } + }); + }; + + ui.columns(3, |ui| { + ui[0].label("Split"); + ui[1].label("Duration"); + ui[2].label("Finished"); + }); + + ui.separator(); + + egui::ScrollArea::auto_sized().show(ui, |ui| { + if let Some(subsplits) = run.subsplits() { + for sub in subsplits { + ui.columns(3, |ui| { + egui::Label::new(sub.name) + .strong() + .ui(&mut ui[0]); + + if let Some(sub_stat) = sub_stats.get(sub.name) { + let dur = Duration::nanoseconds((*sub_stat * 1_000_000.0) as i64); + egui::Label::new(to_human(&dur)) + .strong() + .ui(&mut ui[1]); + } + }); + + for segment in sub.splits { + show_segment(ui, segment); + } + } + } else { + for segment in &run.segments.segments { + show_segment(ui, segment); + } + } + }); + + ui.separator(); + + ui.columns(3, |ui| { + ui[0].label("Total"); + let sum: f64 = stats.values().sum(); + let dur = Duration::nanoseconds((sum * 1_000_000.0) as i64); + ui[1].label(to_human(&dur)); + }); } async fn choose_splits(sender: SyncSender, repaint_signal: Arc) { @@ -461,6 +670,108 @@ impl App { } } +struct RunStats { + means: HashMap, + sub_means: HashMap, + + medians: HashMap, + sub_medians: HashMap, +} + +impl RunStats { + fn from(run: &Run) -> Self { + let mut means = HashMap::with_capacity(run.segments.segments.len()); + let mut sub_means = HashMap::with_capacity(run.segments.segments.len()); + + let mut medians = HashMap::with_capacity(run.segments.segments.len()); + let mut sub_medians = HashMap::with_capacity(run.segments.segments.len()); + + let get_median = |times: &[i64]| -> f64 { + if times.len() % 2 == 1 { + times[times.len() / 2] as f64 + } else if times.len() > 1 { + let idx = times.len() / 2; + (times[idx] + times[idx - 1]) as f64 / 2.0 + } else { + 0.0 + } + }; + + let do_stats = |segment: &Segment| -> (f64, f64) { + let times: Vec = segment.segment_history.times + .iter() + .filter_map(|time| time.real_time.or(time.game_time)) + .map(|dur| dur.num_milliseconds()) + .sorted_unstable() + .collect(); + + let median = get_median(×); + let mean = times.iter().sum::() as f64 / times.len() as f64; + (median, mean) + }; + + match run.subsplits() { + Some(subs) => { + for sub in subs { + // for each split in the category + // collect their time in each attempt + // sum them together to get a category time + // collect all category times + // calc stats + + let mut attempt_times = Vec::with_capacity(run.attempt_history.attempts.len()); + for attempt in &run.attempt_history.attempts { + if attempt.real_time.or(attempt.game_time).is_none() { + continue; + } + + let category_time = run.segments.segments + .iter() + // only consider segments in this category + .filter(|seg| sub.splits.iter().any(|segment| segment.name == seg.name)) + .filter_map(|seg| seg.segment_history.times + .iter() + // find the time for this attempt + .find(|time| time.id == attempt.id) + .and_then(|time| time.real_time.or(time.game_time)) + .map(|dur| dur.num_milliseconds()) + ) + .sum::(); + attempt_times.push(category_time); + } + + let sub_median = get_median(&attempt_times); + let sub_mean = attempt_times.iter().sum::() as f64 / attempt_times.len() as f64; + + sub_medians.insert(sub.name.to_string(), sub_median); + sub_means.insert(sub.name.to_string(), sub_mean); + + for segment in &sub.splits { + let (median, mean) = do_stats(segment); + medians.insert(segment.name.clone(), median); + means.insert(segment.name.clone(), mean); + } + } + } + None => { + for segment in &run.segments.segments { + let (median, mean) = do_stats(segment); + medians.insert(segment.name.clone(), median); + means.insert(segment.name.clone(), mean); + } + } + } + + Self { + means, + sub_means, + + medians, + sub_medians, + } + } +} + #[derive(Deserialize)] struct AppCredentials { access_token: String, @@ -543,7 +854,9 @@ fn to_string(duration: &Duration) -> String { } fn to_human(duration: &Duration) -> String { - let mut secs = duration.num_seconds(); + let millis = duration.num_milliseconds(); + + let mut secs = millis / 1000; let mut mins = secs / 60; secs %= 60; diff --git a/src/config.rs b/src/config.rs index 7abab20..7ca6594 100755 --- a/src/config.rs +++ b/src/config.rs @@ -4,16 +4,16 @@ pub struct Config { pub client: Client, pub twitch: Twitch, pub padding: Padding, - #[serde(default)] - pub display: Display, + #[serde(default, rename = "display")] + pub highlighter: Display, } impl Config { - pub fn ready(&self) -> bool { - !self.client.id.is_empty() - && !self.client.secret.is_empty() - && self.twitch.channel.is_some() - } + // pub fn twitch_ready(&self) -> bool { + // !self.client.id.is_empty() + // && !self.client.secret.is_empty() + // && self.twitch.channel.is_some() + // } } #[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))] diff --git a/src/lib.rs b/src/lib.rs index 0cc776f..18ae9a7 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,16 +3,18 @@ #![cfg_attr(not(debug_assertions), deny(warnings))] // Forbid warnings in release builds #![warn(clippy::all, rust_2018_idioms)] +#[cfg(target_arch = "wasm32")] +use eframe::wasm_bindgen::{self, prelude::*}; + +pub use crate::app::App; + mod app; mod config; -pub use crate::app::App; +mod splits_logic; // ---------------------------------------------------------------------------- // When compiling for web: -#[cfg(target_arch = "wasm32")] -use eframe::wasm_bindgen::{self, prelude::*}; - /// This is the entry-point for all the web-assembly. /// This is called once from the HTML. /// It loads the app, installs some callbacks, then returns. diff --git a/src/main.rs b/src/main.rs index d25489a..32e14c4 100755 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,11 @@ #![feature(try_blocks, async_closure)] #![cfg_attr(target_os = "windows", windows_subsystem = "windows")] +use crate::app::App; + mod app; mod config; - -use crate::app::App; +mod splits_logic; #[cfg(not(target_arch = "wasm32"))] fn main() { diff --git a/src/splits_logic.rs b/src/splits_logic.rs new file mode 100755 index 0000000..d350c1c --- /dev/null +++ b/src/splits_logic.rs @@ -0,0 +1,74 @@ +use livesplit::model::Segment; +use livesplit::Run; + +pub trait SplitsLogic { + fn subsplits(&self) -> Option>>; +} + +impl SplitsLogic for Run { + fn subsplits(&self) -> Option>> { + let mut output = Vec::new(); + let mut current_category = SplitCategory::default(); + for segment in &self.segments.segments { + if !segment.is_subsplit() { + if current_category.splits.is_empty() { + return None; + } else { + if let Some(category) = segment.category_name() { + current_category.name = category; + } + + current_category.splits.push(segment); + + let mut insert = SplitCategory::default(); + std::mem::swap(&mut insert, &mut current_category); + + output.push(insert); + + continue; + } + } + + current_category.splits.push(segment); + } + + Some(output) + } +} + +#[derive(Default)] +pub struct SplitCategory<'a> { + pub name: &'a str, + pub splits: Vec<&'a Segment>, +} + +pub trait SegmentLogic { + fn clean_name(&self) -> &str; + fn category_name(&self) -> Option<&str>; + fn is_subsplit(&self) -> bool; +} + +impl SegmentLogic for Segment { + fn clean_name(&self) -> &str { + let mut output = &*self.name; + + if output.chars().filter(|&c| c == '}' || c == '{').count() > 1 { + if let Some(idx) = output.find('}') { + output = output[idx + 1..].trim_start(); + } + } + + output.trim_start_matches(|c| c == '-') + } + + fn category_name(&self) -> Option<&str> { + let open = self.name.find('{')?; + let close = self.name[open + 1..].find('}')? + open + 1; + + Some(&self.name[open + 1..close]) + } + + fn is_subsplit(&self) -> bool { + self.name.starts_with('-') + } +}