mirror of
https://github.com/alexpasmantier/television.git
synced 2025-07-29 14:21:43 +00:00
fix(ui): avoid glitches caused by programs outputting control sequences (#579)
Fixes #561
This commit is contained in:
parent
45139457a1
commit
dc75e80fb9
@ -468,7 +468,7 @@ pub fn draw(c: &mut Criterion) {
|
||||
// Measurement
|
||||
|(tv, mut terminal)| async move {
|
||||
television::draw::draw(
|
||||
black_box(&tv.dump_context()),
|
||||
black_box(Box::new(tv.dump_context())),
|
||||
black_box(&mut terminal.get_frame()),
|
||||
black_box(Rect::new(0, 0, width, height)),
|
||||
)
|
||||
|
@ -171,7 +171,7 @@ impl UiComponent for StatusBarComponent<'_> {
|
||||
/// A `Result` containing the layout of the current frame if the drawing was successful.
|
||||
/// This layout can then be sent back to the main thread to serve for tasks where having that
|
||||
/// information can be useful or lead to optimizations.
|
||||
pub fn draw(ctx: &Ctx, f: &mut Frame<'_>, area: Rect) -> Result<Layout> {
|
||||
pub fn draw(ctx: Box<Ctx>, f: &mut Frame<'_>, area: Rect) -> Result<Layout> {
|
||||
let show_remote = matches!(ctx.tv_state.mode, Mode::RemoteControl);
|
||||
|
||||
let layout = Layout::build(
|
||||
@ -212,11 +212,17 @@ pub fn draw(ctx: &Ctx, f: &mut Frame<'_>, area: Rect) -> Result<Layout> {
|
||||
&ctx.config.ui.input_bar_position,
|
||||
)?;
|
||||
|
||||
// status bar at the bottom
|
||||
if let Some(status_bar_area) = layout.status_bar {
|
||||
let status_component = StatusBarComponent::new(&ctx);
|
||||
status_component.draw(f, status_bar_area);
|
||||
}
|
||||
|
||||
if let Some(preview_rect) = layout.preview_window {
|
||||
draw_preview_content_block(
|
||||
f,
|
||||
preview_rect,
|
||||
&ctx.tv_state.preview_state,
|
||||
ctx.tv_state.preview_state,
|
||||
ctx.config.ui.use_nerd_font_icons,
|
||||
&ctx.colorscheme,
|
||||
ctx.config.ui.preview_panel.scrollbar,
|
||||
@ -239,13 +245,13 @@ pub fn draw(ctx: &Ctx, f: &mut Frame<'_>, area: Rect) -> Result<Layout> {
|
||||
|
||||
// floating help panel (rendered last to appear on top)
|
||||
if let Some(help_area) = layout.help_panel {
|
||||
draw_help_panel(f, help_area, ctx);
|
||||
}
|
||||
|
||||
// status bar at the bottom
|
||||
if let Some(status_bar_area) = layout.status_bar {
|
||||
let status_component = StatusBarComponent::new(ctx);
|
||||
status_component.draw(f, status_bar_area);
|
||||
draw_help_panel(
|
||||
f,
|
||||
help_area,
|
||||
&ctx.config.keybindings,
|
||||
ctx.tv_state.mode,
|
||||
&ctx.colorscheme,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(layout)
|
||||
|
@ -3,8 +3,10 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ansi_to_tui::IntoText;
|
||||
use anyhow::{Context, Result};
|
||||
use devicons::FileIcon;
|
||||
use ratatui::text::Text;
|
||||
use tokio::{
|
||||
sync::mpsc::{UnboundedReceiver, UnboundedSender},
|
||||
time::timeout,
|
||||
@ -18,7 +20,9 @@ use crate::{
|
||||
},
|
||||
utils::{
|
||||
command::shell_command,
|
||||
strings::{ReplaceNonPrintableConfig, replace_non_printable},
|
||||
strings::{
|
||||
EMPTY_STRING, ReplaceNonPrintableConfig, replace_non_printable,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -99,7 +103,9 @@ impl Ticket {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Preview {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
// NOTE: this does couple the previewer with ratatui but allows
|
||||
// to only parse ansi text once and reuse it in the UI.
|
||||
pub content: Text<'static>,
|
||||
pub icon: Option<FileIcon>,
|
||||
pub total_lines: u16,
|
||||
pub footer: String,
|
||||
@ -111,7 +117,7 @@ impl Default for Preview {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
title: DEFAULT_PREVIEW_TITLE.to_string(),
|
||||
content: String::new(),
|
||||
content: Text::from(EMPTY_STRING),
|
||||
icon: None,
|
||||
total_lines: 1,
|
||||
footer: String::new(),
|
||||
@ -122,7 +128,7 @@ impl Default for Preview {
|
||||
impl Preview {
|
||||
fn new(
|
||||
title: &str,
|
||||
content: String,
|
||||
content: Text<'static>,
|
||||
icon: Option<FileIcon>,
|
||||
total_lines: u16,
|
||||
footer: String,
|
||||
@ -238,33 +244,47 @@ pub fn try_preview(
|
||||
|
||||
let preview: Preview = {
|
||||
if child.status.success() {
|
||||
let (content, _) = replace_non_printable(
|
||||
&child.stdout,
|
||||
ReplaceNonPrintableConfig::default()
|
||||
.keep_line_feed()
|
||||
.keep_control_characters(),
|
||||
);
|
||||
Preview::new(
|
||||
&entry.raw,
|
||||
content.to_string(),
|
||||
None,
|
||||
u16::try_from(content.lines().count()).unwrap_or(u16::MAX),
|
||||
String::new(),
|
||||
)
|
||||
let mut text = child
|
||||
.stdout
|
||||
.into_text()
|
||||
.unwrap_or_else(|_| Text::from(EMPTY_STRING));
|
||||
|
||||
text.lines.iter_mut().for_each(|line| {
|
||||
// replace non-printable characters
|
||||
line.spans.iter_mut().for_each(|span| {
|
||||
span.content = replace_non_printable(
|
||||
&span.content.bytes().collect::<Vec<_>>(),
|
||||
&ReplaceNonPrintableConfig::default(),
|
||||
)
|
||||
.0
|
||||
.into();
|
||||
});
|
||||
});
|
||||
|
||||
let total_lines = u16::try_from(text.lines.len()).unwrap_or(0);
|
||||
|
||||
Preview::new(&entry.raw, text, None, total_lines, String::new())
|
||||
} else {
|
||||
let (content, _) = replace_non_printable(
|
||||
&child.stderr,
|
||||
ReplaceNonPrintableConfig::default()
|
||||
.keep_line_feed()
|
||||
.keep_control_characters(),
|
||||
);
|
||||
Preview::new(
|
||||
&entry.raw,
|
||||
content.to_string(),
|
||||
None,
|
||||
u16::try_from(content.lines().count()).unwrap_or(u16::MAX),
|
||||
String::new(),
|
||||
)
|
||||
let mut text = child
|
||||
.stderr
|
||||
.into_text()
|
||||
.unwrap_or_else(|_| Text::from(EMPTY_STRING));
|
||||
|
||||
text.lines.iter_mut().for_each(|line| {
|
||||
// replace non-printable characters
|
||||
line.spans.iter_mut().for_each(|span| {
|
||||
span.content = replace_non_printable(
|
||||
&span.content.bytes().collect::<Vec<_>>(),
|
||||
&ReplaceNonPrintableConfig::default(),
|
||||
)
|
||||
.0
|
||||
.into();
|
||||
});
|
||||
});
|
||||
|
||||
let total_lines = u16::try_from(text.lines.len()).unwrap_or(0);
|
||||
|
||||
Preview::new(&entry.raw, text, None, total_lines, String::new())
|
||||
}
|
||||
};
|
||||
results_handle
|
||||
|
@ -1,3 +1,5 @@
|
||||
use ratatui::text::Text;
|
||||
|
||||
use crate::previewer::Preview;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@ -10,7 +12,7 @@ pub struct PreviewState {
|
||||
|
||||
const PREVIEW_MIN_SCROLL_LINES: u16 = 3;
|
||||
pub const ANSI_BEFORE_CONTEXT_SIZE: u16 = 3;
|
||||
const ANSI_CONTEXT_SIZE: usize = 500;
|
||||
const ANSI_CONTEXT_SIZE: u16 = 500;
|
||||
|
||||
impl PreviewState {
|
||||
pub fn new(
|
||||
@ -65,20 +67,21 @@ impl PreviewState {
|
||||
pub fn for_render_context(&self) -> Self {
|
||||
let num_skipped_lines =
|
||||
self.scroll.saturating_sub(ANSI_BEFORE_CONTEXT_SIZE);
|
||||
|
||||
let cropped_content = self
|
||||
.preview
|
||||
.content
|
||||
.lines()
|
||||
.lines
|
||||
.iter()
|
||||
.skip(num_skipped_lines as usize)
|
||||
.take(ANSI_CONTEXT_SIZE)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
.take(ANSI_CONTEXT_SIZE as usize)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let target_line: Option<u16> =
|
||||
if let Some(target_line) = self.target_line {
|
||||
if num_skipped_lines < target_line
|
||||
&& (target_line - num_skipped_lines)
|
||||
<= u16::try_from(ANSI_CONTEXT_SIZE).unwrap()
|
||||
&& (target_line - num_skipped_lines) <= ANSI_CONTEXT_SIZE
|
||||
{
|
||||
Some(target_line.saturating_sub(num_skipped_lines))
|
||||
} else {
|
||||
@ -92,7 +95,7 @@ impl PreviewState {
|
||||
self.enabled,
|
||||
Preview::new(
|
||||
&self.preview.title,
|
||||
cropped_content,
|
||||
Text::from(cropped_content),
|
||||
self.preview.icon,
|
||||
self.preview.total_lines,
|
||||
self.preview.footer.clone(),
|
||||
|
@ -128,9 +128,10 @@ pub async fn render(
|
||||
if size.width.checked_mul(size.height).is_some() {
|
||||
queue!(stderr(), BeginSynchronizedUpdate).ok();
|
||||
tui.terminal.draw(|frame| {
|
||||
match draw(&context, frame, frame.area()) {
|
||||
let current_layout = context.layout;
|
||||
match draw(context, frame, frame.area()) {
|
||||
Ok(layout) => {
|
||||
if layout != context.layout {
|
||||
if layout != current_layout {
|
||||
let _ = ui_state_tx
|
||||
.send(UiState::new(layout));
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
use crate::{
|
||||
config::KeyBindings,
|
||||
draw::Ctx,
|
||||
screen::colors::Colorscheme,
|
||||
screen::keybindings::{ActionMapping, extract_keys_from_binding},
|
||||
television::Mode,
|
||||
@ -17,17 +16,19 @@ const MIN_PANEL_WIDTH: u16 = 25;
|
||||
const MIN_PANEL_HEIGHT: u16 = 5;
|
||||
|
||||
/// Draws a Helix-style floating help panel in the bottom-right corner
|
||||
pub fn draw_help_panel(f: &mut Frame<'_>, area: Rect, ctx: &Ctx) {
|
||||
pub fn draw_help_panel(
|
||||
f: &mut Frame<'_>,
|
||||
area: Rect,
|
||||
keybindings: &KeyBindings,
|
||||
tv_mode: Mode,
|
||||
colorscheme: &Colorscheme,
|
||||
) {
|
||||
if area.width < MIN_PANEL_WIDTH || area.height < MIN_PANEL_HEIGHT {
|
||||
return; // Too small to display anything meaningful
|
||||
}
|
||||
|
||||
// Generate content
|
||||
let content = generate_help_content(
|
||||
&ctx.config.keybindings,
|
||||
ctx.tv_state.mode,
|
||||
&ctx.colorscheme,
|
||||
);
|
||||
let content = generate_help_content(keybindings, tv_mode, colorscheme);
|
||||
|
||||
// Clear the area first to create the floating effect
|
||||
f.render_widget(Clear, area);
|
||||
@ -36,14 +37,11 @@ pub fn draw_help_panel(f: &mut Frame<'_>, area: Rect, ctx: &Ctx) {
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(ctx.colorscheme.general.border_fg))
|
||||
.border_style(Style::default().fg(colorscheme.general.border_fg))
|
||||
.title_top(Line::from(" Help ").alignment(Alignment::Center))
|
||||
.style(
|
||||
Style::default().bg(ctx
|
||||
.colorscheme
|
||||
.general
|
||||
.background
|
||||
.unwrap_or_default()),
|
||||
Style::default()
|
||||
.bg(colorscheme.general.background.unwrap_or_default()),
|
||||
)
|
||||
.padding(Padding::horizontal(1));
|
||||
|
||||
|
@ -6,7 +6,6 @@ use crate::{
|
||||
shrink_with_ellipsis,
|
||||
},
|
||||
};
|
||||
use ansi_to_tui::IntoText;
|
||||
use anyhow::Result;
|
||||
use devicons::FileIcon;
|
||||
use ratatui::{
|
||||
@ -14,7 +13,7 @@ use ratatui::{
|
||||
layout::{Alignment, Rect},
|
||||
prelude::{Color, Line, Span, Style, Stylize, Text},
|
||||
widgets::{
|
||||
Block, BorderType, Borders, Padding, Paragraph, Scrollbar,
|
||||
Block, BorderType, Borders, Clear, Padding, Paragraph, Scrollbar,
|
||||
ScrollbarOrientation, ScrollbarState, StatefulWidget,
|
||||
},
|
||||
};
|
||||
@ -24,7 +23,7 @@ use std::str::FromStr;
|
||||
pub fn draw_preview_content_block(
|
||||
f: &mut Frame,
|
||||
rect: Rect,
|
||||
preview_state: &PreviewState,
|
||||
preview_state: PreviewState,
|
||||
use_nerd_font_icons: bool,
|
||||
colorscheme: &Colorscheme,
|
||||
scrollbar_enabled: bool,
|
||||
@ -38,11 +37,16 @@ pub fn draw_preview_content_block(
|
||||
&preview_state.preview.footer,
|
||||
use_nerd_font_icons,
|
||||
)?;
|
||||
let scroll = preview_state.scroll as usize;
|
||||
let total_lines =
|
||||
preview_state.preview.total_lines.saturating_sub(1) as usize;
|
||||
|
||||
// render the preview content
|
||||
let rp = build_preview_paragraph(
|
||||
preview_state,
|
||||
colorscheme.preview.highlight_bg,
|
||||
);
|
||||
f.render_widget(Clear, inner);
|
||||
f.render_widget(rp, inner);
|
||||
|
||||
// render scrollbar if enabled
|
||||
@ -50,10 +54,8 @@ pub fn draw_preview_content_block(
|
||||
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
.style(Style::default().fg(colorscheme.general.border_fg));
|
||||
|
||||
let mut scrollbar_state = ScrollbarState::new(
|
||||
preview_state.preview.total_lines.saturating_sub(1) as usize,
|
||||
)
|
||||
.position(preview_state.scroll as usize);
|
||||
let mut scrollbar_state =
|
||||
ScrollbarState::new(total_lines).position(scroll);
|
||||
|
||||
// Create a separate area for the scrollbar that accounts for text padding
|
||||
let scrollbar_rect = Rect {
|
||||
@ -70,9 +72,9 @@ pub fn draw_preview_content_block(
|
||||
}
|
||||
|
||||
pub fn build_preview_paragraph(
|
||||
preview_state: &PreviewState,
|
||||
preview_state: PreviewState,
|
||||
highlight_bg: Color,
|
||||
) -> Paragraph<'_> {
|
||||
) -> Paragraph<'static> {
|
||||
let preview_block =
|
||||
Block::default().style(Style::default()).padding(Padding {
|
||||
top: 0,
|
||||
@ -82,7 +84,7 @@ pub fn build_preview_paragraph(
|
||||
});
|
||||
|
||||
build_ansi_text_paragraph(
|
||||
&preview_state.preview.content,
|
||||
preview_state.preview.content,
|
||||
preview_block,
|
||||
preview_state.target_line,
|
||||
highlight_bg,
|
||||
@ -90,21 +92,20 @@ pub fn build_preview_paragraph(
|
||||
}
|
||||
|
||||
fn build_ansi_text_paragraph<'a>(
|
||||
text: &'a str,
|
||||
mut text: Text<'a>,
|
||||
preview_block: Block<'a>,
|
||||
target_line: Option<u16>,
|
||||
highlight_bg: Color,
|
||||
) -> Paragraph<'a> {
|
||||
let mut t = text.into_text().unwrap();
|
||||
if let Some(target_line) = target_line {
|
||||
// Highlight the target line
|
||||
if let Some(line) = t.lines.get_mut((target_line - 1) as usize) {
|
||||
if let Some(line) = text.lines.get_mut((target_line - 1) as usize) {
|
||||
for span in &mut line.spans {
|
||||
span.style = span.style.bg(highlight_bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
Paragraph::new(t).block(preview_block)
|
||||
Paragraph::new(text).block(preview_block)
|
||||
}
|
||||
|
||||
pub fn build_meta_preview_paragraph<'a>(
|
||||
|
@ -555,16 +555,16 @@ impl Television {
|
||||
// available previews
|
||||
let entry = selected_entry.as_ref().unwrap();
|
||||
if let Ok(mut preview) = receiver.try_recv() {
|
||||
if let Some(tpl) = &self.config.ui.preview_panel.header {
|
||||
preview.title = tpl
|
||||
if let Some(template) = &self.config.ui.preview_panel.header {
|
||||
preview.title = template
|
||||
.format(&entry.raw)
|
||||
.unwrap_or_else(|_| entry.raw.clone());
|
||||
} else {
|
||||
preview.title.clone_from(&entry.raw);
|
||||
}
|
||||
|
||||
if let Some(ftpl) = &self.config.ui.preview_panel.footer {
|
||||
preview.footer = ftpl
|
||||
if let Some(template) = &self.config.ui.preview_panel.footer {
|
||||
preview.footer = template
|
||||
.format(&entry.raw)
|
||||
.unwrap_or_else(|_| String::new());
|
||||
}
|
||||
|
@ -158,6 +158,7 @@ pub const TAB_WIDTH: usize = 4;
|
||||
const NULL_SYMBOL: char = '\u{2400}';
|
||||
const TAB_CHARACTER: char = '\t';
|
||||
const LINE_FEED_CHARACTER: char = '\x0A';
|
||||
const CARRIAGE_RETURN_CHARACTER: char = '\r';
|
||||
const DELETE_CHARACTER: char = '\x7F';
|
||||
const BOM_CHARACTER: char = '\u{FEFF}';
|
||||
const NULL_CHARACTER: char = '\x00';
|
||||
@ -302,7 +303,15 @@ pub fn replace_non_printable(
|
||||
i16::try_from(config.tab_width).unwrap() - 1;
|
||||
}
|
||||
// line feed
|
||||
LINE_FEED_CHARACTER if config.replace_line_feed => {
|
||||
LINE_FEED_CHARACTER | CARRIAGE_RETURN_CHARACTER
|
||||
if config.replace_line_feed =>
|
||||
{
|
||||
cumulative_offset -= 1;
|
||||
}
|
||||
|
||||
// Carriage return
|
||||
'\r' if config.replace_line_feed => {
|
||||
// Do not add to output, just adjust offset
|
||||
cumulative_offset -= 1;
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user