fix(ui): avoid glitches caused by programs outputting control sequences (#579)

Fixes #561
This commit is contained in:
Alex Pasmantier 2025-07-01 15:39:56 +02:00 committed by GitHub
parent 45139457a1
commit dc75e80fb9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 120 additions and 82 deletions

View File

@ -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)),
)

View File

@ -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)

View File

@ -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

View File

@ -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(),

View File

@ -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));
}

View File

@ -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));

View File

@ -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>(

View File

@ -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());
}

View File

@ -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;
}