Skip to content
146 changes: 100 additions & 46 deletions television/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,10 @@ use crate::{
keymap::Keymap,
render::{RenderingTask, UiState, render},
television::{Mode, Television},
tui::{IoStream, Tui, TuiMode},
};
use anyhow::Result;
use crossterm::{
cursor, event::MouseEventKind, terminal::size as terminal_size,
};
use crossterm::event::MouseEventKind;
use rustc_hash::FxHashSet;
use tokio::sync::mpsc;
use tracing::{debug, error, trace};
Expand Down Expand Up @@ -345,44 +344,46 @@ impl App {
is_output_tty: bool,
headless: bool,
) -> Result<AppOutput> {
// Event loop
if !headless {
debug!("Starting backend event loop");
let event_loop = EventLoop::new(self.options.tick_rate);
self.event_rx = event_loop.rx;
self.event_abort_tx = event_loop.abort_tx;
}

// Rendering loop
if !headless {
debug!("Starting rendering loop");
let (render_tx, render_rx) = mpsc::unbounded_channel();
self.render_tx = render_tx.clone();
let ui_state_tx = self.ui_state_tx.clone();
let action_tx_r = self.action_tx.clone();
let height = if self.options.inline {
// Calculate available space for inline mode
Self::calculate_inline_height()?
let tui_mode = Self::determine_tui_mode(
self.options.height,
self.options.width,
self.options.inline,
)?;
let stream = if is_output_tty {
debug!("Rendering to stdout");
IoStream::Stdout.to_stream()
} else {
self.options.height
debug!("Rendering to stderr");
IoStream::BufferedStderr.to_stream()
};
let width = self.options.width;
let mut tui = Tui::new(stream, &tui_mode)
.expect("Failed to create TUI instance");
debug!("Entering tui");
tui.enter().expect("Failed to enter TUI mode");

self.render_task = Some(tokio::spawn(async move {
render(
render_rx,
action_tx_r,
ui_state_tx,
is_output_tty,
height,
width,
)
.await
render(render_rx, action_tx_r, ui_state_tx, tui).await
}));
self.action_tx
.send(Action::Render)
.expect("Unable to send init render action.");
}

// Event loop
if !headless {
debug!("Starting backend event loop");
let event_loop = EventLoop::new(self.options.tick_rate);
self.event_rx = event_loop.rx;
self.event_abort_tx = event_loop.abort_tx;
}

// Start watch timer if configured
self.start_watch_timer();

Expand All @@ -393,8 +394,10 @@ impl App {
let mut action_buf = Vec::with_capacity(ACTION_BUF_SIZE);
let mut action_outcome;

trace!("Entering main event loop");
loop {
// handle event and convert to action
trace!("Waiting for new events...");
if self
.event_rx
.recv_many(&mut event_buf, EVENT_BUF_SIZE)
Expand All @@ -410,6 +413,7 @@ impl App {
}
}
}
trace!("Event buffer processed, handling actions...");
// It's important that this shouldn't block if no actions are available
action_outcome = self.handle_actions(&mut action_buf).await?;

Expand Down Expand Up @@ -447,7 +451,7 @@ impl App {

// wait for the rendering task to finish
if let Some(rendering_task) = self.render_task.take() {
rendering_task.await??;
rendering_task.await?.expect("Rendering task failed");
}

return Ok(AppOutput::new(action_outcome));
Expand Down Expand Up @@ -535,7 +539,7 @@ impl App {
};

if action != Action::Tick {
trace!("Converted event to action: {action:?}");
trace!("Converted {event:?} to action: {action:?}");
}

if action == Action::NoOp {
Expand Down Expand Up @@ -719,26 +723,76 @@ impl App {
}
}

/// Calculate the height for inline mode.
///
/// This method determines the available space at the bottom of the terminal
// TODO: revisit minimum height if/when input can be toggled
fn calculate_inline_height() -> Result<Option<u16>> {
const MIN_HEIGHT: u16 = 6;

// Get current cursor position and terminal size
let (_, current_row) = cursor::position()?;
let (_, terminal_height) = terminal_size()?;

// Calculate available space from next line to bottom of terminal
let available_space = terminal_height.saturating_sub(current_row + 1);
let ui_height = available_space.max(MIN_HEIGHT);

debug!(
"Inline mode: using {} lines (available: {}, minimum: {})",
ui_height, available_space, MIN_HEIGHT
/// Determine the TUI mode based on the provided options.
fn determine_tui_mode(
height: Option<u16>,
width: Option<u16>,
inline: bool,
) -> Result<TuiMode> {
if inline {
// Inline mode uses all available space at the bottom of the terminal
Ok(TuiMode::Inline)
} else if let Some(h) = height {
// Fixed mode with specified height and width
Ok(TuiMode::Fixed { width, height: h })
} else if width.is_some() {
// error if width is specified without height
Err(anyhow::anyhow!(
"TUI viewport: Width cannot be set without a given height."
))
} else {
// Fullscreen mode
Ok(TuiMode::Fullscreen)
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_determine_tui_mode() {
// Test inline mode
assert_eq!(
App::determine_tui_mode(None, None, true).unwrap(),
TuiMode::Inline,
"Passing `inline = true` should return Inline mode"
);
assert_eq!(
App::determine_tui_mode(Some(0), None, true).unwrap(),
TuiMode::Inline,
"Passing `inline = true` should return Inline mode"
);
assert_eq!(
App::determine_tui_mode(Some(0), Some(0), true).unwrap(),
TuiMode::Inline,
"Passing `inline = true` should return Inline mode"
);

// Test fixed mode
assert_eq!(
App::determine_tui_mode(Some(20), Some(80), false).unwrap(),
TuiMode::Fixed {
width: Some(80),
height: 20
}
);
assert_eq!(
App::determine_tui_mode(Some(20), None, false).unwrap(),
TuiMode::Fixed {
width: None,
height: 20
}
);

// Test fullscreen mode
assert_eq!(
App::determine_tui_mode(None, None, false).unwrap(),
TuiMode::Fullscreen
);

Ok(Some(ui_height))
// Test error case for width without height
assert!(App::determine_tui_mode(None, Some(80), false).is_err());
}
}
4 changes: 2 additions & 2 deletions television/channels/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ impl Entry {
self.display.as_deref().unwrap_or(&self.raw)
}

pub fn stdout_repr(&self, template: &Option<Template>) -> String {
pub fn output(&self, template: &Option<Template>) -> String {
if let Some(template) = template {
return template.format(&self.raw).unwrap_or_else(|_| {
panic!(
Expand Down Expand Up @@ -195,7 +195,7 @@ mod tests {
line_number: None,
};
assert_eq!(
entry.stdout_repr(&Some(Template::parse("{}").unwrap())),
entry.output(&Some(Template::parse("{}").unwrap())),
"test name with spaces"
);
}
Expand Down
4 changes: 2 additions & 2 deletions television/errors.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
use crate::tui::Tui;
use crate::tui::{Tui, TuiMode};
use anyhow::Result;
use std::panic;
use tracing::error;

pub fn init() -> Result<()> {
panic::set_hook(Box::new(move |panic_info| {
// Clean up the terminal
if let Ok(mut t) = Tui::new(std::io::stderr(), None, None) {
if let Ok(mut t) = Tui::new(std::io::stderr(), &TuiMode::Fullscreen) {
if let Err(err) = t.exit() {
error!("Unable to exit terminal: {:?}", err);
}
Expand Down
4 changes: 1 addition & 3 deletions television/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,7 @@ async fn main() -> Result<()> {
writeln!(
bufwriter,
"{}",
entry.stdout_repr(
&app.television.channel.prototype.source.output
)
entry.output(&app.television.channel.prototype.source.output)
)?;
}
}
Expand Down
37 changes: 4 additions & 33 deletions television/render.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::io::Write;

use crate::{
action::Action,
draw::{Ctx, draw},
Expand All @@ -9,7 +11,6 @@ use crossterm::{
execute, queue,
terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate},
};
use std::io::{LineWriter, stderr, stdout};
use tokio::sync::mpsc;
use tracing::{debug, warn};

Expand All @@ -23,21 +24,6 @@ pub enum RenderingTask {
Quit,
}

#[derive(Debug, Clone)]
enum IoStream {
Stdout,
BufferedStderr,
}

impl IoStream {
fn to_stream(&self) -> Box<dyn std::io::Write + Send> {
match self {
IoStream::Stdout => Box::new(stdout()),
IoStream::BufferedStderr => Box::new(LineWriter::new(stderr())),
}
}
}

#[derive(Default)]
/// The state of the UI after rendering.
///
Expand Down Expand Up @@ -72,27 +58,12 @@ const MAX_FRAME_RATE: u128 = 1000 / 60; // 60 FPS
///
/// When starting the rendering loop, a choice is made to either render to stdout or stderr based
/// on if the output is believed to be a TTY or not.
pub async fn render(
pub async fn render<W: Write>(
mut render_rx: mpsc::UnboundedReceiver<RenderingTask>,
action_tx: mpsc::UnboundedSender<Action>,
ui_state_tx: mpsc::UnboundedSender<UiState>,
is_output_tty: bool,
height: Option<u16>,
width: Option<u16>,
mut tui: Tui<W>,
) -> Result<()> {
let stream = if is_output_tty {
debug!("Rendering to stdout");
IoStream::Stdout.to_stream()
} else {
debug!("Rendering to stderr");
IoStream::BufferedStderr.to_stream()
};
let mut tui =
Tui::new(stream, height, width).expect("Failed to create TUI");

debug!("Entering tui");
tui.enter().expect("Failed to enter TUI mode");

let mut buffer = Vec::with_capacity(256);
let mut num_instructions;
let mut frame_start;
Expand Down
Loading