├── .gitignore ├── src ├── display │ ├── null │ │ ├── mod.rs │ │ └── null.rs │ ├── smithay │ │ ├── mod.rs │ │ └── smithay.rs │ ├── terminal │ │ ├── mod.rs │ │ └── terminal.rs │ └── mod.rs ├── config.rs ├── time_format.rs ├── file.rs ├── main.rs └── wl_split_timer.rs ├── screenshots └── screenshot.png ├── Cargo.toml ├── wlsplitctl └── main.rs ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | Cargo.lock 3 | .vscode/ 4 | -------------------------------------------------------------------------------- /src/display/null/mod.rs: -------------------------------------------------------------------------------- 1 | mod null; 2 | 3 | pub use self::null::App; -------------------------------------------------------------------------------- /src/display/smithay/mod.rs: -------------------------------------------------------------------------------- 1 | mod smithay; 2 | 3 | pub use self::smithay::App; -------------------------------------------------------------------------------- /src/display/terminal/mod.rs: -------------------------------------------------------------------------------- 1 | mod terminal; 2 | 3 | pub use self::terminal::App; -------------------------------------------------------------------------------- /screenshots/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junglerobba/wlsplit/HEAD/screenshots/screenshot.png -------------------------------------------------------------------------------- /src/display/mod.rs: -------------------------------------------------------------------------------- 1 | mod terminal; 2 | 3 | pub use self::terminal::App as TerminalApp; 4 | 5 | mod null; 6 | 7 | pub use self::null::App as Headless; 8 | 9 | mod smithay; 10 | 11 | pub use self::smithay::App as Wayland; -------------------------------------------------------------------------------- /src/display/null/null.rs: -------------------------------------------------------------------------------- 1 | use crate::{wl_split_timer::WlSplitTimer, TimerDisplay}; 2 | 3 | use std::{ 4 | error::Error, 5 | sync::{Arc, Mutex}, 6 | }; 7 | pub struct App { 8 | timer: Arc>, 9 | } 10 | impl App { 11 | pub fn new(timer: WlSplitTimer) -> Self { 12 | Self { 13 | timer: Arc::new(Mutex::new(timer)), 14 | } 15 | } 16 | } 17 | 18 | impl TimerDisplay for App { 19 | fn run(&mut self) -> Result> { 20 | let timer = self.timer.lock().unwrap(); 21 | if timer.exit { 22 | return Ok(true); 23 | } 24 | Ok(false) 25 | } 26 | 27 | fn timer(&self) -> &Arc> { 28 | &self.timer 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "wlsplit" 3 | version = "0.1.0" 4 | authors = ["Tobias Langendorf "] 5 | edition = "2018" 6 | default-run = "wlsplit" 7 | 8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 9 | 10 | [dependencies] 11 | livesplit-core = "0.11.0" 12 | clap = "2.33.3" 13 | chrono = "0.4.19" 14 | tui = { version = "0.10.0", features = ["crossterm"], default-features = false } 15 | crossterm = "0.19.0" 16 | serde = { version = "1.0.126", features = ["derive"] } 17 | serde_json = "1.0" 18 | smithay-client-toolkit = "0.14.0" 19 | andrew = "0.3.1" 20 | font-kit = "0.10.0" 21 | confy = "0.4.0" 22 | 23 | [[bin]] 24 | name = "wlsplit" 25 | path = "src/main.rs" 26 | 27 | [[bin]] 28 | name = "wlsplitctl" 29 | path = "wlsplitctl/main.rs" 30 | -------------------------------------------------------------------------------- /wlsplitctl/main.rs: -------------------------------------------------------------------------------- 1 | use clap::{App, Arg}; 2 | use std::env; 3 | use std::error::Error; 4 | use std::io::prelude::*; 5 | use std::os::unix::net::UnixStream; 6 | 7 | const SOCKET_NAME: &str = "wlsplit.sock"; 8 | 9 | fn main() -> Result<(), Box> { 10 | let socket_path = format!( 11 | "{}/{}", 12 | env::var("XDG_RUNTIME_DIR").unwrap_or("/tmp".to_string()), 13 | SOCKET_NAME 14 | ); 15 | let matches = App::new("wlsplitctl") 16 | .arg(Arg::with_name("command").required(true).index(1)) 17 | .arg( 18 | Arg::with_name("socket") 19 | .short("s") 20 | .long("socket") 21 | .default_value(&socket_path), 22 | ) 23 | .get_matches(); 24 | 25 | let socket = matches.value_of("socket").unwrap().to_string(); 26 | let command = matches 27 | .value_of("command") 28 | .expect("Input command required!"); 29 | 30 | let mut stream = UnixStream::connect(&socket).expect("Server is not running"); 31 | 32 | stream.write_all(&command.as_bytes())?; 33 | Ok(()) 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Tobias Langendorf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Serialize, Deserialize)] 4 | pub struct Config { 5 | pub anchor: String, 6 | pub margin: (i32, i32, i32, i32), 7 | pub width: usize, 8 | pub text_size: usize, 9 | pub padding_h: usize, 10 | pub padding_v: usize, 11 | pub background_color: [u8; 3], 12 | pub background_opacity: u8, 13 | pub font_color: [u8; 4], 14 | pub font_color_gain: [u8; 4], 15 | pub font_color_loss: [u8; 4], 16 | pub font_color_gold: [u8; 4], 17 | pub font_family: Option, 18 | pub target_framerate: u16, 19 | } 20 | 21 | impl Default for Config { 22 | fn default() -> Self { 23 | Self { 24 | anchor: String::from("top-left"), 25 | margin: (12, 12, 12, 12), 26 | width: 400, 27 | text_size: 20, 28 | padding_h: 5, 29 | padding_v: 5, 30 | background_color: [0, 0, 0], 31 | background_opacity: 128, 32 | font_color: [255, 255, 255, 255], 33 | font_color_gain: [255, 0, 255, 0], 34 | font_color_loss: [255, 255, 0, 0], 35 | font_color_gold: [255, 255, 255, 0], 36 | font_family: None, 37 | target_framerate: 30, 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wlsplit 2 | 3 | Basic speedrun timer for Wayland compositors using wlr-layer-shell (wlroots/kwin) 4 | 5 | [![Search](screenshots/screenshot.png?raw=true)](screenshots/screenshot.png?raw=true) 6 | # Usage 7 | 8 | For the simplest case, simply execute `wlsplit ` and a split file will be generated and immediately used. 9 | 10 | Some optional flags can be passed to change the content of that generated file: 11 | 12 | - `--game`: Game name to use 13 | - `--category`: Run category (e.g. "any%") 14 | - `--splits`: A comma separated list of splits to use (e.g. "Tutorial,Boss 1,Firelink Shrine" etc) 15 | 16 | See `wlsplit --help` for more. 17 | 18 | wlsplit does not support any direct commands, instead it is meant to be controlled via socket, for which `wlsplitctl` can be used. 19 | Available commands are: 20 | 21 | - start 22 | - split 23 | - skip 24 | - pause 25 | - reset 26 | - quit 27 | 28 | I would recommend binding these commands as hotkeys in your compositor so that they can be used while a game is in focus. 29 | 30 | # Installation 31 | 32 | ## Requirements 33 | 34 | - `freetype-devel` 35 | - `fontconfig-devel` 36 | 37 | For installation into `~/.cargo/bin` simply clone the repo and run: `cargo install --path .` 38 | 39 | # Configuration 40 | 41 | A configuration file with the defaults is automatically created in `.config/wlsplit/wlsplit.toml`. 42 | Current configuration support is still rather rudimentary and will hopefully be improved. -------------------------------------------------------------------------------- /src/time_format.rs: -------------------------------------------------------------------------------- 1 | const MSEC_HOUR: u128 = 3600000; 2 | const MSEC_MINUTE: u128 = 60000; 3 | const MSEC_SECOND: u128 = 1000; 4 | 5 | pub struct TimeFormat { 6 | pub hours: usize, 7 | pub minutes: usize, 8 | pub seconds: usize, 9 | pub msecs: usize, 10 | pub allow_shorten: bool, 11 | pub always_prefix: bool, 12 | } 13 | 14 | impl TimeFormat { 15 | pub fn for_diff() -> Self { 16 | TimeFormat { 17 | always_prefix: true, 18 | ..Default::default() 19 | } 20 | } 21 | 22 | pub fn for_file() -> Self { 23 | TimeFormat { 24 | allow_shorten: false, 25 | ..Default::default() 26 | } 27 | } 28 | 29 | pub fn format_time(&self, time: u128, negative: bool) -> String { 30 | let prefix = if negative { 31 | "-" 32 | } else if self.always_prefix { 33 | "+" 34 | } else { 35 | "" 36 | }; 37 | let mut time = time; 38 | let hours = time / MSEC_HOUR; 39 | time -= hours * MSEC_HOUR; 40 | let minutes = time / MSEC_MINUTE; 41 | time -= minutes * MSEC_MINUTE; 42 | let seconds = time / MSEC_SECOND; 43 | time -= seconds * MSEC_SECOND; 44 | 45 | if self.allow_shorten && hours == 0 { 46 | if minutes == 0 { 47 | return format!( 48 | "{}{}.{}", 49 | prefix, 50 | pad_zeroes(seconds, self.seconds), 51 | pad_zeroes(time, self.msecs), 52 | ); 53 | } 54 | return format!( 55 | "{}{}:{}.{}", 56 | prefix, 57 | pad_zeroes(minutes, self.minutes), 58 | pad_zeroes(seconds, self.seconds), 59 | pad_zeroes(time, self.msecs), 60 | ); 61 | } 62 | format!( 63 | "{}{}:{}:{}.{}", 64 | prefix, 65 | pad_zeroes(hours, self.hours), 66 | pad_zeroes(minutes, self.minutes), 67 | pad_zeroes(seconds, self.seconds), 68 | pad_zeroes(time, self.msecs), 69 | ) 70 | } 71 | } 72 | 73 | impl Default for TimeFormat { 74 | fn default() -> Self { 75 | Self { 76 | hours: 2, 77 | minutes: 2, 78 | seconds: 2, 79 | msecs: 3, 80 | allow_shorten: true, 81 | always_prefix: false, 82 | } 83 | } 84 | } 85 | 86 | fn pad_zeroes(time: u128, length: usize) -> String { 87 | let str_length = time.to_string().chars().count(); 88 | if str_length >= length { 89 | return format!("{}", time); 90 | } 91 | let count = length - str_length; 92 | let zeroes = "0".repeat(count); 93 | format!("{}{}", zeroes, time) 94 | } 95 | -------------------------------------------------------------------------------- /src/file.rs: -------------------------------------------------------------------------------- 1 | use std::{error::Error, fs::File, io::Read, io::Write}; 2 | 3 | use livesplit_core::Run as LivesplitRun; 4 | use serde::{de::DeserializeOwned, Deserialize, Serialize}; 5 | 6 | use crate::time_format::TimeFormat; 7 | 8 | #[derive(Debug, Serialize, Deserialize, PartialEq)] 9 | pub struct Run { 10 | pub game_name: String, 11 | pub category_name: String, 12 | pub attempt_count: usize, 13 | pub attempt_history: Vec, 14 | pub segments: Vec, 15 | } 16 | 17 | impl Default for Run { 18 | fn default() -> Self { 19 | let segments = vec![Segment { 20 | name: "Example Segment".to_string(), 21 | ..Default::default() 22 | }]; 23 | 24 | Self { 25 | game_name: "Example Splits".to_string(), 26 | category_name: "Any%".to_string(), 27 | attempt_count: 0, 28 | attempt_history: Vec::new(), 29 | segments, 30 | } 31 | } 32 | } 33 | 34 | impl Run { 35 | pub fn new(run: &LivesplitRun) -> Self { 36 | let mut attempt_history: Vec = Vec::new(); 37 | for attempt in run.attempt_history() { 38 | if let Some(time) = attempt.time().real_time { 39 | attempt_history.push(Attempt { 40 | time: Some( 41 | TimeFormat::for_file() 42 | .format_time(time.total_milliseconds() as u128, false), 43 | ), 44 | id: attempt.index(), 45 | started: attempt.started().map(|t| t.time.to_rfc3339()), 46 | ended: attempt.ended().map(|t| t.time.to_rfc3339()), 47 | pause_time: attempt.pause_time().map(|t| { 48 | TimeFormat::for_file().format_time(t.total_milliseconds() as u128, false) 49 | }), 50 | }); 51 | } 52 | } 53 | 54 | let mut segments: Vec = Vec::new(); 55 | for segment in run.segments() { 56 | let best_segment_time = segment.best_segment_time().real_time.map(|time| { 57 | TimeFormat::for_file().format_time(time.total_milliseconds() as u128, false) 58 | }); 59 | 60 | let personal_best_split_time = 61 | segment.personal_best_split_time().real_time.map(|time| { 62 | TimeFormat::for_file().format_time(time.total_milliseconds() as u128, false) 63 | }); 64 | 65 | let segment_history: Vec = segment 66 | .segment_history() 67 | .iter() 68 | .map(|entry| SplitTime { 69 | id: Some(entry.0), 70 | time: entry.1.real_time.map(|time| { 71 | TimeFormat::for_file().format_time(time.total_milliseconds() as u128, false) 72 | }), 73 | }) 74 | .collect(); 75 | 76 | segments.push(Segment { 77 | name: segment.name().to_string(), 78 | segment_history, 79 | personal_best_split_time, 80 | best_segment_time, 81 | }); 82 | } 83 | 84 | Self { 85 | game_name: run.game_name().to_string(), 86 | category_name: run.category_name().to_string(), 87 | attempt_count: run.attempt_count() as usize, 88 | attempt_history, 89 | segments, 90 | } 91 | } 92 | 93 | pub fn with_game_name(mut self, game_name: &str) -> Self { 94 | self.game_name = game_name.to_string(); 95 | self 96 | } 97 | 98 | pub fn with_category_name(mut self, category_name: &str) -> Self { 99 | self.category_name = category_name.to_string(); 100 | self 101 | } 102 | 103 | pub fn with_splits(mut self, splits: Vec<&str>) -> Self { 104 | self.segments = splits 105 | .iter() 106 | .map(|split| Segment { 107 | name: split.to_string(), 108 | ..Default::default() 109 | }) 110 | .collect(); 111 | self 112 | } 113 | } 114 | 115 | #[derive(Debug, Serialize, Deserialize, PartialEq)] 116 | pub struct Attempt { 117 | pub id: i32, 118 | pub started: Option, 119 | pub ended: Option, 120 | pub time: Option, 121 | pub pause_time: Option, 122 | } 123 | 124 | #[derive(Debug, Serialize, Deserialize, PartialEq)] 125 | pub struct SplitTime { 126 | pub time: Option, 127 | pub id: Option, 128 | } 129 | 130 | #[derive(Debug, Serialize, Deserialize, PartialEq, Default)] 131 | pub struct Segment { 132 | pub name: String, 133 | pub personal_best_split_time: Option, 134 | pub best_segment_time: Option, 135 | pub segment_history: Vec, 136 | } 137 | 138 | pub fn read_json(path: &str) -> Result> { 139 | let mut file = File::open(path)?; 140 | let mut content = String::new(); 141 | file.read_to_string(&mut content)?; 142 | let result: T = serde_json::from_str(&content)?; 143 | 144 | Ok(result) 145 | } 146 | 147 | pub fn write_json(path: &str, data: T) -> Result<(), Box> { 148 | let serialized = serde_json::to_string_pretty(&data)?; 149 | let mut file = File::create(path)?; 150 | file.write_all(serialized.as_bytes())?; 151 | 152 | Ok(()) 153 | } 154 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | config::Config, 3 | display::{Headless, TerminalApp, Wayland}, 4 | wl_split_timer::RunMetadata, 5 | }; 6 | use clap::{App, Arg}; 7 | use std::{ 8 | env, 9 | error::Error, 10 | fs::OpenOptions, 11 | sync::{Arc, Mutex}, 12 | time::Duration, 13 | }; 14 | use std::{ 15 | io::{BufRead, BufReader}, 16 | os::unix::net::{UnixListener, UnixStream}, 17 | }; 18 | use wl_split_timer::WlSplitTimer; 19 | mod config; 20 | mod display; 21 | mod file; 22 | mod time_format; 23 | mod wl_split_timer; 24 | 25 | #[macro_export] 26 | macro_rules! app_name { 27 | () => { 28 | "wlsplit" 29 | }; 30 | } 31 | 32 | const SOCKET_NAME: &str = concat!(app_name!(), ".sock"); 33 | 34 | pub trait TimerDisplay { 35 | fn run(&mut self) -> Result>; 36 | 37 | fn timer(&self) -> &Arc>; 38 | } 39 | 40 | fn main() -> Result<(), Box> { 41 | let socket_path = format!( 42 | "{}/{}", 43 | env::var("XDG_RUNTIME_DIR").unwrap_or("/tmp".to_string()), 44 | SOCKET_NAME 45 | ); 46 | let matches = App::new("wlsplit") 47 | .arg(Arg::with_name("file").required(true).index(1)) 48 | .arg( 49 | Arg::with_name("display") 50 | .short("d") 51 | .long("display") 52 | .default_value("wayland"), 53 | ) 54 | .arg( 55 | Arg::with_name("create_file") 56 | .short("f") 57 | .long("create-file") 58 | .long_help("Creates a new file regardless if a file already exists in that location or not") 59 | .required(false) 60 | .takes_value(false), 61 | ) 62 | .arg( 63 | Arg::with_name("game_name") 64 | .long_help("Game name to use when generating run file") 65 | .long("game") 66 | .required(false) 67 | .takes_value(true), 68 | ) 69 | .arg( 70 | Arg::with_name("category_name") 71 | .long_help("Category name to use when generating run file") 72 | .long("category") 73 | .required(false) 74 | .takes_value(true), 75 | ) 76 | .arg( 77 | Arg::with_name("splits") 78 | .long_help("Comma separated list of splits to use when generating run file") 79 | .long("splits") 80 | .required(false) 81 | .takes_value(true), 82 | ) 83 | .arg( 84 | Arg::with_name("socket") 85 | .short("s") 86 | .long("socket") 87 | .default_value(&socket_path), 88 | ) 89 | .get_matches(); 90 | let config: Config = confy::load("wlsplit")?; 91 | println!("{:?}", config); 92 | let input = matches.value_of("file").expect("Input file required!"); 93 | 94 | let create_file = matches.is_present("create_file") 95 | || OpenOptions::new() 96 | .write(true) 97 | .create_new(true) 98 | .open(input) 99 | .is_ok(); 100 | 101 | let socket = matches.value_of("socket").unwrap().to_string(); 102 | 103 | let timer = if create_file { 104 | let metadata = RunMetadata { 105 | game_name: matches.value_of("game_name"), 106 | category_name: matches.value_of("category_name"), 107 | splits: matches 108 | .value_of("splits") 109 | .map(|split_names| split_names.split(',').collect()), 110 | }; 111 | WlSplitTimer::new(input.to_string(), metadata) 112 | } else { 113 | WlSplitTimer::from_file(input.to_string()) 114 | }; 115 | 116 | let display = matches.value_of("display").unwrap(); 117 | let app = get_app(display, timer, &config); 118 | 119 | let app = Arc::new(Mutex::new(app)); 120 | let timer = Arc::clone(app.lock().unwrap().timer()); 121 | 122 | std::fs::remove_file(&socket).ok(); 123 | let listener = UnixListener::bind(&socket).unwrap(); 124 | std::thread::spawn(move || { 125 | for stream in listener.incoming().flatten() { 126 | if handle_stream_response(&timer, stream) { 127 | break; 128 | } 129 | } 130 | }); 131 | 132 | loop { 133 | if app.lock().unwrap().run().unwrap_or(false) { 134 | break; 135 | } 136 | std::thread::sleep(Duration::from_millis(33)); 137 | } 138 | std::fs::remove_file(&socket).ok(); 139 | Ok(()) 140 | } 141 | 142 | fn handle_stream_response(timer: &Arc>, stream: UnixStream) -> bool { 143 | let stream = BufReader::new(stream); 144 | for line in stream.lines() { 145 | match line.unwrap_or_default().as_str() { 146 | "start" => { 147 | timer.lock().unwrap().start(); 148 | } 149 | "split" => { 150 | timer.lock().unwrap().split(); 151 | } 152 | "skip" => { 153 | timer.lock().unwrap().skip(); 154 | } 155 | "pause" => { 156 | timer.lock().unwrap().pause(); 157 | } 158 | "reset" => { 159 | timer.lock().unwrap().reset(true); 160 | } 161 | "quit" => { 162 | timer.lock().unwrap().quit(); 163 | return true; 164 | } 165 | _ => {} 166 | } 167 | } 168 | false 169 | } 170 | 171 | fn get_app(display: &str, timer: WlSplitTimer, config: &Config) -> Box { 172 | match display { 173 | "terminal" => Box::new(TerminalApp::new(timer)), 174 | "null" => Box::new(Headless::new(timer)), 175 | "wayland" => Box::new(Wayland::new(timer, config)), 176 | _ => { 177 | panic!("Unknown method"); 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/display/terminal/terminal.rs: -------------------------------------------------------------------------------- 1 | use crossterm::{ 2 | execute, 3 | terminal::{EnterAlternateScreen, LeaveAlternateScreen}, 4 | }; 5 | 6 | use crate::{time_format::TimeFormat, wl_split_timer::WlSplitTimer, TimerDisplay}; 7 | use livesplit_core::TimeSpan; 8 | use std::io::{stdout, Stdout}; 9 | use std::{ 10 | convert::TryInto, 11 | error::Error, 12 | sync::{Arc, Mutex}, 13 | }; 14 | use tui::{ 15 | backend::CrosstermBackend, 16 | layout::{Constraint, Layout}, 17 | style::{Color, Modifier, Style}, 18 | widgets::Row, 19 | widgets::Table, 20 | widgets::TableState, 21 | widgets::{Block, Borders}, 22 | Terminal, 23 | }; 24 | 25 | pub struct App { 26 | timer: Arc>, 27 | terminal: Terminal>, 28 | } 29 | impl App { 30 | pub fn new(timer: WlSplitTimer) -> Self { 31 | let mut stdout = stdout(); 32 | execute!(stdout, EnterAlternateScreen).unwrap(); 33 | 34 | let backend = CrosstermBackend::new(stdout); 35 | let mut terminal = Terminal::new(backend).unwrap(); 36 | terminal.hide_cursor().unwrap(); 37 | 38 | Self { 39 | timer: Arc::new(Mutex::new(timer)), 40 | terminal, 41 | } 42 | } 43 | 44 | fn quit(&mut self) { 45 | execute!(stdout(), LeaveAlternateScreen).unwrap(); 46 | self.terminal.show_cursor().unwrap(); 47 | } 48 | } 49 | 50 | impl TimerDisplay for App { 51 | fn run(&mut self) -> Result> { 52 | let mut rows: Vec> = Vec::new(); 53 | 54 | let timer = self.timer.lock().unwrap(); 55 | if timer.exit { 56 | drop(timer); 57 | self.quit(); 58 | return Ok(true); 59 | } 60 | for (i, segment) in timer.segments().iter().enumerate() { 61 | let mut row = Vec::new(); 62 | let index = timer.current_segment_index().unwrap_or(0); 63 | 64 | // Segment 65 | if i == index { 66 | row.push(format!("> {}", segment.name().to_string())); 67 | } else { 68 | row.push(format!(" {}", segment.name().to_string())); 69 | } 70 | 71 | // Current 72 | row.push(match i.cmp(&index) { 73 | std::cmp::Ordering::Equal => { 74 | diff_time(timer.time(), segment.personal_best_split_time().real_time) 75 | } 76 | std::cmp::Ordering::Less => diff_time( 77 | segment.split_time().real_time, 78 | timer.segments()[i].personal_best_split_time().real_time, 79 | ), 80 | _ => "".to_string(), 81 | }); 82 | 83 | let time = if let Some(time) = segment.personal_best_split_time().real_time { 84 | Some(time) 85 | } else if segment.segment_history().iter().len() == 0 { 86 | segment.split_time().real_time 87 | } else { 88 | None 89 | }; 90 | row.push(time.map_or("-:--:--.---".to_string(), |time| { 91 | TimeFormat::default() 92 | .format_time(time.to_duration().num_milliseconds() as u128, false) 93 | })); 94 | 95 | rows.push(row); 96 | } 97 | 98 | if let Some(time) = timer.time() { 99 | rows.push(vec![ 100 | "".to_string(), 101 | "".to_string(), 102 | TimeFormat::default().format_time( 103 | time.to_duration().num_milliseconds().try_into().unwrap(), 104 | false, 105 | ), 106 | ]); 107 | } 108 | 109 | rows.push(vec![ 110 | "".to_string(), 111 | "Sum of best segments".to_string(), 112 | TimeFormat::default().format_time(timer.sum_of_best_segments() as u128, false), 113 | ]); 114 | 115 | rows.push(vec![ 116 | "".to_string(), 117 | "Best possible time".to_string(), 118 | TimeFormat::default().format_time(timer.best_possible_time() as u128, false), 119 | ]); 120 | 121 | let title = format!( 122 | "{} {} - {}", 123 | timer.run().game_name(), 124 | timer.run().category_name(), 125 | timer.run().attempt_count() 126 | ); 127 | 128 | drop(timer); 129 | 130 | self.terminal.draw(|f| { 131 | let rects = Layout::default() 132 | .constraints([Constraint::Percentage(0)].as_ref()) 133 | .margin(0) 134 | .split(f.size()); 135 | 136 | let selected_style = Style::default() 137 | .fg(Color::Yellow) 138 | .add_modifier(Modifier::BOLD); 139 | let normal_style = Style::default().fg(Color::White); 140 | let header = ["Segment", "Current", "Best"]; 141 | let rows = rows.iter().map(|i| Row::StyledData(i.iter(), normal_style)); 142 | let t = Table::new(header.iter(), rows) 143 | .block(Block::default().borders(Borders::NONE).title(title)) 144 | .highlight_style(selected_style) 145 | .highlight_symbol(">> ") 146 | .widths(&[ 147 | Constraint::Percentage(40), 148 | Constraint::Percentage(30), 149 | Constraint::Percentage(30), 150 | ]); 151 | f.render_stateful_widget(t, rects[0], &mut TableState::default()); 152 | })?; 153 | Ok(false) 154 | } 155 | 156 | fn timer(&self) -> &Arc> { 157 | &self.timer 158 | } 159 | } 160 | fn diff_time(time: Option, best: Option) -> String { 161 | if let (Some(time), Some(best)) = (time, best) { 162 | let time = time.to_duration().num_milliseconds(); 163 | let best = best.to_duration().num_milliseconds(); 164 | let negative = best > time; 165 | let diff = if negative { best - time } else { time - best } as u128; 166 | return TimeFormat::for_diff().format_time(diff, negative); 167 | } 168 | "".to_string() 169 | } 170 | -------------------------------------------------------------------------------- /src/wl_split_timer.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | 3 | use crate::file::{self, Run as RunFile}; 4 | use chrono::{DateTime, Utc}; 5 | use livesplit_core::{AtomicDateTime, Run, Segment, Time, TimeSpan, Timer, TimerPhase}; 6 | 7 | const MSEC_HOUR: u128 = 3600000; 8 | const MSEC_MINUTE: u128 = 60000; 9 | const MSEC_SECOND: u128 = 1000; 10 | 11 | pub struct RunMetadata<'a> { 12 | pub game_name: Option<&'a str>, 13 | pub category_name: Option<&'a str>, 14 | pub splits: Option>, 15 | } 16 | pub struct WlSplitTimer { 17 | timer: Timer, 18 | file: String, 19 | pub exit: bool, 20 | } 21 | 22 | impl WlSplitTimer { 23 | pub fn new(file: String, metadata: RunMetadata) -> Self { 24 | let mut run = Run::new(); 25 | 26 | let mut generated = RunFile::default(); 27 | if let Some(game_name) = metadata.game_name { 28 | generated = generated.with_game_name(game_name); 29 | } 30 | if let Some(category_name) = metadata.category_name { 31 | generated = generated.with_category_name(category_name); 32 | } 33 | if let Some(splits) = metadata.splits { 34 | generated = generated.with_splits(splits); 35 | } 36 | file_to_run(generated, &mut run); 37 | write_file(&file, &run).expect("Could not write file"); 38 | let timer = Timer::new(run).unwrap(); 39 | 40 | Self { 41 | timer, 42 | file, 43 | exit: false, 44 | } 45 | } 46 | 47 | pub fn from_file(file: String) -> Self { 48 | let mut run = Run::new(); 49 | read_file(&file, &mut run).expect("Unable to parse file"); 50 | let timer = Timer::new(run).expect("At least one segment expected"); 51 | 52 | Self { 53 | timer, 54 | file, 55 | exit: false, 56 | } 57 | } 58 | 59 | pub fn timer(&self) -> &Timer { 60 | &self.timer 61 | } 62 | 63 | pub fn run(&self) -> &Run { 64 | self.timer.run() 65 | } 66 | 67 | pub fn game_name(&self) -> &str { 68 | self.timer.run().game_name() 69 | } 70 | 71 | pub fn category_name(&self) -> &str { 72 | self.timer.run().category_name() 73 | } 74 | 75 | pub fn start(&mut self) { 76 | self.timer.start(); 77 | } 78 | 79 | pub fn pause(&mut self) { 80 | self.timer.toggle_pause_or_start(); 81 | } 82 | 83 | pub fn split(&mut self) { 84 | self.timer.split(); 85 | let end_of_run = self.timer.current_phase() == TimerPhase::Ended; 86 | 87 | if end_of_run { 88 | self.reset(true); 89 | self.write_file().ok(); 90 | } 91 | } 92 | 93 | pub fn skip(&mut self) { 94 | self.timer.skip_split(); 95 | } 96 | 97 | pub fn reset(&mut self, update_splits: bool) { 98 | self.timer.reset(update_splits); 99 | if update_splits { 100 | self.write_file().ok(); 101 | } 102 | } 103 | 104 | pub fn quit(&mut self) { 105 | self.exit = true; 106 | } 107 | 108 | pub fn write_file(&self) -> Result<(), Box> { 109 | write_file(&self.file, &self.timer.run()) 110 | } 111 | 112 | pub fn time(&self) -> Option { 113 | self.timer.current_time().real_time 114 | } 115 | 116 | pub fn segments(&self) -> &[Segment] { 117 | self.timer.run().segments() 118 | } 119 | 120 | pub fn current_segment(&self) -> Option<&Segment> { 121 | self.timer.current_split() 122 | } 123 | 124 | pub fn current_segment_index(&self) -> Option { 125 | self.timer.current_split_index() 126 | } 127 | 128 | pub fn segment_split_time(&self, index: usize) -> Time { 129 | self.timer.run().segment(index).split_time() 130 | } 131 | 132 | pub fn segment_best_time(&self, index: usize) -> Time { 133 | self.timer.run().segment(index).best_segment_time() 134 | } 135 | 136 | pub fn sum_of_best_segments(&self) -> usize { 137 | let mut sum: usize = 0; 138 | for segment in self.timer.run().segments() { 139 | if let Some(time) = segment.best_segment_time().real_time { 140 | sum += time.total_milliseconds() as usize; 141 | } 142 | } 143 | sum 144 | } 145 | 146 | pub fn best_possible_time(&self) -> usize { 147 | let index = self.current_segment_index().unwrap_or(0); 148 | 149 | if index == 0 { 150 | return self.sum_of_best_segments(); 151 | } 152 | 153 | let mut time: usize = self 154 | .run() 155 | .segment(index - 1) 156 | .split_time() 157 | .real_time 158 | .unwrap_or_default() 159 | .total_milliseconds() as usize; 160 | 161 | for segment in self.run().segments().iter().skip(index) { 162 | let segment = segment 163 | .best_segment_time() 164 | .real_time 165 | .unwrap_or_default() 166 | .total_milliseconds() as usize; 167 | time += segment; 168 | } 169 | 170 | time 171 | } 172 | 173 | pub fn parse_time_string(time: String) -> Result> { 174 | let split: Vec<&str> = time.split(':').collect(); 175 | let mut time: u128 = 0; 176 | time += MSEC_HOUR * split.get(0).ok_or("")?.parse::()?; 177 | time += MSEC_MINUTE * split.get(1).ok_or("")?.parse::()?; 178 | 179 | let split: Vec<&str> = split.get(2).ok_or("")?.split('.').collect(); 180 | 181 | time += MSEC_SECOND * split.get(0).ok_or("")?.parse::()?; 182 | time += split 183 | .get(1) 184 | .ok_or("")? 185 | .chars() 186 | .take(3) 187 | .collect::() 188 | .parse::()?; 189 | 190 | Ok(time) 191 | } 192 | 193 | pub fn string_to_time(string: String) -> Time { 194 | let time = WlSplitTimer::parse_time_string(string) 195 | .map(|time| TimeSpan::from_milliseconds(time as f64)) 196 | .expect("Unable to parse time"); 197 | 198 | Time::new().with_real_time(Some(time)) 199 | } 200 | 201 | pub fn get_segment_time(&self, index: usize) -> Option { 202 | let current_time = self 203 | .segments() 204 | .get(index) 205 | .and_then(|segment| segment.split_time().real_time); 206 | if index == 0 { 207 | return current_time.map(|time| time.to_duration().num_milliseconds() as usize); 208 | } 209 | let time = self 210 | .segments() 211 | .get(index - 1) 212 | .and_then(|segment| segment.split_time().real_time); 213 | if let (Some(current_time), Some(time)) = (current_time, time) { 214 | Some( 215 | (current_time.to_duration().num_milliseconds() 216 | - time.to_duration().num_milliseconds()) as usize, 217 | ) 218 | } else { 219 | None 220 | } 221 | } 222 | 223 | pub fn get_personal_best_index(&self) -> Option { 224 | let history = self.run().attempt_history().to_vec(); 225 | history 226 | .iter() 227 | .min_by(|a, b| a.time().real_time.cmp(&b.time().real_time)) 228 | .map(|attempt| attempt.index()) 229 | } 230 | 231 | pub fn get_personal_best_segment_time(&self, index: usize) -> Option