use crate::tui::{
layout::TuiPane, selection::ActiveSelection, state::MissionControlState,
theme::MissionControlTheme,
};
use ratatui::text::{Line, Span};
use unicode_segmentation::UnicodeSegmentation;
#[cfg(test)]
pub(in crate::tui) fn uncached_transcript_visual_lines(
state: &MissionControlState,
width: u16,
) -> Vec<crate::tui::transcript_cards::TranscriptVisualLine> {
if let Some(selection) = state
.selection
.as_ref()
.filter(|selection| selection.pane == TuiPane::Transcript)
.and_then(|selection| selection.byte_range())
{
return selected_transcript_visual_lines(state, width, selection);
}
crate::tui::transcript_cards::visual_lines(state, width)
}
#[cfg(test)]
pub(in crate::tui) fn uncached_transcript_lines(
state: &MissionControlState,
width: u16,
) -> Vec<Line<'static>> {
uncached_transcript_visual_lines(state, width)
.into_iter()
.map(|line| line.line)
.collect()
}
#[cfg(test)]
fn selected_transcript_visual_lines(
state: &MissionControlState,
width: u16,
selection: (usize, usize),
) -> Vec<crate::tui::transcript_cards::TranscriptVisualLine> {
crate::tui::transcript_cards::visual_lines(state, width)
.into_iter()
.map(|visual_line| {
let Some((line_start, line_end)) = visual_line.copy_byte_range else {
return visual_line;
};
let start = selection.0.max(line_start);
let end = selection.1.min(line_end);
if start >= end {
visual_line
} else {
let mut visual_line = visual_line;
visual_line.line = visual_line_with_selection(
&visual_line,
start - line_start,
end - line_start,
state.theme,
);
visual_line
}
})
.collect()
}
pub(in crate::tui) fn visible_transcript_lines(
state: &MissionControlState,
width: u16,
visible_rows: u16,
) -> crate::tui::transcript_cards::VisibleTranscriptLines {
let active_selection = state.selection.as_ref().filter(|selection| {
selection.pane == TuiPane::Transcript && selection.byte_range().is_some()
});
let visible = crate::tui::transcript_cards::visible_scrollback_visual_lines(
state,
width,
visible_rows,
state.transcript_scroll_offset(),
);
let mut line_start_row = -(visible.scroll_offset as isize);
let lines = visible
.lines
.into_iter()
.map(|visual_line| {
let mut selected = if let Some(selection) = active_selection {
let rows = crate::tui::transcript_cards::visual_line_rows(&visual_line, width);
let line = visual_line_with_active_selection(
&visual_line,
selection,
line_start_row,
rows,
width,
state.theme,
);
line_start_row = line_start_row.saturating_add(rows as isize);
line
} else {
visual_line.line
};
decorate_status_indicators(state, &visual_line.status_indicators, &mut selected);
selected
})
.collect();
crate::tui::transcript_cards::VisibleTranscriptLines {
lines,
scroll_offset: visible.scroll_offset,
scrollbar: visible.scrollbar,
}
}
fn visual_line_with_active_selection(
visual_line: &crate::tui::transcript_cards::TranscriptVisualLine,
selection: &ActiveSelection,
line_start_row: isize,
line_rows: usize,
width: u16,
theme: MissionControlTheme,
) -> Line<'static> {
if !visual_line.copyable || line_rows == 0 {
return visual_line.line.clone();
}
let (selection_start, selection_end) = if selection.anchor.byte <= selection.current.byte {
(selection.anchor, selection.current)
} else {
(selection.current, selection.anchor)
};
let line_end_row = line_start_row.saturating_add(line_rows as isize);
if selection_end.row < line_start_row.max(0) as usize
|| selection_start.row as isize >= line_end_row
{
return visual_line.line.clone();
}
let first_row = (selection_start.row as isize).max(line_start_row);
let last_row = (selection_end.row as isize).min(line_end_row - 1);
let mut selected_copy_start = usize::MAX;
let mut selected_copy_end = 0usize;
let column_offset = usize::from(visual_line.visual_copy_column_offset);
let copy_text_len = visual_line.copy_text.len();
for row in first_row..=last_row {
let row_usize = row as usize;
let start_col = if row_usize == selection_start.row {
selection_start.col
} else {
0
};
let end_col = if row_usize == selection_end.row {
selection_end.col
} else {
usize::from(width.max(1))
};
if start_col >= end_col {
continue;
}
let row_in_line = (row - line_start_row) as usize;
let Some(start) = copy_byte_for_visual_cell(
&visual_line.copy_text,
&visual_line.visual_copy_byte_boundaries,
column_offset,
copy_text_len,
row_in_line,
start_col,
width,
) else {
continue;
};
let Some(end) = copy_byte_for_visual_cell(
&visual_line.copy_text,
&visual_line.visual_copy_byte_boundaries,
column_offset,
copy_text_len,
row_in_line,
end_col,
width,
) else {
continue;
};
if start < end {
selected_copy_start = selected_copy_start.min(start);
selected_copy_end = selected_copy_end.max(end);
}
}
if selected_copy_start >= selected_copy_end {
visual_line.line.clone()
} else {
visual_line_with_selection(visual_line, selected_copy_start, selected_copy_end, theme)
}
}
fn copy_byte_for_visual_cell(
copy_text: &str,
copy_byte_boundaries: &[usize],
column_offset: usize,
copy_text_len: usize,
row: usize,
column: usize,
width: u16,
) -> Option<usize> {
if row == 0 && !copy_byte_boundaries.is_empty() {
let relative_column = column.saturating_sub(column_offset);
return Some(
copy_byte_boundaries
.get(relative_column)
.copied()
.or_else(|| copy_byte_boundaries.last().copied())?
.min(copy_text_len),
);
}
let position = crate::tui::transcript::text_position_for_visual_cell_with_leading_columns(
copy_text,
column_offset,
row,
column,
width.max(1),
)?;
Some(position.byte.min(copy_text_len))
}
fn visual_line_with_selection(
visual_line: &crate::tui::transcript_cards::TranscriptVisualLine,
copy_start: usize,
copy_end: usize,
theme: MissionControlTheme,
) -> Line<'static> {
let rendered_text = crate::tui::transcript_cards::rendered_line_text(&visual_line.line);
let copy_start = copy_start.min(visual_line.copy_text.len());
let copy_end = copy_end.min(visual_line.copy_text.len());
let rendered_start_column = visual_column_for_copy_byte(visual_line, copy_start, false);
let rendered_end_column = visual_column_for_copy_byte(visual_line, copy_end, true);
let rendered_start = byte_index_for_display_column(&rendered_text, rendered_start_column);
let rendered_end = byte_index_for_display_column(&rendered_text, rendered_end_column);
line_with_rendered_selection(&visual_line.line, rendered_start, rendered_end, theme)
}
#[cfg(test)]
pub(in crate::tui) fn visual_line_with_selection_for_test(
visual_line: &crate::tui::transcript_cards::TranscriptVisualLine,
copy_start: usize,
copy_end: usize,
theme: MissionControlTheme,
) -> Line<'static> {
visual_line_with_selection(visual_line, copy_start, copy_end, theme)
}
fn display_width_for_prefix(text: &str, byte_end: usize) -> usize {
let safe_end = grapheme_boundary_at_or_before(text, byte_end.min(text.len()));
unicode_width::UnicodeWidthStr::width(&text[..safe_end])
}
fn visual_column_for_copy_byte(
visual_line: &crate::tui::transcript_cards::TranscriptVisualLine,
byte: usize,
end: bool,
) -> usize {
let column_offset = usize::from(visual_line.visual_copy_column_offset);
let byte = byte.min(visual_line.copy_text.len());
if !visual_line.visual_copy_byte_boundaries.is_empty() {
let relative_column = if end {
visual_line
.visual_copy_byte_boundaries
.iter()
.rposition(|boundary| *boundary <= byte)
.unwrap_or_default()
} else {
visual_line
.visual_copy_byte_boundaries
.iter()
.position(|boundary| *boundary >= byte)
.unwrap_or_else(|| visual_line.visual_copy_byte_boundaries.len() - 1)
};
return column_offset.saturating_add(relative_column);
}
column_offset.saturating_add(display_width_for_prefix(&visual_line.copy_text, byte))
}
fn line_with_rendered_selection(
line: &Line<'static>,
selection_start: usize,
selection_end: usize,
theme: MissionControlTheme,
) -> Line<'static> {
if selection_start >= selection_end {
return line.clone();
}
let mut spans = Vec::new();
let mut span_start = 0usize;
for span in &line.spans {
let text = span.content.as_ref();
let span_end = span_start.saturating_add(text.len());
if selection_end <= span_start || selection_start >= span_end {
spans.push(span.clone());
span_start = span_end;
continue;
}
let start = grapheme_boundary_at_or_before(
text,
selection_start.saturating_sub(span_start).min(text.len()),
);
let end = grapheme_boundary_at_or_before(
text,
selection_end.saturating_sub(span_start).min(text.len()),
);
if start > 0 {
spans.push(Span::styled(text[..start].to_string(), span.style));
}
if start < end {
spans.push(Span::styled(
text[start..end].to_string(),
theme.selection(),
));
}
if end < text.len() {
spans.push(Span::styled(text[end..].to_string(), span.style));
}
span_start = span_end;
}
Line::from(spans)
}
fn byte_index_for_display_column(text: &str, column: usize) -> usize {
if column == 0 {
return 0;
}
let mut width = 0usize;
for (offset, grapheme) in text.grapheme_indices(true) {
if grapheme.chars().any(char::is_control) {
continue;
}
let next_width = width.saturating_add(unicode_width::UnicodeWidthStr::width(grapheme));
if next_width > column {
return offset;
}
width = next_width;
if width == column {
return offset + grapheme.len();
}
}
text.len()
}
fn grapheme_boundary_at_or_before(text: &str, byte_index: usize) -> usize {
let byte_index = byte_index.min(text.len());
if byte_index == text.len() {
return text.len();
}
text.grapheme_indices(true)
.map(|(index, _)| index)
.take_while(|index| *index <= byte_index)
.last()
.unwrap_or(0)
}
pub(crate) fn decorate_status_indicators(
state: &MissionControlState,
indicators: &[(u16, Option<crate::output::ActivityId>)],
line: &mut Line<'static>,
) {
if indicators.is_empty() || state.reduced_motion || state.top_modal().is_some() {
return;
}
let mut column = 0usize;
let mut spans = Vec::new();
for span in &line.spans {
let mut content = String::new();
for grapheme in span.content.graphemes(true) {
let indicator = indicators
.iter()
.find(|(target, _)| usize::from(*target) == column);
let highlight = grapheme == "✓"
&& indicator
.and_then(|(_, id)| id.as_ref())
.is_some_and(|id| state.card_completion_highlight_visible(id));
if highlight {
if !content.is_empty() {
spans.push(Span::styled(std::mem::take(&mut content), span.style));
}
spans.push(Span::styled(
grapheme.to_string(),
span.style.add_modifier(
ratatui::style::Modifier::BOLD | ratatui::style::Modifier::UNDERLINED,
),
));
} else if grapheme == "●"
&& indicator
.and_then(|(_, id)| id.as_ref())
.is_some_and(|id| state.card_spinner_visible(id))
{
content.push_str(state.activity_spinner());
} else {
content.push_str(grapheme);
}
column += unicode_width::UnicodeWidthStr::width(grapheme);
}
if !content.is_empty() {
spans.push(Span::styled(content, span.style));
}
}
line.spans = spans;
}