use std::cell::RefCell;
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::app::copy_feedback::CopyFeedbackRange;
use crate::backend::ratatui_backend::common::{
CursorPlacement, DEFAULT_SCROLLBAR_THUMB, IntegratedScrollbarAppearance, ScrollbarAppearance,
ScrollbarScrollState, calculate_visible_borders, integrated_vscrollbar_track_char,
is_cursor_visible, render_integrated_scrollbar, render_vscrollbar,
resolve_scrollbar_thumb_style, style_paints_bg, to_ratatui_border_set, to_ratatui_border_type,
to_ratatui_rect, to_ratatui_span, to_ratatui_style,
};
use crate::backend::ratatui_backend::render::{
FrameIntegratedVTrack, RenderState, ancestor_frame_integrated_vtrack,
apply_copy_feedback_to_selection_style,
};
use crate::core::node::NodeId;
use crate::style::resolve::{
resolve_base_style, resolve_focus_style_defaults, resolve_scrollbar_theme,
};
use crate::style::{Rect, ScrollbarVariant, Span, Style, Theme, ThemeRole, resolve_slot};
use crate::utils::GridSelection;
use crate::utils::scrollbar::ScrollbarMetricsCache;
#[cfg(feature = "terminal-images")]
use crate::widgets::TerminalImageCrop;
use crate::widgets::internal::terminal_content_layout;
fn clip_spans_no_ellipsis<'a>(
spans: Vec<ratatui::text::Span<'a>>,
max_width: u16,
) -> Vec<ratatui::text::Span<'a>> {
if max_width == 0 {
return Vec::new();
}
let mut out = Vec::new();
let mut used = 0u16;
for span in spans {
if used >= max_width {
break;
}
let mut chunk = String::new();
for grapheme in span.content.graphemes(true) {
let w = if grapheme.chars().all(char::is_control) {
0
} else {
UnicodeWidthStr::width(grapheme).min(u16::MAX as usize) as u16
};
if w == 0 {
chunk.push_str(grapheme);
continue;
}
if used.saturating_add(w) > max_width {
break;
}
chunk.push_str(grapheme);
used = used.saturating_add(w);
}
if !chunk.is_empty() {
out.push(ratatui::text::Span::styled(chunk, span.style));
}
}
out
}
#[cfg(feature = "terminal-images")]
fn render_terminal_images(
f: &mut ratatui::Frame<'_>,
node: &crate::widgets::internal::TerminalNode,
content_rect: Rect,
effective: ratatui::layout::Rect,
) {
use std::sync::Arc;
use crate::backend::ratatui_backend::renderers::image::draw_encoded_image;
let clip_left = i32::from(effective.x);
let clip_top = i32::from(effective.y);
let clip_right = clip_left + i32::from(effective.width);
let clip_bottom = clip_top + i32::from(effective.height);
for placement in node.images.iter() {
let left = i32::from(content_rect.x) + placement.col;
let top = i32::from(content_rect.y) + placement.row;
let cols = i32::from(placement.cols);
let rows = i32::from(placement.rows);
if cols <= 0 || rows <= 0 {
continue;
}
let vis_left = left.max(clip_left);
let vis_top = top.max(clip_top);
let vis_right = (left + cols).min(clip_right);
let vis_bottom = (top + rows).min(clip_bottom);
if vis_right <= vis_left || vis_bottom <= vis_top {
continue;
}
let pixels = placement.image.pixels();
let source = placement.source_crop.unwrap_or(TerminalImageCrop {
x: 0,
y: 0,
width: pixels.width(),
height: pixels.height(),
});
let Some(crop) = crop_for_visible_cells(
source,
(cols as u32, rows as u32),
(
(vis_left - left) as u32,
(vis_top - top) as u32,
(vis_right - vis_left) as u32,
(vis_bottom - vis_top) as u32,
),
) else {
continue;
};
let whole = crop.x == 0
&& crop.y == 0
&& crop.width == pixels.width()
&& crop.height == pixels.height();
let area = ratatui::layout::Rect {
x: vis_left as u16,
y: vis_top as u16,
width: (vis_right - vis_left) as u16,
height: (vis_bottom - vis_top) as u16,
};
draw_encoded_image(
f,
area,
placement_source_hash(placement.image_id, placement.image.source_hash(), crop),
|| {
if whole {
Arc::clone(pixels)
} else {
Arc::new(pixels.crop_imm(crop.x, crop.y, crop.width, crop.height))
}
},
);
}
}
#[cfg(feature = "terminal-images")]
fn crop_for_visible_cells(
source: TerminalImageCrop,
placement_cells: (u32, u32),
visible_cells: (u32, u32, u32, u32),
) -> Option<TerminalImageCrop> {
let (cols, rows) = placement_cells;
let (skip_x, skip_y, keep_w, keep_h) = visible_cells;
if source.width == 0 || source.height == 0 || cols == 0 || rows == 0 {
return None;
}
let x = source.x + skip_x * source.width / cols;
let y = source.y + skip_y * source.height / rows;
let width = (keep_w * source.width)
.div_ceil(cols)
.min(source.x + source.width - x);
let height = (keep_h * source.height)
.div_ceil(rows)
.min(source.y + source.height - y);
(width > 0 && height > 0).then_some(TerminalImageCrop {
x,
y,
width,
height,
})
}
#[cfg(feature = "terminal-images")]
fn placement_source_hash(image_id: u32, source_hash: u64, crop: TerminalImageCrop) -> u64 {
use std::hash::{Hash as _, Hasher as _};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
image_id.hash(&mut hasher);
source_hash.hash(&mut hasher);
(crop.x, crop.y, crop.width, crop.height).hash(&mut hasher);
hasher.finish()
}
pub(crate) struct TerminalRenderCtx<'a> {
pub is_focused: bool,
pub is_hovered: bool,
pub blink_visible: bool,
pub clip_rect: Option<Rect>,
pub caret: CursorPlacement<'a>,
pub parent_integrated_v: Option<FrameIntegratedVTrack>,
pub metrics_cache: Option<&'a RefCell<ScrollbarMetricsCache>>,
pub node_theme: &'a Theme,
pub selection_style: Style,
pub flash_range: Option<CopyFeedbackRange>,
}
pub(crate) fn render_terminal(
f: &mut ratatui::Frame<'_>,
node: &crate::widgets::internal::TerminalNode,
rect: Rect,
rrect: ratatui::layout::Rect,
ctx: TerminalRenderCtx<'_>,
) {
let TerminalRenderCtx {
is_focused,
is_hovered,
blink_visible,
clip_rect,
caret,
parent_integrated_v,
metrics_cache,
node_theme,
selection_style,
flash_range,
} = ctx;
let theme = node_theme;
let style = resolve_base_style(theme, node.style);
let hover_style = resolve_slot(theme, ThemeRole::Hover, &node.hover_style);
let focus_style = resolve_slot(theme, ThemeRole::Focus, &node.focus_style);
let focus_content_style =
resolve_focus_style_defaults(theme, node.focus_content_style, theme.terminal.focus);
let mut content_style = style;
let mut chrome_style = style;
if is_hovered {
content_style = content_style.patch(hover_style);
chrome_style = chrome_style.patch(hover_style);
}
if is_focused {
chrome_style = chrome_style.patch(focus_style);
content_style = content_style.patch(focus_content_style);
}
let mut inner = rect;
if node.border {
let borders = calculate_visible_borders(rect, clip_rect);
let mut block = Block::default()
.borders(borders)
.border_type(to_ratatui_border_type(node.border_style))
.style(to_ratatui_style(chrome_style));
if let Some(set) = to_ratatui_border_set(node.border_style) {
block = block.border_set(set);
}
if style_paints_bg(chrome_style) {
f.render_widget(Clear, rrect);
}
f.render_widget(block, rrect);
if borders.contains(Borders::LEFT) {
inner.x = inner.x.saturating_add(1);
inner.w = inner.w.saturating_sub(1);
}
if borders.contains(Borders::RIGHT) {
inner.w = inner.w.saturating_sub(1);
}
if borders.contains(Borders::TOP) {
inner.y = inner.y.saturating_add(1);
inner.h = inner.h.saturating_sub(1);
}
if borders.contains(Borders::BOTTOM) {
inner.h = inner.h.saturating_sub(1);
}
} else if style_paints_bg(chrome_style) {
f.render_widget(Clear, rrect);
f.render_widget(
Block::default().style(to_ratatui_style(chrome_style)),
rrect,
);
}
inner = inner.inset(node.padding);
if inner.w == 0 || inner.h == 0 {
return;
}
let layout = terminal_content_layout(
inner,
node.border,
node.scrollbar,
node.scrollbar_variant,
node.scrollbar_gap,
node.total_scrollback_rows,
parent_integrated_v.is_some(),
);
let content_rect = layout.content_rect;
let scrollbar = layout.scrollbar_visible;
let use_integrated = layout.scrollbar_integrated;
let content_w = content_rect.w;
let content_rrect = to_ratatui_rect(content_rect);
let effective = content_rrect.intersection(rrect);
let projected = node.selection.as_ref().and_then(|sel| {
crate::widgets::to_viewport(
sel,
node.scrollback_offset,
node.total_scrollback_rows,
content_rect.h as usize,
)
});
let mut rendered_content = false;
if content_rect.w > 0 && content_rect.h > 0 && effective.width > 0 && effective.height > 0 {
let dx = (effective.x as i32)
.saturating_sub(content_rect.x as i32)
.max(0) as u16;
let dy = (effective.y as i32)
.saturating_sub(content_rect.y as i32)
.max(0) as u16;
let flash_selection = flash_range.as_ref().map(|range| range.selection.clone());
let paint_selection = match &flash_selection {
Some(_) => &flash_selection,
None => &projected,
};
let lines: Vec<Line<'_>> = (0..content_rect.h as usize)
.map(|row| {
let source = match flash_range.as_ref() {
Some(range) if range.selection.columns_for_row(row, 0).is_some() => {
range.line(row)
}
_ => node.lines.get(row).map(Vec::as_slice),
};
let mut spans: Vec<ratatui::text::Span<'_>> = source
.map(|line| {
apply_selection_to_row(
line,
row,
paint_selection,
selection_style,
content_style,
)
})
.unwrap_or_default();
if spans.is_empty() {
spans.push(ratatui::text::Span::styled(
"",
to_ratatui_style(content_style),
));
}
Line::from(clip_spans_no_ellipsis(spans, content_w))
})
.collect();
f.render_widget(Paragraph::new(lines).scroll((dy, dx)), effective);
rendered_content = true;
#[cfg(feature = "terminal-images")]
render_terminal_images(f, node, content_rect, effective);
}
if scrollbar {
let total = node.viewport_rows + node.total_scrollback_rows;
let visible = node.viewport_rows;
let std_offset = node
.total_scrollback_rows
.saturating_sub(node.scrollback_offset);
let thumb = node.scrollbar_thumb.unwrap_or(DEFAULT_SCROLLBAR_THUMB);
let (thumb_style, thumb_focus_style, track_style) = resolve_scrollbar_theme(
theme,
node.scrollbar_thumb_style,
node.scrollbar_thumb_focus_style,
node.scrollbar_track_style,
);
let mut thumb_style =
resolve_scrollbar_thumb_style(is_focused, thumb_style, thumb_focus_style);
if !is_focused && node.scrollbar_thumb_style.is_none() {
thumb_style = thumb_style.dim();
}
if use_integrated {
let sb_x = parent_integrated_v
.map(|p| p.track_x)
.unwrap_or_else(|| rect.x.saturating_add(rect.w.saturating_sub(1) as i16));
let sb_rect = Rect {
x: sb_x,
y: inner.y,
w: 1,
h: inner.h,
};
let b_style = parent_integrated_v
.map(|p| p.border_style_fallback)
.unwrap_or(node.border_style);
let track_glyph = parent_integrated_v.and_then(|p| p.track_glyph);
let mut track_scratch = [0u8; 4];
let border_char =
integrated_vscrollbar_track_char(track_glyph, b_style, &mut track_scratch);
let integrated_base_style = parent_integrated_v
.map(|p| p.track_style)
.unwrap_or(selection_style);
render_integrated_scrollbar(
f,
sb_rect,
ScrollbarScrollState {
offset: std_offset,
visible,
total,
},
IntegratedScrollbarAppearance {
thumb_char: thumb,
border_char,
base_style: integrated_base_style,
thumb_style,
track_style,
clip_rect: None,
metrics_cache,
},
);
} else {
let sb_rect = Rect {
x: inner
.x
.saturating_add(inner.w.saturating_sub(1) as i16)
.max(0),
y: inner.y,
w: 1,
h: inner.h,
};
let clip_rrect = clip_rect.map(to_ratatui_rect);
render_vscrollbar(
f,
sb_rect,
ScrollbarScrollState {
offset: std_offset,
visible,
total,
},
ScrollbarAppearance {
thumb_char: thumb,
thumb_style,
track_style,
clip_rect: clip_rrect,
metrics_cache,
},
);
}
}
let has_selection = projected.as_ref().is_some_and(|sel| !sel.is_empty());
let cursor_lit = !node.cursor_blinking || blink_visible;
if rendered_content
&& is_focused
&& node.cursor_visible
&& cursor_lit
&& node.scrollback_offset == 0
&& !has_selection
{
let cursor_x = content_rect.x.saturating_add(node.cursor_col as i16);
let cursor_y = content_rect.y.saturating_add(node.cursor_row as i16);
if is_cursor_visible(cursor_x, cursor_y, content_rect, clip_rect) {
caret.place(
f,
ratatui::layout::Position::new(cursor_x as u16, cursor_y as u16),
);
}
}
}
fn apply_selection_to_row<'a>(
spans: &'a [Span],
row: usize,
selection: &'a Option<GridSelection>,
selection_style: Style,
base_style: Style,
) -> Vec<ratatui::text::Span<'a>> {
let Some(sel) = selection else {
return spans
.iter()
.map(|span| to_ratatui_span(span, base_style))
.collect();
};
if sel.is_empty() {
return spans
.iter()
.map(|span| to_ratatui_span(span, base_style))
.collect();
}
let line_width = crate::utils::spans::line_width(spans);
let Some((col_start, col_end)) = sel.columns_for_row(row, line_width) else {
return spans
.iter()
.map(|span| to_ratatui_span(span, base_style))
.collect();
};
apply_column_selection_to_spans(spans, col_start, col_end, selection_style, base_style)
}
fn apply_column_selection_to_spans(
spans: &[Span],
col_start: usize,
col_end: usize,
selection_style: Style,
base_style: Style,
) -> Vec<ratatui::text::Span<'_>> {
if col_start >= col_end {
return spans
.iter()
.map(|span| to_ratatui_span(span, base_style))
.collect();
}
let mut out = Vec::new();
let mut col = 0usize;
let mut run_text = String::new();
let mut run_style: Option<ratatui::style::Style> = None;
for span in spans {
let span_style = base_style.patch(span.style);
for grapheme in span.content.graphemes(true) {
let w = if grapheme.chars().all(char::is_control) {
0
} else {
UnicodeWidthStr::width(grapheme)
};
let selected = if w == 0 {
col >= col_start && col < col_end
} else {
let cell_end = col.saturating_add(w);
col < col_end && cell_end > col_start
};
let style = if selected {
to_ratatui_style(span_style.patch(selection_style))
} else {
to_ratatui_style(span_style)
};
if run_style == Some(style) {
run_text.push_str(grapheme);
} else {
if let Some(run_style) = run_style.take()
&& !run_text.is_empty()
{
out.push(ratatui::text::Span::styled(
std::mem::take(&mut run_text),
run_style,
));
}
run_style = Some(style);
run_text.push_str(grapheme);
}
if w > 0 {
col = col.saturating_add(w);
}
}
}
if let Some(run_style) = run_style
&& !run_text.is_empty()
{
out.push(ratatui::text::Span::styled(run_text, run_style));
}
out
}
#[cfg(feature = "terminal")]
pub(crate) fn render_terminal_node(
state: &mut RenderState<'_, '_, '_>,
node_id: NodeId,
node: &crate::core::node::Node,
term: &crate::widgets::internal::TerminalNode,
rect: Rect,
rrect: ratatui::layout::Rect,
clip_bounds: Option<Rect>,
) {
let is_focused = Some(node_id) == state.ctx.focused;
let is_hovered = Some(node_id) == state.ctx.hovered;
let parent_integrated_v = if !term.border
&& term.scrollbar
&& matches!(term.scrollbar_variant, ScrollbarVariant::Integrated)
{
ancestor_frame_integrated_vtrack(state, node.parent)
} else {
None
};
let scrollbar_cache = &state.ctx.scrollbar_metrics_cache;
let theme = node.active_theme();
let selection_style = apply_copy_feedback_to_selection_style(
state.ctx,
node_id,
resolve_slot(theme, ThemeRole::TextSelection, &term.selection_style),
);
let flash_range = state
.ctx
.copy_feedback
.and_then(|feedback| feedback.active_range(node_id));
let f = &mut *state.f;
render_terminal(
f,
term,
rect,
rrect,
TerminalRenderCtx {
is_focused,
is_hovered,
blink_visible: state.ctx.blink_visible,
clip_rect: clip_bounds,
caret: CursorPlacement::tracked(state.ctx.cursor_position, state.ctx.tree, node_id),
parent_integrated_v,
metrics_cache: Some(scrollbar_cache),
node_theme: theme,
selection_style,
flash_range,
},
);
}
pub(crate) fn terminal_cursor_position(
node: &crate::widgets::internal::TerminalNode,
rect: Rect,
clip_rect: Option<Rect>,
parent_integrated_v_edge: bool,
) -> Option<ratatui::layout::Position> {
let inner = rect.inner(node.border, node.padding);
if inner.w == 0 || inner.h == 0 {
return None;
}
let layout = terminal_content_layout(
inner,
node.border,
node.scrollbar,
node.scrollbar_variant,
node.scrollbar_gap,
node.total_scrollback_rows,
parent_integrated_v_edge,
);
let content_rect = layout.content_rect;
let cursor_x = content_rect.x.saturating_add(node.cursor_col as i16);
let cursor_y = content_rect.y.saturating_add(node.cursor_row as i16);
if !is_cursor_visible(cursor_x, cursor_y, content_rect, clip_rect) {
return None;
}
Some(ratatui::layout::Position::new(
cursor_x as u16,
cursor_y as u16,
))
}
#[cfg(test)]
mod selection_tests {
use super::*;
use crate::utils::{GridPos, GridSelection};
use crate::widgets::terminal_selection_text;
#[test]
fn renderer_and_extractor_agree_for_wide_joined_emoji() {
let lines = vec![vec![Span::new("a👩💻b")]];
let selection = GridSelection {
anchor: GridPos { row: 0, col: 1 },
cursor: GridPos { row: 0, col: 3 },
};
let selected_style = Style::new().bg(crate::style::Color::Red);
let rendered =
apply_column_selection_to_spans(&lines[0], 1, 3, selected_style, Style::default());
let selected_render_text: String = rendered
.iter()
.filter(|span| span.style == to_ratatui_style(selected_style))
.map(|span| span.content.as_ref())
.collect();
assert_eq!(selected_render_text, "👩💻");
assert_eq!(
terminal_selection_text(&lines, &selection),
selected_render_text
);
}
}