use std::collections::BTreeSet;
use std::time::Instant;
use ratatui::Frame;
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Cell, Paragraph, Row, Table, Wrap};
use super::atoms::panel_block_highlight;
use super::text_utils::{MIN_COL_WIDTH, PADDING, calculate_header_min_widths};
use crate::app::focused_pane::FocusedPane;
use crate::app::query_execution::PREVIEW_PAGE_SIZE;
use crate::app::state::AppState;
use crate::app::ui_state::{RESULT_INNER_OVERHEAD, ResultSelection};
use crate::app::viewport::{
ColumnWidthConfig, MAX_COL_WIDTH, SelectionContext, ViewportPlan, select_viewport_columns,
};
use crate::domain::{QueryResult, QuerySource};
use crate::ui::theme::Theme;
pub struct ResultPane;
impl ResultPane {
pub fn render(frame: &mut Frame, area: Rect, state: &AppState) -> ViewportPlan {
let is_focused = state.ui.focused_pane == FocusedPane::Result;
let should_highlight = state
.query
.result_highlight_until
.map(|t| Instant::now() < t)
.unwrap_or(false);
let result = Self::current_result(state);
let title = Self::build_title(result, state);
let block = panel_block_highlight(&title, is_focused, should_highlight);
if let Some(result) = result {
if result.is_error() {
Self::render_error(frame, area, result, block);
ViewportPlan::default()
} else if result.rows.is_empty() {
Self::render_empty(frame, area, block);
ViewportPlan::default()
} else {
Self::render_table(
frame,
area,
result,
block,
state.ui.result_scroll_offset,
state.ui.result_horizontal_offset,
&state.ui.result_viewport_plan,
&state.ui.result_selection,
if state.cell_edit.is_active() {
Some((
state.cell_edit.row.unwrap_or_default(),
state.cell_edit.col.unwrap_or_default(),
state.cell_edit.draft_value(),
state.ui.input_mode == crate::app::input_mode::InputMode::CellEdit,
state.cell_edit.input.cursor,
))
} else {
None
},
&state.ui.staged_delete_rows,
)
}
} else {
Self::render_placeholder(frame, area, block);
ViewportPlan::default()
}
}
fn current_result(state: &AppState) -> Option<&QueryResult> {
match state.query.history_index {
None => state.query.current_result.as_deref(),
Some(i) => state.query.result_history.get(i),
}
}
fn build_title(result: Option<&QueryResult>, state: &AppState) -> String {
match result {
None => " [3] Result ".to_string(),
Some(r) => {
let source_badge = match r.source {
QuerySource::Preview => {
let pagination = &state.query.pagination;
let page_num = pagination.current_page + 1;
if r.rows.is_empty() {
match pagination.total_pages_estimate() {
Some(total_pages) => {
format!("PREVIEW p.{}/{}", page_num, total_pages)
}
None => format!("PREVIEW p.{}", page_num),
}
} else {
let row_start = pagination.current_page * PREVIEW_PAGE_SIZE + 1;
let row_end = row_start + r.rows.len() - 1;
match pagination.total_pages_estimate() {
Some(total_pages) => {
let total_rows = pagination
.total_rows_estimate
.map(|t| t.max(0) as usize)
.unwrap_or(0);
format!(
"PREVIEW p.{}/{} (rows {}–{} of ~{})",
page_num, total_pages, row_start, row_end, total_rows
)
}
None => {
format!(
"PREVIEW p.{} (rows {}–{})",
page_num, row_start, row_end
)
}
}
}
}
QuerySource::Adhoc => {
if let Some(idx) = state.query.history_index {
format!("ADHOC #{}", idx + 1)
} else {
"ADHOC".to_string()
}
}
};
if r.is_error() {
format!(" [3] Result [{}] ERROR ", source_badge)
} else {
format!(
" [3] Result [{}] ({}, {}ms) ",
source_badge,
r.row_count_display(),
r.execution_time_ms
)
}
}
}
}
fn render_placeholder(frame: &mut Frame, area: Rect, block: Block) {
let content = Paragraph::new("(select a table to preview)")
.block(block)
.style(Style::default().fg(Theme::PLACEHOLDER_TEXT));
frame.render_widget(content, area);
}
fn render_empty(frame: &mut Frame, area: Rect, block: Block) {
let content = Paragraph::new("No rows returned")
.block(block)
.style(Style::default().fg(Theme::PLACEHOLDER_TEXT));
frame.render_widget(content, area);
}
fn render_error(frame: &mut Frame, area: Rect, result: &QueryResult, block: Block) {
let error_msg = result.error.as_deref().unwrap_or("Unknown error");
let block = block.style(Style::default().fg(Theme::STATUS_ERROR));
let content = Paragraph::new(error_msg)
.block(block)
.wrap(Wrap { trim: false })
.style(Style::default().fg(Theme::STATUS_ERROR));
frame.render_widget(content, area);
}
#[allow(clippy::too_many_arguments)]
fn render_table(
frame: &mut Frame,
area: Rect,
result: &QueryResult,
block: Block,
scroll_offset: usize,
horizontal_offset: usize,
stored_plan: &ViewportPlan,
selection: &ResultSelection,
editing_cell: Option<(usize, usize, &str, bool, usize)>,
staged_delete_rows: &BTreeSet<usize>,
) -> ViewportPlan {
let inner = block.inner(area);
frame.render_widget(block, area);
if result.columns.is_empty() {
return ViewportPlan::default();
}
let header_min_widths = calculate_header_min_widths(&result.columns);
let all_ideal_widths = calculate_ideal_widths(&result.columns, &result.rows);
let current_min_widths_sum: u16 = header_min_widths.iter().sum();
let current_ideal_widths_sum: u16 = all_ideal_widths.iter().sum();
let current_ideal_widths_max: u16 = all_ideal_widths.iter().copied().max().unwrap_or(0);
let plan = if stored_plan.needs_recalculation(
all_ideal_widths.len(),
inner.width,
current_min_widths_sum,
current_ideal_widths_sum,
current_ideal_widths_max,
) {
ViewportPlan::calculate(&all_ideal_widths, &header_min_widths, inner.width)
} else {
stored_plan.clone()
};
let clamped_offset = horizontal_offset.min(plan.max_offset);
let config = ColumnWidthConfig {
ideal_widths: &all_ideal_widths,
min_widths: &header_min_widths,
};
let ctx = SelectionContext {
horizontal_offset: clamped_offset,
available_width: inner.width,
fixed_count: Some(plan.column_count),
max_offset: plan.max_offset,
slack_policy: plan.slack_policy,
};
let (viewport_indices, viewport_widths) = select_viewport_columns(&config, &ctx);
if viewport_indices.is_empty() {
return plan;
}
let widths: Vec<Constraint> = viewport_widths
.iter()
.map(|&w| Constraint::Length(w))
.collect();
let header = Row::new(viewport_indices.iter().map(|&idx| {
let col_name = result.columns.get(idx).map(|s| s.as_str()).unwrap_or("");
Cell::from(col_name.to_string())
}))
.style(
Style::default()
.add_modifier(Modifier::UNDERLINED)
.add_modifier(Modifier::BOLD)
.fg(Theme::TEXT_PRIMARY),
)
.height(1);
let data_rows_visible = inner.height.saturating_sub(RESULT_INNER_OVERHEAD) as usize;
let scroll_viewport_size = data_rows_visible;
let active_row = selection.row();
let active_cell = selection.cell();
let rows: Vec<Row> = result
.rows
.iter()
.enumerate()
.skip(scroll_offset)
.take(data_rows_visible)
.map(|(abs_row_idx, row)| {
let is_staged_for_delete = staged_delete_rows.contains(&abs_row_idx);
let is_active_row = active_row == Some(abs_row_idx);
let row_bg = if is_staged_for_delete {
Some(Theme::STAGED_DELETE_BG)
} else if is_active_row {
Some(Theme::RESULT_ROW_ACTIVE_BG)
} else if (abs_row_idx - scroll_offset) % 2 == 1 {
Some(Theme::STRIPED_ROW_BG)
} else {
None
};
let cells: Vec<Cell> = viewport_indices
.iter()
.zip(viewport_widths.iter())
.map(|(&orig_idx, &col_width)| {
let val = row
.get(orig_idx)
.map(|s| s.as_str())
.unwrap_or("")
.to_string();
let is_editing_cell = editing_cell
.is_some_and(|(er, ec, _, _, _)| er == abs_row_idx && ec == orig_idx);
let mut cell;
if let Some((_, _, draft, actively_editing, cursor_pos)) = editing_cell
&& is_editing_cell
{
if actively_editing {
let line = cell_edit_line_with_cursor(
draft,
cursor_pos,
col_width as usize,
);
cell = Cell::from(line).style(
Style::default()
.bg(Theme::RESULT_CELL_ACTIVE_BG)
.fg(Theme::CELL_EDIT_FG),
);
} else {
let display = truncate_cell(draft, col_width as usize);
cell = Cell::from(display).style(
Style::default()
.bg(Theme::RESULT_CELL_ACTIVE_BG)
.fg(Theme::CELL_DRAFT_PENDING_FG),
);
}
} else {
let display = truncate_cell(&val, col_width as usize);
cell = Cell::from(display);
}
if !is_editing_cell {
if is_staged_for_delete {
cell = cell.style(Style::default().fg(Theme::STAGED_DELETE_FG));
} else if is_active_row && active_cell == Some(orig_idx) {
cell =
cell.style(Style::default().bg(Theme::RESULT_CELL_ACTIVE_BG));
}
}
cell
})
.collect();
let mut r = Row::new(cells);
if let Some(bg) = row_bg {
r = r.style(Style::default().bg(bg));
}
r
})
.collect();
let table = Table::new(rows, widths).header(header);
frame.render_widget(table, inner);
let total_rows = result.rows.len();
let total_cols = result.columns.len();
use super::scroll_indicator::{
HorizontalScrollParams, VerticalScrollParams, render_horizontal_scroll_indicator,
render_vertical_scroll_indicator_bar,
};
render_vertical_scroll_indicator_bar(
frame,
inner,
VerticalScrollParams {
position: scroll_offset,
viewport_size: scroll_viewport_size,
total_items: total_rows,
},
);
render_horizontal_scroll_indicator(
frame,
inner,
HorizontalScrollParams {
position: clamped_offset,
viewport_size: plan.column_count, total_items: total_cols,
},
);
plan
}
}
fn calculate_ideal_widths(headers: &[String], rows: &[Vec<String>]) -> Vec<u16> {
const SAMPLE_ROWS: usize = 50;
headers
.iter()
.enumerate()
.map(|(col_idx, header)| {
let mut max_width = header.chars().count();
let sample_size = rows.len().min(SAMPLE_ROWS);
for row in rows.iter().take(sample_size) {
if let Some(cell) = row.get(col_idx) {
let first_line = cell.lines().next().unwrap_or(cell);
let cell_width = first_line.chars().count();
max_width = max_width.max(cell_width);
}
}
(max_width as u16 + PADDING).clamp(MIN_COL_WIDTH, MAX_COL_WIDTH)
})
.collect()
}
fn cell_edit_line_with_cursor(text: &str, cursor: usize, max_chars: usize) -> Line<'static> {
let chars: Vec<char> = text.chars().collect();
let total = chars.len();
if max_chars == 0 {
return Line::from(vec![]);
}
let (view_start, view_len) = if cursor >= total {
let effective = max_chars.saturating_sub(1);
let start = total.saturating_sub(effective);
(start, total - start)
} else {
let start = if cursor < max_chars {
0
} else {
cursor.saturating_sub(max_chars / 2)
};
let end = (start + max_chars).min(total);
(start, end - start)
};
let visible: Vec<char> = chars[view_start..view_start + view_len].to_vec();
let cursor_in_view = cursor.saturating_sub(view_start);
let cursor_style = Style::default()
.bg(Theme::CURSOR_FG)
.fg(Theme::SELECTION_BG)
.add_modifier(Modifier::BOLD);
if cursor >= total {
let text_str: String = visible.iter().collect();
Line::from(vec![
Span::raw(text_str),
Span::styled("\u{2588}", Style::default().fg(Theme::CURSOR_FG)),
])
} else if cursor_in_view < visible.len() {
let before: String = visible[..cursor_in_view].iter().collect();
let cursor_char: String = visible[cursor_in_view].to_string();
let after: String = visible[cursor_in_view + 1..].iter().collect();
Line::from(vec![
Span::raw(before),
Span::styled(cursor_char, cursor_style),
Span::raw(after),
])
} else {
let text_str: String = visible.iter().collect();
Line::from(vec![Span::raw(text_str)])
}
}
fn truncate_cell(s: &str, max_chars: usize) -> String {
let first_line = s.lines().next().unwrap_or(s);
let char_count = first_line.chars().count();
if char_count <= max_chars {
first_line.to_string()
} else {
let truncated: String = first_line
.chars()
.take(max_chars.saturating_sub(3))
.collect();
format!("{}...", truncated)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
mod calculate_ideal_widths_tests {
use super::*;
#[test]
fn empty_headers_returns_empty_vec() {
let headers: Vec<String> = vec![];
let rows: Vec<Vec<String>> = vec![];
let result = calculate_ideal_widths(&headers, &rows);
assert_eq!(result.len(), 0);
}
#[test]
fn single_column_uses_header_width_plus_padding() {
let headers = vec!["name".to_string()];
let rows: Vec<Vec<String>> = vec![];
let result = calculate_ideal_widths(&headers, &rows);
assert_eq!(result.len(), 1);
assert_eq!(result[0], 6);
}
#[test]
fn uses_max_of_header_and_cell_widths() {
let headers = vec!["id".to_string(), "name".to_string()];
let rows = vec![
vec!["1".to_string(), "Alice".to_string()],
vec!["2".to_string(), "Bob".to_string()],
];
let result = calculate_ideal_widths(&headers, &rows);
assert_eq!(result.len(), 2);
assert_eq!(result[0], 4);
assert_eq!(result[1], 7);
}
#[test]
fn respects_max_width_constraint() {
let headers = vec!["description".to_string()];
let long_text = "a".repeat(300);
let rows = vec![vec![long_text]];
let result = calculate_ideal_widths(&headers, &rows);
assert_eq!(result.len(), 1);
assert_eq!(result[0], 200);
}
#[test]
fn handles_multibyte_characters_correctly() {
let headers = vec!["名前".to_string()];
let rows = vec![vec!["日本語テスト".to_string()]];
let result = calculate_ideal_widths(&headers, &rows);
assert_eq!(result.len(), 1);
assert_eq!(result[0], 8);
}
#[test]
fn only_considers_first_line_for_multiline_cells() {
let headers = vec!["text".to_string()];
let rows = vec![vec![
"short\nvery long second line that should be ignored".to_string(),
]];
let result = calculate_ideal_widths(&headers, &rows);
assert_eq!(result.len(), 1);
assert_eq!(result[0], 7);
}
#[test]
fn handles_multiple_columns_independently() {
let headers = vec!["id".to_string(), "name".to_string(), "email".to_string()];
let rows = vec![
vec![
"1".to_string(),
"Alice".to_string(),
"alice@example.com".to_string(),
],
vec![
"22".to_string(),
"Bob Smith Jr.".to_string(),
"bob@ex.com".to_string(),
],
];
let result = calculate_ideal_widths(&headers, &rows);
assert_eq!(result.len(), 3);
assert_eq!(result[0], 4);
assert_eq!(result[1], 15);
assert_eq!(result[2], 19);
}
}
#[test]
fn short_string_returns_unchanged() {
let result = truncate_cell("hello", 10);
assert_eq!(result, "hello");
}
#[test]
fn exact_length_returns_unchanged() {
let result = truncate_cell("hello", 5);
assert_eq!(result, "hello");
}
#[test]
fn long_string_truncates_with_ellipsis() {
let result = truncate_cell("hello world", 8);
assert_eq!(result, "hello...");
}
#[test]
fn multibyte_characters_count_correctly() {
let result = truncate_cell("こんにちは世界", 5);
assert_eq!(result, "こん...");
}
#[rstest]
#[case("日本語テスト", 10, "日本語テスト")]
#[case("日本語テスト", 5, "日本...")]
#[case("日本語テスト", 4, "日...")]
#[case("日本語テスト", 3, "...")]
#[case("SELECT * FROM 日本語テーブル", 15, "SELECT * FRO...")]
fn multibyte_truncation_is_safe(
#[case] input: &str,
#[case] max: usize,
#[case] expected: &str,
) {
let result = truncate_cell(input, max);
assert_eq!(result, expected);
assert!(result.chars().count() <= max);
}
#[test]
fn newline_shows_first_line_only() {
let result = truncate_cell("first\nsecond\nthird", 20);
assert_eq!(result, "first");
}
#[test]
fn newline_with_truncation_applies_to_first_line() {
let result = truncate_cell("this is a long first line\nsecond", 10);
assert_eq!(result, "this is...");
}
#[test]
fn empty_string_returns_empty() {
let result = truncate_cell("", 10);
assert_eq!(result, "");
}
#[test]
fn zero_max_chars_returns_ellipsis_only() {
let result = truncate_cell("hello", 0);
assert_eq!(result, "...");
}
#[rstest]
#[case(1, "...")]
#[case(2, "...")]
#[case(3, "...")]
#[case(4, "h...")]
#[case(5, "he...")]
fn small_max_chars_handles_edge_cases(#[case] max: usize, #[case] expected: &str) {
let result = truncate_cell("hello world", max);
assert_eq!(result, expected);
}
}