use anyhow::Result;
use arboard::Clipboard;
use crossterm::{
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseEventKind,
},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
tty::IsTty,
};
use ratatui::{
backend::{Backend, CrosstermBackend},
layout::{Constraint, Direction, Layout, Margin, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{
Block, Borders, Clear, List, ListItem, ListState, Paragraph, Scrollbar,
ScrollbarOrientation, ScrollbarState, Wrap,
},
Frame, Terminal,
};
use std::io;
use crate::{document::*, Cli};
use ratatui_image::{picker::Picker, protocol::StatefulProtocol};
type ImageProtocols = Vec<StatefulProtocol>;
pub struct App {
pub document: Document,
pub current_view: ViewMode,
pub scroll_offset: usize,
pub search_query: String,
pub search_results: Vec<SearchResult>,
pub backup_search_results: Vec<SearchResult>,
pub current_search_index: usize,
pub outline_state: ListState,
pub show_help: bool,
pub clipboard: Option<Clipboard>,
pub status_message: Option<String>,
pub color_enabled: bool,
pub image_picker: Option<Picker>,
pub image_protocols: ImageProtocols,
}
#[derive(Debug, Clone)]
pub enum ViewMode {
Document,
Outline,
Search,
#[allow(dead_code)]
Help,
}
impl App {
pub fn new(document: Document, cli: &Cli) -> Self {
let mut app = Self {
document,
current_view: ViewMode::Document,
scroll_offset: 0,
search_query: String::new(),
search_results: Vec::new(),
backup_search_results: Vec::new(),
current_search_index: 0,
outline_state: ListState::default(),
show_help: false,
clipboard: Clipboard::new().ok(),
status_message: None,
color_enabled: cli.color,
image_picker: None,
image_protocols: Vec::new(),
};
if cli.outline {
app.current_view = ViewMode::Outline;
}
if let Some(search) = &cli.search {
app.search_query = search.clone();
app.search_results = crate::document::search_document(&app.document, search);
app.current_view = ViewMode::Search;
}
if let Some(page) = cli.page {
let elements_per_page = 10;
app.scroll_offset = (page.saturating_sub(1)) * elements_per_page;
}
if cli.images {
app.init_image_support();
}
app
}
fn init_image_support(&mut self) {
#[cfg(unix)]
let picker = if let Ok(p) = Picker::from_query_stdio() {
p
} else {
Picker::from_fontsize((8, 16))
};
#[cfg(not(unix))]
let picker = Picker::from_fontsize((8, 16));
for element in &self.document.elements {
if let DocumentElement::Image {
image_path: Some(path),
..
} = element
{
if let Ok(img) = image::ImageReader::open(path) {
if let Ok(dyn_img) = img.decode() {
let protocol = picker.new_resize_protocol(dyn_img);
self.image_protocols.push(protocol);
}
}
}
}
self.image_picker = Some(picker);
}
pub fn next_search_result(&mut self) {
if !self.search_results.is_empty() {
self.current_search_index = (self.current_search_index + 1) % self.search_results.len();
if let Some(result) = self.search_results.get(self.current_search_index) {
self.scroll_offset = result.element_index;
}
}
}
pub fn prev_search_result(&mut self) {
if !self.search_results.is_empty() {
self.current_search_index = if self.current_search_index == 0 {
self.search_results.len() - 1
} else {
self.current_search_index - 1
};
if let Some(result) = self.search_results.get(self.current_search_index) {
self.scroll_offset = result.element_index;
}
}
}
pub fn scroll_up(&mut self) {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
pub fn scroll_down(&mut self) {
if self.scroll_offset + 1 < self.document.elements.len() {
self.scroll_offset += 1;
}
}
pub fn page_up(&mut self, page_size: usize) {
self.scroll_offset = self.scroll_offset.saturating_sub(page_size);
}
pub fn page_down(&mut self, page_size: usize) {
let max_offset = self.document.elements.len().saturating_sub(1);
self.scroll_offset = std::cmp::min(self.scroll_offset + page_size, max_offset);
}
pub fn copy_content(&mut self) {
if let Some(clipboard) = &mut self.clipboard {
let content = match self.current_view {
ViewMode::Document => {
crate::export::format_as_text(&self.document)
}
ViewMode::Search => {
if self.search_results.is_empty() {
"No search results to copy.".to_string()
} else {
let mut content =
format!("Search results for '{}':\n\n", self.search_query);
for (i, result) in self.search_results.iter().enumerate() {
content.push_str(&format!("{}. {}\n", i + 1, result.text.trim()));
}
content
}
}
ViewMode::Outline => {
let outline = crate::document::generate_outline(&self.document);
let mut content = String::from("Document Outline:\n\n");
for item in outline {
let indent = " ".repeat((item.level as usize).saturating_sub(1));
content.push_str(&format!("{}{}\n", indent, item.title));
}
content
}
_ => "Content not available for copying in this view.".to_string(),
};
match clipboard.set_text(content) {
Ok(_) => {
self.status_message = Some("Copied to clipboard!".to_string());
}
Err(_) => {
self.status_message = Some("Failed to copy to clipboard.".to_string());
}
}
} else {
self.status_message = Some("Clipboard not available.".to_string());
}
}
pub fn clear_status_message(&mut self) {
self.status_message = None;
}
pub fn toggle_search_state(&mut self) {
if self.search_query.is_empty() {
return;
}
if !self.search_results.is_empty() {
self.backup_search_results = self.search_results.clone();
self.search_results.clear();
} else if !self.backup_search_results.is_empty() {
self.search_results = self.backup_search_results.clone();
self.backup_search_results.clear();
}
}
}
async fn run_non_interactive(document: Document, cli: &Cli) -> Result<()> {
let app = App::new(document, cli);
match app.current_view {
ViewMode::Outline => {
let outline = crate::document::generate_outline(&app.document);
println!("Document Outline:");
println!("================");
for item in outline {
let indent = " ".repeat((item.level.saturating_sub(1)) as usize);
println!("{}{}", indent, item.title);
}
}
ViewMode::Search => {
println!("Search Results for '{}':", app.search_query);
println!("==============================");
for (i, result) in app.search_results.iter().enumerate() {
println!("{}. {}", i + 1, result.text.trim());
println!();
}
if app.search_results.is_empty() {
println!("No results found.");
}
}
_ => {
println!("Document: {}", app.document.title);
println!("Pages: {}", app.document.metadata.page_count);
println!("Words: {}", app.document.metadata.word_count);
println!();
println!("Content Preview:");
println!("================");
let preview_count = std::cmp::min(app.document.elements.len(), 20);
for element in &app.document.elements[0..preview_count] {
match element {
DocumentElement::Heading {
level,
text,
number,
} => {
let prefix = match level {
1 => "# ",
2 => "## ",
_ => "### ",
};
let heading_text = if let Some(number) = number {
format!("{number} {text}")
} else {
text.clone()
};
println!("{prefix}{heading_text}");
println!();
}
DocumentElement::Paragraph { runs } => {
let text: String = runs.iter().map(|run| run.text.as_str()).collect();
println!("{text}");
println!();
}
DocumentElement::List { items, ordered } => {
for (i, item) in items.iter().enumerate() {
let bullet = if *ordered {
format!("{}. ", i + 1)
} else {
"• ".to_string()
};
let indent = " ".repeat(item.level as usize);
let item_text: String =
item.runs.iter().map(|run| run.text.as_str()).collect();
println!("{indent}{bullet}{item_text}");
}
println!();
}
DocumentElement::Table { .. } => {
println!("[Table content - use --export csv to view]");
println!();
}
DocumentElement::Image {
description,
image_path,
..
} => {
if let Some(path) = image_path {
match crate::terminal_image::TerminalImageRenderer::with_options(
app.document.image_options.max_width,
app.document.image_options.max_height,
app.document.image_options.scale,
)
.render_image_from_path(path, description)
{
Ok(_) => {
println!();
}
Err(_) => {
println!("📷 [Image: {description}]");
println!();
}
}
} else {
println!("📷 [Image: {description}]");
println!();
}
}
DocumentElement::Equation { latex, .. } => {
println!("📐 Equation: {latex}");
println!();
}
DocumentElement::PageBreak => {
println!("---");
println!();
}
}
}
if app.document.elements.len() > preview_count {
println!(
"... and {} more elements",
app.document.elements.len() - preview_count
);
println!();
}
println!(
"Use --export to save full content, or run in an interactive terminal for full UI."
);
}
}
Ok(())
}
pub async fn run_viewer(document: Document, cli: &Cli) -> Result<()> {
if !cli.force_ui && !IsTty::is_tty(&io::stdout()) {
return run_non_interactive(document, cli).await;
}
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut app = App::new(document, cli);
let res = run_app(&mut terminal, &mut app).await;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("{err:?}");
}
Ok(())
}
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Result<()> {
loop {
terminal.draw(|f| ui(f, app))?;
match event::read()? {
Event::Key(key) => {
if key.kind == KeyEventKind::Press {
if app.status_message.is_some()
&& key.code != KeyCode::Char('c')
&& key.code != KeyCode::F(2)
{
app.clear_status_message();
}
match app.current_view {
ViewMode::Document => match key.code {
KeyCode::Char('q') => break,
KeyCode::Char('o') => app.current_view = ViewMode::Outline,
KeyCode::Char('s') => app.current_view = ViewMode::Search,
KeyCode::Char('S') => app.toggle_search_state(),
KeyCode::Char('c') => app.copy_content(),
KeyCode::Char('h') | KeyCode::F(1) => app.show_help = !app.show_help,
KeyCode::Up | KeyCode::Char('k') => app.scroll_up(),
KeyCode::Down | KeyCode::Char('j') => app.scroll_down(),
KeyCode::PageUp => app.page_up(10),
KeyCode::PageDown => app.page_down(10),
KeyCode::Home => app.scroll_offset = 0,
KeyCode::End => {
app.scroll_offset = app.document.elements.len().saturating_sub(1)
}
KeyCode::Char('n') if !app.search_results.is_empty() => {
app.next_search_result()
}
KeyCode::Char('p') if !app.search_results.is_empty() => {
app.prev_search_result()
}
_ => {}
},
ViewMode::Outline => match key.code {
KeyCode::Char('q') | KeyCode::Esc => {
app.current_view = ViewMode::Document
}
KeyCode::Char('c') => app.copy_content(),
KeyCode::Up | KeyCode::Char('k') => {
let selected = app.outline_state.selected().unwrap_or(0);
if selected > 0 {
app.outline_state.select(Some(selected - 1));
}
}
KeyCode::Down | KeyCode::Char('j') => {
let selected = app.outline_state.selected().unwrap_or(0);
if selected + 1
< crate::document::generate_outline(&app.document).len()
{
app.outline_state.select(Some(selected + 1));
}
}
KeyCode::Enter => {
if let Some(selected) = app.outline_state.selected() {
if let Some(outline_item) =
crate::document::generate_outline(&app.document)
.get(selected)
{
app.scroll_offset = outline_item.element_index;
app.current_view = ViewMode::Document;
}
}
}
_ => {}
},
ViewMode::Search => match key.code {
KeyCode::Esc => app.current_view = ViewMode::Document,
KeyCode::F(2) => app.copy_content(), KeyCode::Char(c) => {
app.search_query.push(c);
app.search_results = crate::document::search_document(
&app.document,
&app.search_query,
);
app.current_search_index = 0;
}
KeyCode::Backspace => {
app.search_query.pop();
app.search_results = crate::document::search_document(
&app.document,
&app.search_query,
);
app.current_search_index = 0;
}
KeyCode::Enter | KeyCode::Down => app.next_search_result(),
KeyCode::Up => app.prev_search_result(),
_ => {}
},
ViewMode::Help => match key.code {
KeyCode::Char('q')
| KeyCode::Esc
| KeyCode::Char('h')
| KeyCode::F(1) => {
app.show_help = false;
app.current_view = ViewMode::Document;
}
_ => {}
},
}
}
}
Event::Mouse(mouse) => {
match mouse.kind {
MouseEventKind::ScrollUp => {
match app.current_view {
ViewMode::Document => {
for _ in 0..3 {
app.scroll_up();
}
}
ViewMode::Outline => {
let selected = app.outline_state.selected().unwrap_or(0);
if selected > 0 {
app.outline_state.select(Some(selected - 1));
}
}
ViewMode::Search => app.prev_search_result(),
_ => {}
}
}
MouseEventKind::ScrollDown => {
match app.current_view {
ViewMode::Document => {
for _ in 0..3 {
app.scroll_down();
}
}
ViewMode::Outline => {
let selected = app.outline_state.selected().unwrap_or(0);
if selected + 1
< crate::document::generate_outline(&app.document).len()
{
app.outline_state.select(Some(selected + 1));
}
}
ViewMode::Search => app.next_search_result(),
_ => {}
}
}
_ => {}
}
}
_ => {}
}
}
Ok(())
}
fn ui(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(3)].as_ref())
.split(f.area());
match app.current_view {
ViewMode::Document => render_document(f, chunks[0], app),
ViewMode::Outline => render_outline(f, chunks[0], app),
ViewMode::Search => render_search(f, chunks[0], app),
ViewMode::Help => render_help(f, chunks[0]),
}
render_status_bar(f, chunks[1], app);
if app.show_help {
render_help_overlay(f, app);
}
}
fn render_document(f: &mut Frame, area: Rect, app: &mut App) {
let title = format!("📄 doxx - {}", app.document.title);
let block = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Blue));
let inner = block.inner(area);
f.render_widget(block, area);
let visible_height = inner.height as usize;
let end_index = std::cmp::min(
app.scroll_offset + visible_height,
app.document.elements.len(),
);
let mut text = Text::default();
for (index, element) in app.document.elements[app.scroll_offset..end_index]
.iter()
.enumerate()
{
let actual_index = app.scroll_offset + index;
let is_search_match = app
.search_results
.iter()
.any(|r| r.element_index == actual_index);
match element {
DocumentElement::Heading {
level,
text: heading_text,
number,
} => {
let style = match level {
1 => Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
2 => Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
_ => Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
};
let prefix = match level {
1 => "■ ".to_string(),
2 => " ▶ ".to_string(),
3 => " ◦ ".to_string(),
_ => " • ".to_string(),
};
let display_text = if let Some(number) = number {
format!("{number} {heading_text}")
} else {
heading_text.clone()
};
let line = if is_search_match {
Line::from(vec![
Span::styled(prefix.clone(), style),
Span::styled(display_text, style.bg(Color::Yellow).fg(Color::Black)),
])
} else {
Line::from(vec![
Span::styled(prefix, style),
Span::styled(display_text, style),
])
};
text.lines.push(line);
text.lines.push(Line::from(""));
}
DocumentElement::Paragraph { runs } => {
if runs.is_empty() || runs.iter().all(|run| run.text.trim().is_empty()) {
continue;
}
let mut spans = Vec::new();
let total_text: String = runs.iter().map(|run| run.text.as_str()).collect();
for run in runs {
let mut style = Style::default();
if run.formatting.bold {
style = style.add_modifier(Modifier::BOLD);
}
if run.formatting.italic {
style = style.add_modifier(Modifier::ITALIC);
}
if run.formatting.underline {
style = style.add_modifier(Modifier::UNDERLINED);
}
if run.formatting.strikethrough {
style = style.add_modifier(Modifier::CROSSED_OUT);
}
if app.color_enabled {
if let Some(color_hex) = &run.formatting.color {
if let Some(color) = hex_to_color(color_hex) {
style = style.fg(color);
}
}
}
let display_text = if total_text.len() > 100 {
if spans.is_empty() {
format!(" {}", run.text)
} else {
run.text.clone()
}
} else {
run.text.clone()
};
if is_search_match {
style = style.bg(Color::Yellow).fg(Color::Black);
}
spans.push(Span::styled(display_text, style));
}
let line = Line::from(spans);
text.lines.push(line);
text.lines.push(Line::from(""));
}
DocumentElement::List { items, ordered } => {
for (i, item) in items.iter().enumerate() {
let bullet = if *ordered {
format!("{}. ", i + 1)
} else {
"• ".to_string()
};
let indent = " ".repeat(item.level as usize);
let prefixed_bullet = format!("{indent}{bullet}");
let mut spans = vec![Span::styled(
prefixed_bullet,
Style::default().fg(Color::Blue),
)];
for run in &item.runs {
let mut style = Style::default();
if run.formatting.bold {
style = style.add_modifier(Modifier::BOLD);
}
if run.formatting.italic {
style = style.add_modifier(Modifier::ITALIC);
}
if run.formatting.underline {
style = style.add_modifier(Modifier::UNDERLINED);
}
if run.formatting.strikethrough {
style = style.add_modifier(Modifier::CROSSED_OUT);
}
if let Some(color_hex) = &run.formatting.color {
if let Some(color) = hex_to_color(color_hex) {
style = style.fg(color);
}
}
spans.push(Span::styled(run.text.clone(), style));
}
let line = Line::from(spans);
text.lines.push(line);
}
text.lines.push(Line::from(""));
}
DocumentElement::Table { table } => {
render_table_enhanced(table, &mut text);
}
DocumentElement::Image {
description,
width,
height,
image_path,
..
} => {
let dimensions = match (width, height) {
(Some(w), Some(h)) => format!(" ({w}x{h})"),
_ => String::new(),
};
let status = if image_path.is_some() && !app.image_protocols.is_empty() {
" [TUI placeholder - use --export text to view images]"
} else if image_path.is_some() {
" [Image available - use --export text to view]"
} else {
" [Image not extracted]"
};
let line = Line::from(vec![
Span::styled("🖼️ ", Style::default().fg(Color::Magenta)),
Span::styled(description, Style::default().fg(Color::Gray)),
Span::styled(dimensions, Style::default().fg(Color::DarkGray)),
Span::styled(status, Style::default().fg(Color::Green)),
]);
text.lines.push(line);
text.lines.push(Line::from(""));
}
DocumentElement::Equation { latex, .. } => {
let line = Line::from(vec![
Span::styled("📐 ", Style::default().fg(Color::Cyan)),
Span::styled(
latex,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
]);
text.lines.push(line);
text.lines.push(Line::from(""));
}
DocumentElement::PageBreak => {
text.lines.push(Line::from(Span::styled(
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
Style::default().fg(Color::DarkGray),
)));
text.lines.push(Line::from(""));
}
}
}
let paragraph = Paragraph::new(text)
.wrap(Wrap { trim: false }) .scroll((0, 0));
f.render_widget(paragraph, inner);
let scrollbar = Scrollbar::default()
.orientation(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("↑"))
.end_symbol(Some("↓"));
let mut scrollbar_state = ScrollbarState::default()
.content_length(app.document.elements.len())
.position(app.scroll_offset);
f.render_stateful_widget(
scrollbar,
area.inner(Margin {
vertical: 1,
horizontal: 0,
}),
&mut scrollbar_state,
);
}
fn render_outline(f: &mut Frame, area: Rect, app: &mut App) {
let outline = crate::document::generate_outline(&app.document);
let items: Vec<ListItem> = outline
.iter()
.map(|item| {
let indent = " ".repeat((item.level.saturating_sub(1)) as usize);
let text = format!("{}{}", indent, item.title);
ListItem::new(text)
})
.collect();
let list = List::new(items)
.block(
Block::default()
.title("📋 Document Outline")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Green)),
)
.style(Style::default().fg(Color::White))
.highlight_style(Style::default().bg(Color::Blue).fg(Color::White))
.highlight_symbol("➤ ");
f.render_stateful_widget(list, area, &mut app.outline_state);
}
fn render_search(f: &mut Frame, area: Rect, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)].as_ref())
.split(area);
let input = Paragraph::new(app.search_query.as_str())
.style(Style::default().fg(Color::Yellow))
.block(
Block::default()
.borders(Borders::ALL)
.title("🔍 Search")
.border_style(Style::default().fg(Color::Yellow)),
);
f.render_widget(input, chunks[0]);
let results: Vec<ListItem> = app
.search_results
.iter()
.enumerate()
.map(|(i, result)| {
let prefix = "📄";
let style = if i == app.current_search_index {
Style::default().bg(Color::Blue).fg(Color::White)
} else {
Style::default()
};
let display_text = if result.text.len() > 80 {
let max_bytes = 77;
let safe_boundary = if result.text.len() <= max_bytes {
result.text.len()
} else {
let mut boundary = max_bytes;
while boundary > 0 && !result.text.is_char_boundary(boundary) {
boundary -= 1;
}
boundary
};
format!("{}...", &result.text[..safe_boundary])
} else {
result.text.clone()
};
ListItem::new(format!("{} {} [{}]", prefix, display_text, i + 1)).style(style)
})
.collect();
let results_list = List::new(results).block(
Block::default()
.title(format!(
"Results ({}/{})",
if app.search_results.is_empty() {
0
} else {
app.current_search_index + 1
},
app.search_results.len()
))
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
);
f.render_widget(results_list, chunks[1]);
}
fn render_help(f: &mut Frame, area: Rect) {
let help_text = vec![
"🆘 doxx - Help",
"",
"📖 Document Navigation:",
" ↑/k Scroll up",
" ↓/j Scroll down",
" Page Up Page up",
" Page Down Page down",
" Home Go to start",
" End Go to end",
"",
"🔍 Search:",
" s Open search",
" n Next result",
" p Previous result",
" S Deselect/Reselect current selection",
"",
"📋 Other Features:",
" o Show outline",
" c Copy content to clipboard",
" h/F1 Toggle help",
" q Quit",
"",
"📄 Copy Functionality:",
" Document: Copies full document as text",
" Outline: Copies document structure",
" Search: Copies search results (use F2)",
"",
"Press any key to close help...",
];
let help = Paragraph::new(help_text.join("\n"))
.block(
Block::default()
.title("Help")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.wrap(Wrap { trim: true });
f.render_widget(help, area);
}
fn render_help_overlay(f: &mut Frame, _app: &App) {
let area = centered_rect(60, 70, f.area());
f.render_widget(Clear, area);
render_help(f, area);
}
fn render_status_bar(f: &mut Frame, area: Rect, app: &App) {
let metadata = &app.document.metadata;
let view_indicator = match app.current_view {
ViewMode::Document => "📄 Document",
ViewMode::Outline => "📋 Outline",
ViewMode::Search => "🔍 Search",
ViewMode::Help => "❓ Help",
};
let search_info = if !app.search_results.is_empty() {
format!(
" • 🔍 {}/{} matches",
app.current_search_index + 1,
app.search_results.len()
)
} else if !app.search_query.is_empty() {
" • 🔍 No matches".to_string()
} else {
String::new()
};
let status_text = if let Some(status_msg) = &app.status_message {
status_msg.clone()
} else {
format!(
"{} • 📄 {} • {} pages • {} words • {}/{}{}",
view_indicator,
metadata
.file_path
.split('/')
.next_back()
.unwrap_or("Unknown"),
metadata.page_count,
metadata.word_count,
app.scroll_offset + 1,
app.document.elements.len(),
search_info
)
};
let status_style = if app.status_message.is_some() {
Style::default()
.fg(Color::Green)
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White).bg(Color::DarkGray)
};
let status = Paragraph::new(status_text)
.style(status_style)
.block(Block::default());
f.render_widget(status, area);
let help_text = "[↕] Scroll [o] Outline [s] Search [c] Copy [h] Help [q] Quit";
let help_area = Rect {
x: area.x,
y: area.y + 1,
width: area.width,
height: 1,
};
let help = Paragraph::new(help_text)
.style(Style::default().fg(Color::Gray))
.block(Block::default());
f.render_widget(help, help_area);
}
fn render_table_enhanced(table: &TableData, text: &mut Text) {
let metadata = &table.metadata;
if let Some(title) = &metadata.title {
text.lines.push(Line::from(Span::styled(
format!("📊 {title}"),
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
)));
text.lines.push(Line::from(""));
}
if !table.headers.is_empty() {
let top_border = generate_table_border(&metadata.column_widths, BorderType::Top);
text.lines.push(Line::from(Span::styled(
top_border,
Style::default().fg(Color::Gray),
)));
let header_line = render_table_row(&table.headers, &metadata.column_widths, true);
text.lines.push(Line::from(Span::styled(
header_line,
Style::default().add_modifier(Modifier::BOLD),
)));
let separator = generate_table_border(&metadata.column_widths, BorderType::Separator);
text.lines.push(Line::from(Span::styled(
separator,
Style::default().fg(Color::Gray),
)));
for row in &table.rows {
let row_line = render_table_row(row, &metadata.column_widths, false);
text.lines.push(Line::from(Span::raw(row_line)));
}
let bottom_border = generate_table_border(&metadata.column_widths, BorderType::Bottom);
text.lines.push(Line::from(Span::styled(
bottom_border,
Style::default().fg(Color::Gray),
)));
}
text.lines.push(Line::from(""));
}
#[derive(Clone, Copy)]
enum BorderType {
Top,
Separator,
Bottom,
}
fn generate_table_border(column_widths: &[usize], border_type: BorderType) -> String {
let (left, middle, right, fill) = match border_type {
BorderType::Top => ("┌", "┬", "┐", "─"),
BorderType::Separator => ("├", "┼", "┤", "─"),
BorderType::Bottom => ("└", "┴", "┘", "─"),
};
let mut border = String::new();
border.push_str(left);
for (i, &width) in column_widths.iter().enumerate() {
border.push_str(&fill.repeat(width + 2)); if i < column_widths.len() - 1 {
border.push_str(middle);
}
}
border.push_str(right);
border
}
fn render_table_row(cells: &[TableCell], column_widths: &[usize], is_header: bool) -> String {
let mut row = String::new();
row.push('│');
for (i, cell) in cells.iter().enumerate() {
let width = column_widths.get(i).copied().unwrap_or(10);
let aligned_content = align_cell_content(&cell.content, cell.alignment, width);
let formatted_content = if is_header {
aligned_content
} else {
apply_cell_formatting(&aligned_content, &cell.formatting)
};
row.push(' ');
row.push_str(&formatted_content);
row.push(' ');
row.push('│');
}
row
}
fn align_cell_content(content: &str, alignment: TextAlignment, width: usize) -> String {
let trimmed = content.trim();
match alignment {
TextAlignment::Left => format!("{trimmed:<width$}"),
TextAlignment::Right => format!("{trimmed:>width$}"),
TextAlignment::Center => {
let padding = width.saturating_sub(trimmed.len());
let left_pad = padding / 2;
let right_pad = padding - left_pad;
format!(
"{}{}{}",
" ".repeat(left_pad),
trimmed,
" ".repeat(right_pad)
)
}
TextAlignment::Justify => {
format!("{trimmed:<width$}")
}
}
}
fn apply_cell_formatting(content: &str, _formatting: &TextFormatting) -> String {
content.to_string()
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
fn hex_to_color(hex: &str) -> Option<Color> {
let hex = hex.trim_start_matches('#');
if hex.len() != 6 {
return None;
}
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some(Color::Rgb(r, g, b))
}