//! The module which manages all user interface, including inputs. use std::{ fmt::Write, io::{stdout, Stdout}, sync::{ atomic::{AtomicUsize, Ordering}, Arc, }, time::Duration, }; use crate::Args; use crossterm::{ cursor::{Hide, MoveTo, MoveToColumn, MoveUp, Show}, event::{KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags}, style::{Print, Stylize}, terminal::{self, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen}, }; use lazy_static::lazy_static; use tokio::{sync::mpsc::Sender, task, time::sleep}; use super::{Messages, Player}; mod components; mod input; /// Self explanitory. const FPS: usize = 12; /// How long the audio bar will be visible for when audio is adjusted. /// This is in frames. const AUDIO_BAR_DURATION: usize = 10; /// How long to wait in between frames. /// This is fairly arbitrary, but an ideal value should be enough to feel /// snappy but not require too many resources. const FRAME_DELTA: f32 = 1.0 / FPS as f32; lazy_static! { /// The volume timer, which controls how long the volume display should /// show up and when it should disappear. /// /// When this is 0, it means that the audio bar shouldn't be displayed. /// To make it start counting, you need to set it to 1. static ref VOLUME_TIMER: AtomicUsize = AtomicUsize::new(0); } /// Represents an abstraction for drawing the actual lowfi window itself. /// /// The main purpose of this struct is just to add the fancy border, /// as well as clear the screen before drawing. pub struct Window { /// Whether or not to include borders in the output. borderless: bool, /// The top & bottom borders, which are here since they can be /// prerendered, as they don't change from window to window. /// /// If the option to not include borders is set, these will just be empty [String]s. borders: [String; 2], /// The output, currently just an [`Stdout`]. out: Stdout, } impl Window { /// Initializes a new [Window]. /// /// * `width` - Width of the windows. /// * `borderless` - Whether to include borders in the window, or not. pub fn new(width: usize, borderless: bool) -> Self { let borders = if borderless { [String::new(), String::new()] } else { let middle = "─".repeat(width + 2); [format!("┌{middle}┐"), format!("└{middle}┘")] }; Self { borders, borderless, out: stdout(), } } /// Actually draws the window, with each element in `content` being on a new line. pub fn draw(&mut self, content: Vec) -> eyre::Result<()> { let len = content.len() as u16; // Note that this will have a trailing newline, which we use later. let menu: String = content.into_iter().fold(String::new(), |mut output, x| { // Horizontal Padding & Border let padding = if self.borderless { " " } else { "│" }; write!(output, "{padding} {} {padding}\r\n", x.reset()).unwrap(); output }); // We're doing this because Windows is stupid and can't stand // writing to the last line repeatedly. #[cfg(windows)] let (height, suffix) = (len + 2, "\r\n"); #[cfg(not(windows))] let (height, suffix) = (len + 1, ""); // There's no need for another newline after the main menu content, because it already has one. let rendered = format!("{}\r\n{menu}{}{suffix}", self.borders[0], self.borders[1]); crossterm::execute!( self.out, Clear(ClearType::FromCursorDown), MoveToColumn(0), Print(rendered), MoveToColumn(0), MoveUp(height), )?; Ok(()) } } /// The code for the terminal interface itself. /// /// * `minimalist` - All this does is hide the bottom control bar. /// * `borderless` - Whether to include borders or not. /// * `width` - The width of player async fn interface( player: Arc, minimalist: bool, borderless: bool, width: usize, ) -> eyre::Result<()> { let mut window = Window::new(width, borderless); loop { // Load `current` once so that it doesn't have to be loaded over and over // again by different UI components. let current = player.current.load(); let current = current.as_ref(); let action = components::action(&player, current, width); let volume = player.sink.volume(); let percentage = format!("{}%", (volume * 100.0).round().abs()); let timer = VOLUME_TIMER.load(Ordering::Relaxed); let middle = match timer { 0 => components::progress_bar(&player, current, width - 16), _ => components::audio_bar(volume, &percentage, width - 17), }; if timer > 0 && timer <= AUDIO_BAR_DURATION { // We'll keep increasing the timer until it eventually hits `AUDIO_BAR_DURATION`. VOLUME_TIMER.fetch_add(1, Ordering::Relaxed); } else { // If enough time has passed, we'll reset it back to 0. VOLUME_TIMER.store(0, Ordering::Relaxed); } let controls = components::controls(width); let menu = if minimalist { vec![action, middle] } else { vec![action, middle, controls] }; window.draw(menu)?; sleep(Duration::from_secs_f32(FRAME_DELTA)).await; } } /// Represents the terminal environment, and is used to properly /// initialize and clean up the terminal. pub struct Environment { /// Whether keyboard enhancements are enabled. enhancement: bool, /// Whether the terminal is in an alternate screen or not. alternate: bool, } impl Environment { /// This prepares the terminal, returning an [Environment] helpful /// for cleaning up afterwards. pub fn ready(alternate: bool) -> eyre::Result { let mut lock = stdout().lock(); crossterm::execute!(lock, Hide)?; if alternate { crossterm::execute!(lock, EnterAlternateScreen, MoveTo(0, 0))?; } terminal::enable_raw_mode()?; let enhancement = terminal::supports_keyboard_enhancement()?; if enhancement { crossterm::execute!( lock, PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) )?; } Ok(Self { enhancement, alternate, }) } /// Uses the information collected from initialization to safely close down /// the terminal & restore it to it's previous state. pub fn cleanup(&self) -> eyre::Result<()> { let mut lock = stdout().lock(); if self.alternate { crossterm::execute!(lock, LeaveAlternateScreen)?; } crossterm::execute!(lock, Clear(ClearType::FromCursorDown), Show)?; if self.enhancement { crossterm::execute!(lock, PopKeyboardEnhancementFlags)?; } terminal::disable_raw_mode()?; eprintln!("bye! :)"); Ok(()) } } impl Drop for Environment { /// Just a wrapper for [`Environment::cleanup`] which ignores any errors thrown. fn drop(&mut self) { // Well, we're dropping it, so it doesn't really matter if there's an error. let _ = self.cleanup(); } } /// Initializes the UI, this will also start taking input from the user. /// /// `alternate` controls whether to use [`EnterAlternateScreen`] in order to hide /// previous terminal history. pub async fn start(player: Arc, sender: Sender, args: Args) -> eyre::Result<()> { let environment = Environment::ready(args.alternate)?; let interface = task::spawn(interface( Arc::clone(&player), args.minimalist, args.borderless, 21 + args.width.min(32) * 2, )); input::listen(sender.clone()).await?; interface.abort(); environment.cleanup()?; Ok(()) }