use crate::render::context::RenderContext;
use crate::render::datatable_view::{ActiveSidebar, DatatableLayout};
use crate::widgets::info::{DataTableInfo, InfoContext};
use crate::widgets::table::DataTable;
use crate::widgets::ui::{HintBar, Working};
use crate::widgets::{copy, export, pivot_melt};
use datui_cli::keys::Context;
use ratatui::layout::Rect;
use ratatui::prelude::StatefulWidget;
use ratatui::style::{Modifier, Style};
use ratatui::widgets::{Clear, Paragraph, Widget};
pub fn render(
area: Rect,
main_area: Rect,
buf: &mut ratatui::buffer::Buffer,
app: &mut crate::App,
ctx: &RenderContext,
) {
let active_sidebar = ActiveSidebar::from_modals(
app.overlay.shows(&crate::Overlay::Info),
app.overlay == crate::Overlay::SortFilter,
app.overlay == crate::Overlay::View,
);
let datatable_layout = DatatableLayout::compute(
main_area,
active_sidebar,
app.app_config.display.sidebar_width,
);
let data_area = datatable_layout.content_area;
let sort_area = datatable_layout.sidebar_area.unwrap_or_default();
let evidence_label = app.quality.evidence_label.clone();
let facts_reading = app.file_facts_reading();
let facts_tab = app.info_facts_tab();
let footer_expected = app.info_facts().is_some_and(|(_, facts)| facts.footer);
let declared_types = app
.opened_format()
.is_some_and(|f| f.descriptor().declares_types);
let find_cell = app.find_hit();
let match_cells = app.live_cells();
let hex = app.hex_target().is_some();
let query_reading = app.query_reading().map(str::to_string);
let frame = app.throbber_frame as usize;
let header_toggle = app.header_toggle_offered();
let estimate = app.row_estimate();
match &mut app.data_table_state {
Some(state) => {
let mut table_area = data_area;
let breadcrumb_text = if state.is_drilled_down() {
state.drilled_group_key().map(|(key_columns, key_values)| {
let g = crate::glyphs::get();
let parts: Vec<String> = key_columns
.iter()
.zip(key_values.iter())
.map(|(col, val)| {
format!(
"{col}={}",
crate::exact::cell_preview(val, g, main_area.width)
)
})
.collect();
format!(
"{} Group: {}",
g.arrow_left,
parts.join(&format!(" {} ", g.middot))
)
})
} else {
evidence_label.map(|label| format!("{} {label}", crate::glyphs::get().arrow_left))
};
if let Some(text) = breadcrumb_text {
render_breadcrumb(
Rect {
height: 1.min(data_area.height),
..data_area
},
buf,
&text,
ctx,
);
table_area = Rect {
y: data_area.y + 1.min(data_area.height),
height: data_area.height.saturating_sub(1),
..data_area
};
}
Clear.render(table_area, buf);
let mut dt = DataTable::new()
.with_colors(
ctx.table_header_bg,
ctx.table_header,
ctx.row_numbers,
ctx.column_separator,
)
.with_cell_padding(ctx.table_cell_padding)
.with_screen_width(main_area.width)
.with_alternate_row_bg(ctx.alternate_row_color)
.with_stripes_follow_rows(ctx.stripes_follow_rows)
.with_binary_col(ctx.binary_col)
.with_binary_columns(state.binary_column_names())
.with_number_format(ctx.number_format.clone())
.with_dtype_row(ctx.dtype_row)
.with_selection_colors(
ctx.highlight_style(),
ctx.table_selected,
ctx.accent,
ctx.dimmed,
)
.with_cursor_styles(ctx.column_cursor_style(), ctx.cell_cursor_style())
.with_drift(
state.display_drift(table_area.height as usize),
state.drift_groups(),
)
.with_find_cell(find_cell, ctx.find_match)
.with_match_cells(match_cells);
if ctx.column_colors {
dt = dt.with_column_type_colors(
ctx.str_col,
ctx.int_col,
ctx.float_col,
ctx.bool_col,
ctx.temporal_col,
);
}
StatefulWidget::render(dt, table_area, buf, state);
if let Some(status) = &query_reading {
Working {
text: status,
frame,
}
.render_centered(table_area, buf, ctx);
}
if app.overlay.shows(&crate::Overlay::Info) {
let facts = crate::App::facts_shown(
&app.info.file_facts,
app.dataset_generation,
facts_reading,
);
let info_ctx = InfoContext {
format: app.source.original_file_format,
declared_types,
facts,
facts_tab,
footer_expected,
};
let mut info_widget = DataTableInfo::new(state, info_ctx, &mut app.info_modal, ctx);
info_widget.hex = hex;
info_widget.header_toggle = header_toggle;
info_widget.codebook = app.info.codebook.as_deref();
info_widget.estimate = estimate;
info_widget.documentation = app
.info
.info_documentation
.is_open()
.then_some(&mut app.info.info_documentation);
info_widget.render(sort_area, buf);
}
}
None => {
}
}
if app.overlay == crate::Overlay::SortFilter {
crate::render::sort_filter_sidebar::render(sort_area, buf, &mut app.sort_filter_modal, ctx);
}
if app.overlay == crate::Overlay::View {
crate::render::view_sidebar::render(
sort_area,
buf,
&mut app.view_modal,
app.views.active_id.as_deref(),
ctx,
);
}
if app.overlay == crate::Overlay::PivotMelt {
pivot_melt::render(main_area, buf, &mut app.pivot_melt_modal, ctx);
}
if matches!(app.overlay, crate::Overlay::Export { .. }) {
let modal_area = export::dialog_area(area);
export::render_export_modal(modal_area, buf, &mut app.export_modal, ctx);
}
if app.overlay == crate::Overlay::Inspect
&& let Some(state) = app.data_table_state.as_ref()
{
crate::widgets::inspector::render(
main_area,
buf,
&mut app.inspector_modal,
state,
app.info.codebook.as_deref(),
ctx,
);
}
if app.overlay == crate::Overlay::GoToColumn {
render_go_to_column(data_area, buf, &app.pickers.go_to_column, ctx);
}
if app.overlay == crate::Overlay::PickFormat {
render_picker(
data_area,
buf,
&app.pickers.format_picker,
("Format", Context::FormatPicker, "No format matches"),
ctx,
);
}
if matches!(app.overlay, crate::Overlay::Retype { .. })
&& let Some(modal) = &app.column_forms.retype
{
crate::widgets::retype::render_retype(area, buf, modal, ctx);
}
if matches!(app.overlay, crate::Overlay::Combine { .. })
&& let Some(modal) = &app.column_forms.combine
{
crate::widgets::retype::render_combine(area, buf, modal, ctx);
}
if app.overlay == crate::Overlay::Sample
&& let Some(form) = &app.sample.form
{
crate::app::pointer::record(data_area, crate::app::pointer::Hit::Modal);
crate::widgets::sample_form::render(form, true, data_area, buf, ctx);
}
if app.overlay == crate::Overlay::PickTable
&& let Some(tables) = app.pickers.table_choices.as_ref()
{
let details: Vec<String> = tables
.tables
.iter()
.enumerate()
.map(
|(i, t)| match (tables.current == Some(i), t.detail.is_empty()) {
(true, true) => "opened".to_string(),
(true, false) => format!("{}, opened", t.detail),
(false, _) => t.detail.clone(),
},
)
.collect();
render_picker_with(
data_area,
buf,
&app.pickers.table_picker,
("Table", Context::TablePicker, "No table matches"),
Some(&details),
ctx,
);
}
if app.overlay == crate::Overlay::Copy {
let modal_width = (area.width * 3 / 4).min(46);
let wanted = if app.copy_modal.picker.is_some() {
15
} else {
crate::app::modals::copy_modal::CopyModal::MOST_ROWS + 6
};
let modal_height = wanted.min(area.height);
let modal_area = crate::render::layout::centered_rect(area, modal_width, modal_height);
copy::render_copy_modal(modal_area, buf, &mut app.copy_modal, ctx);
}
}
pub(crate) fn render_breadcrumb(
area: Rect,
buf: &mut ratatui::buffer::Buffer,
text: &str,
ctx: &RenderContext,
) {
if area.height == 0 || area.width == 0 {
return;
}
let style = Style::default().bg(ctx.controls_bg).fg(ctx.table_header);
buf.set_style(area, style);
let back = HintBar::from_ctx(ctx).screen(Context::Table).key("Esc");
let chip_w = back.flush_width_in(area.width.saturating_sub(12));
let text_w = area.width.saturating_sub(chip_w + 1);
Paragraph::new(crate::glyphs::fit(text, text_w as usize))
.style(style.add_modifier(Modifier::BOLD))
.render(
Rect {
width: text_w,
..area
},
buf,
);
if chip_w > 0 {
back.render_flush(
Rect {
x: area.x + area.width - chip_w,
width: chip_w,
..area
},
buf,
);
}
}
fn render_go_to_column(
area: Rect,
buf: &mut ratatui::buffer::Buffer,
picker: &crate::widgets::ui::PickerState,
ctx: &RenderContext,
) {
render_picker(
area,
buf,
picker,
("Go to Column", Context::GoToColumn, "No column matches"),
ctx,
);
}
pub(crate) fn render_picker(
area: Rect,
buf: &mut ratatui::buffer::Buffer,
picker: &crate::widgets::ui::PickerState,
words: (&str, Context, &str),
ctx: &RenderContext,
) {
render_picker_with(area, buf, picker, words, None, ctx);
}
pub(crate) fn render_picker_with(
area: Rect,
buf: &mut ratatui::buffer::Buffer,
picker: &crate::widgets::ui::PickerState,
(title, keys, none): (&str, Context, &str),
details: Option<&[String]>,
ctx: &RenderContext,
) {
use ratatui::text::{Line, Span};
let all = picker.items();
let widest = all
.iter()
.enumerate()
.map(|(i, item)| {
let note = details
.and_then(|d| d.get(i))
.map_or(0, |d| crate::glyphs::display_width(d) + 2);
crate::glyphs::display_width(item) + note
})
.max()
.unwrap_or(0) as u16;
let most = if details.is_some() { 64 } else { 48 };
let width = (widest + 6).clamp(30, most).min(area.width);
let height = (all.len().max(1) as u16 + 5).min(15).min(area.height);
let items = picker.filtered();
if width < 4 || height < 4 {
return;
}
let popup = Rect {
x: area.x + area.width.saturating_sub(width) / 2,
y: area.y + area.height.saturating_sub(height) / 3,
width,
height,
};
let footer = HintBar::from_ctx(ctx).screen(keys).key("Enter").key("Esc");
let inner = crate::widgets::ui::Surface::new(title)
.footer(&footer)
.render(popup, buf, ctx);
let filter = if picker.filter.is_empty() {
Line::from(Span::styled(
"type to narrow",
Style::default().fg(ctx.dimmed),
))
} else {
Line::from(Span::raw(picker.filter.clone()))
};
Paragraph::new(filter).render(Rect { height: 1, ..inner }, buf);
let list = Rect {
y: inner.y + 1,
height: inner.height.saturating_sub(1),
..inner
};
if items.is_empty() {
Paragraph::new(Span::styled(none, Style::default().fg(ctx.dimmed)))
.render(Rect { height: 1, ..list }, buf);
return;
}
let mut list_widget = crate::widgets::ui::Picker::from_state(picker, true);
if let Some(details) = details {
let shown = items
.iter()
.map(|(i, _)| details.get(*i).cloned().unwrap_or_default())
.collect();
list_widget = list_widget.details(shown);
}
list_widget.render(list, buf, ctx);
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::buffer::Buffer;
#[test]
fn the_breadcrumb_is_one_plain_line() {
let ctx = RenderContext::for_test();
let area = Rect::new(0, 0, 60, 3);
let mut buf = Buffer::empty(area);
render_breadcrumb(
Rect { height: 1, ..area },
&mut buf,
"<- Group: department=Engineering",
&ctx,
);
let rows = crate::tests::buffer_lines(&buf);
assert!(
rows[0].starts_with("<- Group: department=Engineering"),
"{rows:?}"
);
assert!(rows[0].trim_end().ends_with("Back"), "{rows:?}");
assert!(rows[0].contains("Esc"), "{rows:?}");
for row in &rows[1..] {
assert!(row.trim().is_empty(), "a second row was drawn: {rows:?}");
}
let g = crate::glyphs::get();
for glyph in [
g.border.top_left,
g.border.bottom_left,
g.border.vertical_left,
] {
assert!(
!rows[0].contains(glyph),
"a border around one line: {rows:?}"
);
}
}
#[test]
fn a_long_breadcrumb_is_cut_and_keeps_the_way_back() {
let ctx = RenderContext::for_test();
let area = Rect::new(0, 0, 40, 1);
let mut buf = Buffer::empty(area);
let long = format!("<- Group: {}", "x".repeat(80));
render_breadcrumb(area, &mut buf, &long, &ctx);
let row = &crate::tests::buffer_lines(&buf)[0];
assert!(row.contains(crate::glyphs::get().ellipsis), "{row:?}");
assert!(row.contains("Esc") && row.contains("Back"), "{row:?}");
}
}