├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── screenshots └── screenshot.png ├── src ├── config.rs ├── display │ ├── mod.rs │ ├── null │ │ ├── mod.rs │ │ └── null.rs │ ├── smithay │ │ ├── mod.rs │ │ └── smithay.rs │ └── terminal │ │ ├── mod.rs │ │ └── terminal.rs ├── file.rs ├── main.rs ├── time_format.rs └── wl_split_timer.rs └── wlsplitctl └── main.rs /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | Cargo.lock 3 | .vscode/ 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /screenshots/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/junglerobba/wlsplit/084f7a4d709b5b1bf0e674702e97df4e0a5c1ee1/screenshots/screenshot.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/mod.rs: -------------------------------------------------------------------------------- 1 | mod null; 2 | 3 | pub use self::null::App; -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/display/smithay/mod.rs: -------------------------------------------------------------------------------- 1 | mod smithay; 2 | 3 | pub use self::smithay::App; -------------------------------------------------------------------------------- /src/display/smithay/smithay.rs: -------------------------------------------------------------------------------- 1 | use andrew::Canvas; 2 | use livesplit_core::{Segment, TimeSpan, TimerPhase}; 3 | use smithay_client_toolkit::{ 4 | default_environment, 5 | environment::{Environment, SimpleGlobal}, 6 | new_default_environment, 7 | reexports::{ 8 | calloop::{self, EventLoop}, 9 | client::protocol::*, 10 | client::{Display, Main}, 11 | protocols::wlr::unstable::layer_shell::v1::client::{ 12 | zwlr_layer_shell_v1, zwlr_layer_surface_v1, 13 | }, 14 | }, 15 | shm::AutoMemPool, 16 | WaylandSource, 17 | }; 18 | 19 | use std::{ 20 | cell::Cell, 21 | convert::TryInto, 22 | error::Error, 23 | rc::Rc, 24 | sync::{Arc, Mutex}, 25 | time::{Duration, Instant}, 26 | }; 27 | 28 | use font_kit::{family_name::FamilyName, properties::Properties, source::SystemSource}; 29 | 30 | use crate::{config::Config, time_format::TimeFormat, wl_split_timer::WlSplitTimer, TimerDisplay}; 31 | 32 | default_environment!(Env, 33 | fields = [ 34 | layer_shell: SimpleGlobal, 35 | ], 36 | singles = [ 37 | zwlr_layer_shell_v1::ZwlrLayerShellV1 => layer_shell 38 | ], 39 | ); 40 | 41 | type Damage = [usize; 4]; 42 | 43 | #[derive(Debug)] 44 | pub enum SplitColor { 45 | Gain, 46 | Loss, 47 | Gold, 48 | } 49 | 50 | pub struct App<'a> { 51 | timer: Arc>, 52 | surface: Surface, 53 | display: Display, 54 | event_loop: EventLoop<'a, ()>, 55 | sleep: u16, 56 | } 57 | 58 | impl App<'_> { 59 | pub fn new(timer: WlSplitTimer, config: &Config) -> Self { 60 | let (env, display, queue) = 61 | new_default_environment!(Env, fields = [layer_shell: SimpleGlobal::new(),]) 62 | .expect("Initial roundtrip failed!"); 63 | let event_loop = calloop::EventLoop::<()>::try_new().unwrap(); 64 | WaylandSource::new(queue) 65 | .quick_insert(event_loop.handle()) 66 | .unwrap(); 67 | 68 | let height = get_total_height(timer.segments().len(), config.text_size, config.padding_v); 69 | let surface = Surface::new(&env, None, (config.width as u32, height as u32), config); 70 | Self { 71 | timer: Arc::new(Mutex::new(timer)), 72 | surface, 73 | display, 74 | event_loop, 75 | sleep: 1000 / config.target_framerate, 76 | } 77 | } 78 | } 79 | 80 | impl TimerDisplay for App<'_> { 81 | fn run(&mut self) -> Result> { 82 | let mut extra_frame = false; 83 | loop { 84 | let duration = Instant::now(); 85 | let timer = self.timer.lock().unwrap(); 86 | if timer.exit { 87 | break; 88 | } 89 | drop(timer); 90 | let mut redraw = false; 91 | match self.surface.handle_events() { 92 | Event::Close => break, 93 | Event::Redraw => redraw = true, 94 | Event::Idle => {} 95 | } 96 | 97 | let timer_running = 98 | self.timer().lock().unwrap().timer().current_phase() == TimerPhase::Running; 99 | if redraw || timer_running || extra_frame { 100 | self.surface.draw(&self.timer); 101 | } 102 | extra_frame = timer_running; 103 | self.display.flush().unwrap(); 104 | let duration: u16 = duration 105 | .elapsed() 106 | .as_millis() 107 | .try_into() 108 | .unwrap_or_default(); 109 | 110 | let sleep = if duration > self.sleep { 111 | Duration::from_millis(0) 112 | } else { 113 | Duration::from_millis((self.sleep - duration).into()) 114 | }; 115 | self.event_loop.dispatch(sleep, &mut ()).unwrap(); 116 | std::thread::sleep(sleep); 117 | } 118 | Ok(true) 119 | } 120 | 121 | fn timer(&self) -> &Arc> { 122 | &self.timer 123 | } 124 | } 125 | 126 | #[derive(PartialEq, Copy, Clone)] 127 | enum RenderEvent { 128 | Configure { width: u32, height: u32 }, 129 | Closed, 130 | } 131 | 132 | #[derive(Debug, Copy, Clone)] 133 | struct RenderProperties { 134 | text_height: usize, 135 | padding_h: usize, 136 | padding_v: usize, 137 | background_color: [u8; 4], 138 | background_opacity: u8, 139 | font_color: [u8; 4], 140 | font_color_gain: [u8; 4], 141 | font_color_loss: [u8; 4], 142 | font_color_gold: [u8; 4], 143 | } 144 | 145 | enum Event { 146 | Close, 147 | Redraw, 148 | Idle, 149 | } 150 | 151 | struct Surface { 152 | surface: wl_surface::WlSurface, 153 | layer_surface: Main, 154 | next_render_event: Rc>>, 155 | pool: AutoMemPool, 156 | dimensions: (u32, u32), 157 | current_scale: i32, 158 | scale_handle: Rc>, 159 | current_split: Option, 160 | font_data: Vec, 161 | render_properties: RenderProperties, 162 | } 163 | 164 | impl Surface { 165 | fn new( 166 | env: &Environment, 167 | output: Option<&wl_output::WlOutput>, 168 | dimensions: (u32, u32), 169 | config: &Config, 170 | ) -> Self { 171 | let pool = env 172 | .create_auto_pool() 173 | .expect("Failed to create memory pool"); 174 | let layer_shell = env.require_global::(); 175 | let scale = Rc::new(Cell::new(1)); 176 | let scale_handle = Rc::clone(&scale); 177 | let surface = env 178 | .create_surface_with_scale_callback(move |dpi, _, _| { 179 | scale.set(dpi); 180 | }) 181 | .detach(); 182 | let layer_surface = layer_shell.get_layer_surface( 183 | &surface, 184 | output, 185 | zwlr_layer_shell_v1::Layer::Overlay, 186 | crate::app_name!().to_owned(), 187 | ); 188 | 189 | layer_surface.set_size(dimensions.0, dimensions.1); 190 | layer_surface.set_margin( 191 | config.margin.0, 192 | config.margin.1, 193 | config.margin.2, 194 | config.margin.3, 195 | ); 196 | // Anchor to the top left corner of the output 197 | let mut anchor = zwlr_layer_surface_v1::Anchor::all(); 198 | anchor.set( 199 | zwlr_layer_surface_v1::Anchor::Top, 200 | config.anchor.contains("top"), 201 | ); 202 | anchor.set( 203 | zwlr_layer_surface_v1::Anchor::Bottom, 204 | config.anchor.contains("bottom"), 205 | ); 206 | anchor.set( 207 | zwlr_layer_surface_v1::Anchor::Left, 208 | config.anchor.contains("left"), 209 | ); 210 | anchor.set( 211 | zwlr_layer_surface_v1::Anchor::Right, 212 | config.anchor.contains("right"), 213 | ); 214 | layer_surface.set_anchor(anchor); 215 | 216 | let next_render_event = Rc::new(Cell::new(None::)); 217 | let next_render_event_handle = Rc::clone(&next_render_event); 218 | layer_surface.quick_assign(move |layer_surface, event, _| { 219 | match (event, next_render_event_handle.get()) { 220 | (zwlr_layer_surface_v1::Event::Closed, _) => { 221 | next_render_event_handle.set(Some(RenderEvent::Closed)); 222 | } 223 | ( 224 | zwlr_layer_surface_v1::Event::Configure { 225 | serial, 226 | width, 227 | height, 228 | }, 229 | next, 230 | ) if next != Some(RenderEvent::Closed) => { 231 | layer_surface.ack_configure(serial); 232 | next_render_event_handle.set(Some(RenderEvent::Configure { width, height })); 233 | } 234 | (_, _) => {} 235 | } 236 | }); 237 | 238 | // Commit so that the server will send a configure event 239 | surface.commit(); 240 | 241 | let family_name = config 242 | .font_family 243 | .clone() 244 | .map_or_else(|| FamilyName::Monospace, FamilyName::Title); 245 | let font = SystemSource::new() 246 | .select_best_match(&[family_name], &Properties::new()) 247 | .unwrap() 248 | .load() 249 | .unwrap(); 250 | let font_data = font.copy_font_data().unwrap().to_vec(); 251 | Self { 252 | surface, 253 | layer_surface, 254 | next_render_event, 255 | pool, 256 | dimensions: (0, 0), 257 | current_scale: 1, 258 | scale_handle, 259 | current_split: None, 260 | font_data, 261 | render_properties: RenderProperties { 262 | text_height: config.text_size, 263 | padding_h: config.padding_h, 264 | padding_v: config.padding_v, 265 | background_color: [ 266 | 255, 267 | config.background_color[0], 268 | config.background_color[1], 269 | config.background_color[2], 270 | ], 271 | background_opacity: config.background_opacity, 272 | font_color: config.font_color, 273 | font_color_gain: config.font_color_gain, 274 | font_color_loss: config.font_color_loss, 275 | font_color_gold: config.font_color_gold, 276 | }, 277 | } 278 | } 279 | 280 | fn handle_events(&mut self) -> Event { 281 | match self.next_render_event.take() { 282 | Some(RenderEvent::Closed) => Event::Close, 283 | Some(RenderEvent::Configure { width, height }) => { 284 | self.dimensions = (width, height); 285 | Event::Redraw 286 | } 287 | None => Event::Idle, 288 | } 289 | } 290 | 291 | fn draw(&mut self, timer: &Arc>) { 292 | let scale = self.scale_handle.get(); 293 | if self.current_scale != scale { 294 | self.current_scale = scale; 295 | self.surface.set_buffer_scale(scale); 296 | println!("Scale set to {}", scale); 297 | // Force full redraw 298 | self.current_split = None; 299 | } 300 | let stride = 4 * self.dimensions.0 as i32 * scale; 301 | let width = self.dimensions.0 as i32 * scale; 302 | let height = self.dimensions.1 as i32 * scale; 303 | 304 | let scale = scale as usize; 305 | let (pixels, buffer) = if let Ok((canvas, buffer)) = 306 | self.pool 307 | .buffer(width, height, stride, wl_shm::Format::Argb8888) 308 | { 309 | (canvas, buffer) 310 | } else { 311 | return; 312 | }; 313 | 314 | let timer = timer.lock().unwrap(); 315 | let mut canvas = andrew::Canvas::new( 316 | pixels, 317 | width as usize, 318 | height as usize, 319 | stride as usize, 320 | andrew::Endian::native(), 321 | ); 322 | let mut damage: Vec = Vec::new(); 323 | match self.current_split { 324 | Some(previous_split) => { 325 | let current_split = if let Some(index) = timer.current_segment_index() { 326 | index 327 | } else { 328 | self.current_split = None; 329 | return; 330 | }; 331 | if previous_split != current_split { 332 | damage.push(Surface::draw_segment_title( 333 | previous_split, 334 | false, 335 | &timer.segments()[previous_split], 336 | &mut canvas, 337 | &self.font_data, 338 | &self.render_properties, 339 | scale, 340 | )); 341 | damage.push(Surface::draw_segment_title( 342 | current_split, 343 | true, 344 | &timer.current_segment().unwrap(), 345 | &mut canvas, 346 | &self.font_data, 347 | &self.render_properties, 348 | scale, 349 | )); 350 | damage.push(Surface::draw_segment_time( 351 | previous_split, 352 | &timer.segments()[previous_split], 353 | false, 354 | &mut canvas, 355 | &self.font_data, 356 | width as usize, 357 | &timer, 358 | &self.render_properties, 359 | scale, 360 | )); 361 | damage.push(Surface::draw_attempts_counter( 362 | timer.run().attempt_count() as usize, 363 | &self.font_data, 364 | &self.render_properties, 365 | width as usize, 366 | &mut canvas, 367 | scale, 368 | )); 369 | let best_segment = timer.get_personal_best_segment_time(previous_split); 370 | let current_segment = timer.get_segment_time(previous_split); 371 | let diff = diff_time( 372 | current_segment.map(|msecs| TimeSpan::from_milliseconds(msecs as f64)), 373 | best_segment.and_then(|segment| segment.real_time), 374 | ); 375 | let mut previous_segment_render_properties = self.render_properties.clone(); 376 | previous_segment_render_properties.font_color = match diff.1 { 377 | SplitColor::Gain => self.render_properties.font_color_gain, 378 | SplitColor::Loss => self.render_properties.font_color_loss, 379 | SplitColor::Gold => self.render_properties.font_color_gold, 380 | }; 381 | damage.push(Surface::draw_additional_info( 382 | &mut canvas, 383 | timer.segments().len() + 3, 384 | &previous_segment_render_properties, 385 | &self.font_data, 386 | width as usize, 387 | "Previous segment", 388 | &diff.0, 389 | scale, 390 | )) 391 | } 392 | damage.push(Surface::draw_segment_time( 393 | current_split, 394 | &timer.current_segment().unwrap(), 395 | true, 396 | &mut canvas, 397 | &self.font_data, 398 | width as usize, 399 | &timer, 400 | &self.render_properties, 401 | scale, 402 | )); 403 | } 404 | None => { 405 | damage.push([0, 0, width as usize, height as usize]); 406 | canvas.clear(); 407 | canvas.draw(&andrew::shapes::rectangle::Rectangle::new( 408 | (0, 0), 409 | (width as usize, height as usize), 410 | None, 411 | Some(self.render_properties.background_color), 412 | )); 413 | let title = format!("{} ({})", timer.game_name(), timer.category_name()); 414 | canvas.draw(&andrew::text::Text::new( 415 | ( 416 | self.render_properties.padding_h * scale, 417 | self.render_properties.padding_v * scale, 418 | ), 419 | self.render_properties.font_color, 420 | &self.font_data, 421 | (self.render_properties.text_height * scale) as f32, 422 | 1.0, 423 | title, 424 | )); 425 | 426 | Surface::draw_attempts_counter( 427 | timer.run().attempt_count() as usize, 428 | &self.font_data, 429 | &self.render_properties, 430 | width as usize, 431 | &mut canvas, 432 | scale, 433 | ); 434 | 435 | for (i, segment) in timer.segments().iter().enumerate() { 436 | let current_segment = timer.current_segment_index().unwrap_or(0); 437 | self.current_split = Some(current_segment); 438 | Surface::draw_segment_title( 439 | i, 440 | i == current_segment, 441 | segment, 442 | &mut canvas, 443 | &self.font_data, 444 | &self.render_properties, 445 | scale, 446 | ); 447 | Surface::draw_segment_time( 448 | i, 449 | segment, 450 | i == current_segment, 451 | &mut canvas, 452 | &self.font_data, 453 | width as usize, 454 | &timer, 455 | &self.render_properties, 456 | scale, 457 | ); 458 | } 459 | 460 | Surface::draw_additional_info( 461 | &mut canvas, 462 | timer.segments().len() + 2, 463 | &self.render_properties, 464 | &self.font_data, 465 | width as usize, 466 | "Sum of best segments", 467 | &TimeFormat::default() 468 | .format_time(timer.best_possible_time().try_into().unwrap(), false), 469 | scale, 470 | ); 471 | } 472 | } 473 | let mut current_time = andrew::text::Text::new( 474 | (0, 0), 475 | self.render_properties.font_color, 476 | &self.font_data, 477 | (self.render_properties.text_height * scale) as f32 * 1.2, 478 | 1.0, 479 | &timer.time().map_or_else( 480 | || "/".to_string(), 481 | |time| { 482 | TimeFormat::default() 483 | .format_time(time.to_duration().num_milliseconds() as u128, false) 484 | }, 485 | ), 486 | ); 487 | let pos = ( 488 | width as usize - current_time.get_width() - self.render_properties.padding_h * scale, 489 | (2 * self.render_properties.padding_v 490 | + ((timer.segments().len() + 1) 491 | * (self.render_properties.text_height + self.render_properties.padding_v))) 492 | * scale, 493 | ); 494 | 495 | canvas.draw(&andrew::shapes::rectangle::Rectangle::new( 496 | pos, 497 | ( 498 | current_time.get_width() + self.render_properties.padding_h, 499 | (self.render_properties.text_height + self.render_properties.padding_v) * scale, 500 | ), 501 | None, 502 | Some(self.render_properties.background_color), 503 | )); 504 | current_time.pos = pos; 505 | canvas.draw(¤t_time); 506 | damage.push([ 507 | current_time.pos.0, 508 | current_time.pos.1, 509 | current_time.get_width() + self.render_properties.padding_h, 510 | (self.render_properties.text_height + self.render_properties.padding_v) * scale, 511 | ]); 512 | self.current_split = timer.current_segment_index(); 513 | drop(timer); 514 | 515 | // Ugly workaround for transparency 516 | for dst_pixel in pixels.chunks_exact_mut(4) { 517 | if dst_pixel[0] == self.render_properties.background_color[1] 518 | && dst_pixel[1] == self.render_properties.background_color[2] 519 | && dst_pixel[2] == self.render_properties.background_color[3] 520 | { 521 | dst_pixel[3] = self.render_properties.background_opacity; 522 | } 523 | } 524 | self.surface.attach(Some(&buffer), 0, 0); 525 | for damage in damage { 526 | self.surface.damage_buffer( 527 | damage[0] as i32, 528 | damage[1] as i32, 529 | damage[2] as i32, 530 | damage[3] as i32, 531 | ); 532 | } 533 | 534 | self.surface.commit(); 535 | } 536 | fn draw_segment_title( 537 | index: usize, 538 | current: bool, 539 | segment: &Segment, 540 | canvas: &mut Canvas, 541 | font_data: &[u8], 542 | render_properties: &RenderProperties, 543 | scale: usize, 544 | ) -> Damage { 545 | let name = format!("> {}", segment.name().to_string()); 546 | let pos = ( 547 | render_properties.padding_h * scale, 548 | (render_properties.padding_v 549 | + ((index + 1) * (render_properties.text_height + render_properties.padding_v))) 550 | * scale, 551 | ); 552 | let mut title = andrew::text::Text::new( 553 | pos, 554 | render_properties.font_color, 555 | &font_data, 556 | (render_properties.text_height * scale) as f32, 557 | 1.0, 558 | &name, 559 | ); 560 | let damage: Damage = [ 561 | title.pos.0, 562 | title.pos.1, 563 | (title.get_width() + render_properties.padding_h) * scale, 564 | (render_properties.text_height + render_properties.padding_v) * scale, 565 | ]; 566 | canvas.draw(&andrew::shapes::rectangle::Rectangle::new( 567 | title.pos, 568 | ( 569 | (title.get_width() + render_properties.padding_h) * scale, 570 | (render_properties.text_height + render_properties.padding_v) * scale, 571 | ), 572 | None, 573 | Some(render_properties.background_color), 574 | )); 575 | 576 | if !current { 577 | title.text = String::from(name.strip_prefix("> ").unwrap()); 578 | } 579 | 580 | canvas.draw(&title); 581 | damage 582 | } 583 | 584 | fn draw_segment_time( 585 | index: usize, 586 | segment: &Segment, 587 | current: bool, 588 | canvas: &mut Canvas, 589 | font_data: &[u8], 590 | width: usize, 591 | timer: &WlSplitTimer, 592 | render_properties: &RenderProperties, 593 | scale: usize, 594 | ) -> Damage { 595 | let timestamp = if let Some(time) = segment.personal_best_split_time().real_time { 596 | Some(time) 597 | } else if segment.segment_history().iter().len() == 0 { 598 | segment.split_time().real_time 599 | } else { 600 | None 601 | }; 602 | let mut time = andrew::text::Text::new( 603 | (0, 0), 604 | render_properties.font_color, 605 | &font_data, 606 | (render_properties.text_height * scale) as f32, 607 | 1.0, 608 | ×tamp.map_or_else( 609 | || "/".to_string(), 610 | |time| { 611 | TimeFormat::default() 612 | .format_time(time.to_duration().num_milliseconds() as u128, false) 613 | }, 614 | ), 615 | ); 616 | time.pos = ( 617 | width as usize - time.get_width() - render_properties.padding_h * scale, 618 | (render_properties.padding_v 619 | + ((index + 1) * (render_properties.text_height + render_properties.padding_v))) 620 | * scale, 621 | ); 622 | 623 | let diff_timestamp = { 624 | let mut diff = diff_time( 625 | if current { 626 | timer.time() 627 | } else { 628 | segment.split_time().real_time 629 | }, 630 | timer.segments()[index].personal_best_split_time().real_time, 631 | ); 632 | let gold = if let (Some(split), Some(pb)) = ( 633 | timer.get_segment_time(index), 634 | timer.segments()[index].best_segment_time().real_time, 635 | ) { 636 | split < pb.to_duration().num_milliseconds().try_into().unwrap() 637 | } else { 638 | false 639 | }; 640 | if !current && gold { 641 | diff.1 = SplitColor::Gold; 642 | } 643 | diff 644 | }; 645 | let mut diff = andrew::text::Text::new( 646 | (0, 0), 647 | match diff_timestamp.1 { 648 | SplitColor::Gain => render_properties.font_color_gain, 649 | SplitColor::Loss => render_properties.font_color_loss, 650 | SplitColor::Gold => render_properties.font_color_gold, 651 | }, 652 | &font_data, 653 | (render_properties.text_height * scale) as f32 * 0.9, 654 | 1.0, 655 | "-:--:--.---", 656 | ); 657 | canvas.draw(&andrew::shapes::rectangle::Rectangle::new( 658 | time.pos, 659 | ( 660 | (time.get_width() + render_properties.padding_h) * scale, 661 | (render_properties.text_height + render_properties.padding_v) * scale, 662 | ), 663 | None, 664 | Some(render_properties.background_color), 665 | )); 666 | let diff_damage_pos = ( 667 | width as usize 668 | - time.get_width() 669 | - diff.get_width() 670 | - render_properties.padding_h * 4 * scale, 671 | (render_properties.padding_v 672 | + ((index + 1) * (render_properties.text_height + render_properties.padding_v)) 673 | + (render_properties.text_height / 20)) 674 | * scale, 675 | ); 676 | canvas.draw(&andrew::shapes::rectangle::Rectangle::new( 677 | diff_damage_pos, 678 | ( 679 | (diff.get_width() + render_properties.padding_h) * scale, 680 | (render_properties.text_height + render_properties.padding_v) * scale, 681 | ), 682 | None, 683 | Some(render_properties.background_color), 684 | )); 685 | let damage: Damage = [ 686 | diff_damage_pos.0, 687 | diff_damage_pos.1, 688 | diff.get_width() + time.get_width() + 6 * render_properties.padding_h * scale, 689 | (render_properties.text_height + render_properties.padding_v) * scale, 690 | ]; 691 | diff.text = diff_timestamp.0; 692 | diff.pos = ( 693 | width as usize 694 | - time.get_width() 695 | - diff.get_width() 696 | - render_properties.padding_h * 4 * scale, 697 | (render_properties.padding_v 698 | + ((index + 1) * (render_properties.text_height + render_properties.padding_v)) 699 | + (render_properties.text_height / 20)) 700 | * scale, 701 | ); 702 | canvas.draw(&time); 703 | canvas.draw(&diff); 704 | 705 | damage 706 | } 707 | 708 | fn draw_attempts_counter( 709 | attempt_count: usize, 710 | font_data: &[u8], 711 | render_properties: &RenderProperties, 712 | width: usize, 713 | canvas: &mut Canvas, 714 | scale: usize, 715 | ) -> Damage { 716 | let mut attempts = andrew::text::Text::new( 717 | (0, 0), 718 | render_properties.font_color, 719 | &font_data, 720 | (render_properties.text_height * scale) as f32, 721 | 1.0, 722 | attempt_count.to_string(), 723 | ); 724 | attempts.pos = ( 725 | (width as usize - attempts.get_width() - render_properties.padding_h) * scale, 726 | render_properties.padding_v * scale, 727 | ); 728 | canvas.draw(&andrew::shapes::rectangle::Rectangle::new( 729 | attempts.pos, 730 | ( 731 | (attempts.get_width() + render_properties.padding_h) * scale, 732 | (render_properties.text_height + render_properties.padding_v) * scale, 733 | ), 734 | None, 735 | Some(render_properties.background_color), 736 | )); 737 | canvas.draw(&attempts); 738 | [ 739 | attempts.pos.0, 740 | attempts.pos.1, 741 | attempts.get_width() + render_properties.padding_h, 742 | render_properties.text_height + render_properties.padding_v, 743 | ] 744 | } 745 | 746 | fn draw_additional_info( 747 | canvas: &mut Canvas, 748 | offset: usize, 749 | render_properties: &RenderProperties, 750 | font_data: &[u8], 751 | width: usize, 752 | text_left: &str, 753 | text_right: &str, 754 | scale: usize, 755 | ) -> Damage { 756 | let text_left = andrew::text::Text::new( 757 | ( 758 | render_properties.padding_h * scale, 759 | (2 * render_properties.padding_v 760 | + ((offset) * (render_properties.text_height + render_properties.padding_v))) 761 | * scale, 762 | ), 763 | render_properties.font_color, 764 | &font_data, 765 | (render_properties.text_height * scale) as f32, 766 | 1.0, 767 | text_left, 768 | ); 769 | let mut text_right = andrew::text::Text::new( 770 | (0, 0), 771 | render_properties.font_color, 772 | &font_data, 773 | (render_properties.text_height * scale) as f32, 774 | 1.0, 775 | text_right, 776 | ); 777 | text_right.pos = ( 778 | width as usize - text_right.get_width() - render_properties.padding_h * scale, 779 | (2 * render_properties.padding_v 780 | + ((offset) * (render_properties.text_height + render_properties.padding_v))) 781 | * scale, 782 | ); 783 | canvas.draw(&andrew::shapes::rectangle::Rectangle::new( 784 | text_left.pos, 785 | ( 786 | text_left.get_width() + render_properties.padding_h * scale, 787 | (render_properties.text_height + render_properties.padding_v) * scale, 788 | ), 789 | None, 790 | Some(render_properties.background_color), 791 | )); 792 | canvas.draw(&andrew::shapes::rectangle::Rectangle::new( 793 | text_right.pos, 794 | ( 795 | text_right.get_width() + render_properties.padding_h * scale, 796 | (render_properties.text_height + render_properties.padding_v) * scale, 797 | ), 798 | None, 799 | Some(render_properties.background_color), 800 | )); 801 | canvas.draw(&text_left); 802 | canvas.draw(&text_right); 803 | [ 804 | text_left.pos.0, 805 | text_right.pos.1, 806 | width as usize, 807 | (render_properties.text_height + render_properties.padding_v) * scale, 808 | ] 809 | } 810 | } 811 | 812 | impl Drop for Surface { 813 | fn drop(&mut self) { 814 | self.layer_surface.destroy(); 815 | self.surface.destroy(); 816 | } 817 | } 818 | 819 | fn diff_time(time: Option, best: Option) -> (String, SplitColor) { 820 | if let (Some(time), Some(best)) = (time, best) { 821 | let time = time.to_duration().num_milliseconds(); 822 | let best = best.to_duration().num_milliseconds(); 823 | let negative = best > time; 824 | let diff = if negative { best - time } else { time - best } as u128; 825 | return ( 826 | TimeFormat::for_diff().format_time(diff, negative), 827 | if negative { 828 | SplitColor::Gain 829 | } else { 830 | SplitColor::Loss 831 | }, 832 | ); 833 | } 834 | ("".to_string(), SplitColor::Loss) 835 | } 836 | 837 | fn get_total_height(len: usize, text_height: usize, padding_v: usize) -> usize { 838 | (len + 5) * (text_height + padding_v) 839 | } 840 | -------------------------------------------------------------------------------- /src/display/terminal/mod.rs: -------------------------------------------------------------------------------- 1 | mod terminal; 2 | 3 | pub use self::terminal::App; -------------------------------------------------------------------------------- /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/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/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/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