mirror of
https://github.com/talwat/lowfi
synced 2025-05-10 22:32:19 +00:00
267 lines
8.0 KiB
Rust
267 lines
8.0 KiB
Rust
//! 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<String>) -> 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<Player>,
|
|
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<Self> {
|
|
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<Player>, sender: Sender<Messages>, 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(())
|
|
}
|