1209 lines · 42.1 KB · Rust
RawDownload
mod neon;pub mod markdown;pub mod theme; use ratatui::{    layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},    style::{Color, Modifier, Style},    text::{Line, Span},    widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},    Frame,};use unicode_width::UnicodeWidthStr; use crate::app::{App, Focus, PanelMode, ViewMode};use crate::mascot;use neon::NeonBorder;use markdown::StyledMarkdown; // Tokyo Night color palette (made pub for markdown module)pub const AZUL_BLUE: Color = Color::Rgb(0, 191, 255);pub const TOKYO_BLUE: Color = Color::Rgb(122, 162, 247);pub const TOKYO_PURPLE: Color = Color::Rgb(187, 154, 247);pub const TOKYO_ORANGE: Color = Color::Rgb(255, 169, 0);pub const TOKYO_GREEN: Color = Color::Rgb(158, 206, 106);pub const TOKYO_RED: Color = Color::Rgb(247, 118, 142);pub const TOKYO_TEXT: Color = Color::Rgb(192, 202, 245);pub const TOKYO_COMMENT: Color = Color::Rgb(125, 137, 168);pub const TOKYO_BG: Color = Color::Rgb(26, 27, 38);pub const TOKYO_CYAN: Color = Color::Rgb(125, 207, 255); pub fn render(frame: &mut Frame, app: &mut App) {    let full = frame.area();    if full.width < 4 || full.height < 4 {        return;    }     // Clear entire frame first to prevent artifacts when switching modes    frame.render_widget(Clear, full);     // Render neon border    frame.render_widget(        NeonBorder {            phase: app.animation_tick,        },        full,    );     let inner = full.inner(Margin {        horizontal: 1,        vertical: 1,    });     // Main layout: Tab Bar | URL Bar | Content | Status    let chunks = Layout::default()        .direction(Direction::Vertical)        .constraints([            Constraint::Length(1),   // Tab bar            Constraint::Length(3),   // URL bar            Constraint::Min(0),      // Main content            Constraint::Length(3),   // Status bar        ])        .split(inner);     render_tab_bar(frame, chunks[0], app);    render_url_bar(frame, chunks[1], app);     // Always show 3-panel layout: Links (15%) | Content (50%) | Chat (35%)    let split = Layout::default()        .direction(Direction::Horizontal)        .constraints([            Constraint::Percentage(15),  // Links (left)            Constraint::Percentage(50),  // Content (middle)            Constraint::Percentage(35),  // Chat (right)        ])        .split(chunks[2]);    render_compact_sidebar(frame, split[0], app);    render_content_only(frame, split[1], app);    render_chat_side_panel(frame, split[2], app);     render_status_bar(frame, chunks[3], app);     // Render overlay panels (not chat anymore)    match app.panel_mode {        PanelMode::Bookmarks => render_bookmarks_panel(frame, inner, app),        PanelMode::History => render_history_panel(frame, inner, app),        PanelMode::Help => render_help_panel(frame, inner, app),        PanelMode::Chat => {} // Handled above as side panel        PanelMode::Settings => render_settings_panel(frame, inner, app),        PanelMode::Rag => render_rag_panel(frame, inner, app),        PanelMode::Memory => render_memory_panel(frame, inner, app),        PanelMode::None => {}    }} fn render_tab_bar(frame: &mut Frame, area: Rect, app: &App) {    let tabs = app.tabs.tabs();    let active = app.tabs.active_index();     let mut spans = vec![];     for (i, tab) in tabs.iter().enumerate() {        let is_active = i == active;        let title = tab.display_title(15);         let style = if is_active {            Style::default().fg(TOKYO_BG).bg(AZUL_BLUE).add_modifier(Modifier::BOLD)        } else {            Style::default().fg(TOKYO_TEXT)        };         // Tab number        spans.push(Span::styled(format!(" {} ", i + 1), Style::default().fg(TOKYO_COMMENT)));        // Tab title        spans.push(Span::styled(title, style));         if tab.loading {            spans.push(Span::styled(" *", Style::default().fg(TOKYO_ORANGE)));        }         spans.push(Span::raw(" |"));    }     // Add new tab hint if not full    if !app.tabs.is_full() {        spans.push(Span::styled(" + (t)", Style::default().fg(TOKYO_COMMENT)));    }     let line = Line::from(spans);    let paragraph = Paragraph::new(line)        .style(Style::default().fg(TOKYO_TEXT));     frame.render_widget(paragraph, area);} fn render_title(frame: &mut Frame, area: Rect, app: &App) {    // Check if current page is bookmarked    let bookmark_indicator = if app.is_current_bookmarked() {        Span::styled(" ★", Style::default().fg(TOKYO_ORANGE))    } else {        Span::raw("")    };     // Companion in title - always visible    let companion = app.mascot.view();     let title = vec![        Span::styled(companion, Style::default().fg(AZUL_BLUE)),        Span::raw(" "),        Span::styled("azul", Style::default().fg(AZUL_BLUE).add_modifier(Modifier::BOLD)),        bookmark_indicator,    ];     let title_paragraph = Paragraph::new(Line::from(title))        .block(            Block::default()                .borders(Borders::ALL)                .border_style(Style::default().fg(TOKYO_BLUE)),        )        .style(Style::default().fg(TOKYO_TEXT));     frame.render_widget(title_paragraph, area);} fn render_url_bar(frame: &mut Frame, area: Rect, app: &App) {    let is_focused = app.focus == Focus::URLBar;    let border_color = if is_focused { AZUL_BLUE } else { TOKYO_BLUE };     // Companion appears in URL bar when active    let companion_prefix = match app.mascot.state() {        mascot::CompanionState::Loading | mascot::CompanionState::Searching => {            format!("{} ", app.mascot.view())        }        _ => String::new(),    };     let display_text = if is_focused {        format!("{}{}_", companion_prefix, app.url_input)    } else if let Some(page) = app.current_page() {        format!("{}{}", companion_prefix, page.url)    } else {        format!("{}/ to search...", companion_prefix)    };     let url_paragraph = Paragraph::new(display_text)        .block(            Block::default()                .borders(Borders::ALL)                .border_style(Style::default().fg(border_color))                .title(if is_focused { " URL (editing) " } else { " URL " }),        )        .style(if is_focused {            Style::default().fg(AZUL_BLUE).add_modifier(Modifier::BOLD)        } else {            Style::default().fg(TOKYO_TEXT)        });     frame.render_widget(url_paragraph, area);} fn render_main_content(frame: &mut Frame, area: Rect, app: &mut App) {    // Split into content and sidebar    let has_sidebar = app.current_page().map(|p| !p.links.is_empty()).unwrap_or(false);     if has_sidebar {        let chunks = Layout::default()            .direction(Direction::Horizontal)            .constraints([                Constraint::Percentage(70),                Constraint::Percentage(30),            ])            .split(area);         render_content_area(frame, chunks[0], app);        render_sidebar(frame, chunks[1], app);    } else {        render_content_area(frame, area, app);    }} /// Render only content without sidebar (used when chat is open)fn render_content_only(frame: &mut Frame, area: Rect, app: &App) {    render_content_area(frame, area, app);} fn render_content_area(frame: &mut Frame, area: Rect, app: &App) {    let is_focused = app.focus == Focus::Content;    let border_color = if is_focused { AZUL_BLUE } else { TOKYO_BLUE };     let mode_label = match app.view_mode {        ViewMode::Rendered => "content",        ViewMode::Raw => "raw",    };     let block = Block::default()        .borders(Borders::ALL)        .border_style(Style::default().fg(border_color))        .title(if is_focused {            format!(" {} ", mode_label.to_uppercase())        } else {            format!(" {} ", mode_label)        })        .style(Style::default());     if let Some(page) = app.current_page() {        let inner = area.inner(Margin { horizontal: 1, vertical: 1 });        let visible_height = inner.height as usize;        let wrap_width = inner.width.saturating_sub(2).max(20) as usize;         let mut lines: Vec<String> = match app.view_mode {            ViewMode::Rendered => page.content_lines.clone(),            ViewMode::Raw => page.raw_content.lines().map(|s| s.to_string()).collect(),        };         if app.view_mode == ViewMode::Rendered && app.format_text {            lines = format_lines(&lines, wrap_width);        }         // Prepend AI summary if available        if let Some(summary) = &app.ai_summary {            let mut summary_lines = vec![                "# AI Summary".to_string(),                String::new(),            ];            for wrapped in textwrap::wrap(summary, wrap_width) {                summary_lines.push(wrapped.into_owned());            }            summary_lines.push(String::new());            summary_lines.push("---".to_string());            summary_lines.push(String::new());             let mut combined = summary_lines;            combined.extend(lines);            lines = combined;        }         let total_lines = lines.len().max(1);        let scroll = app.scroll_offset();        let start = scroll.min(total_lines.saturating_sub(1));        let end = (start + visible_height).min(total_lines);         // Use styled markdown rendering for rendered view        let visible_lines: Vec<Line> = if app.view_mode == ViewMode::Rendered {            let md_renderer = StyledMarkdown::new(wrap_width);            let styled = md_renderer.render(&lines[start..end].to_vec());            styled        } else {            // Raw view - plain text            lines[start..end]                .iter()                .map(|line| Line::from(line.clone()))                .collect()        };         let content = Paragraph::new(visible_lines)            .block(block)            .style(Style::default().fg(TOKYO_TEXT))            .wrap(Wrap { trim: false });         frame.render_widget(content, area);    } else {        // Show splash with mascot when no page loaded        let splash_text = mascot::mini_splash();        let placeholder = Paragraph::new(splash_text)            .block(block)            .style(Style::default().fg(AZUL_BLUE))            .alignment(Alignment::Center);         frame.render_widget(placeholder, area);    }} fn render_sidebar(frame: &mut Frame, area: Rect, app: &mut App) {    let is_focused = app.focus == Focus::Sidebar;    let border_color = if is_focused { TOKYO_ORANGE } else { TOKYO_BLUE };     let total_links = app.current_page().map(|p| p.links.len()).unwrap_or(0);    let selected = app.sidebar_selected();     let position = if total_links == 0 { 0 } else { selected + 1 };     let title = if is_focused {        format!(" LINKS {}/{} ", position, total_links)    } else {        format!(" links {}/{} ", position, total_links)    };     let block = Block::default()        .borders(Borders::ALL)        .border_style(Style::default().fg(border_color))        .title(title);     if let Some(page) = app.current_page() {        let inner = area.inner(Margin { horizontal: 1, vertical: 1 });        let mut visible_rows = inner.height.saturating_sub(1) as usize;        if visible_rows == 0 {            visible_rows = 1;        }         // Calculate offset for scrolling        let mut offset = 0;        if selected >= visible_rows {            offset = selected + 1 - visible_rows;        }         let width_for_text = inner.width.saturating_sub(6) as usize;         let items: Vec<ListItem> = page            .links            .iter()            .enumerate()            .skip(offset)            .take(visible_rows)            .map(|(i, link)| {                let primary = if link.text.trim().is_empty() {                    link.url.clone()                } else {                    link.text.clone()                };                 let truncated = truncate_to_width(&primary, width_for_text);                let number = format!("{:2}.", i + 1);                 let is_selected = i == selected;                let style = if is_selected && is_focused {                    Style::default().fg(TOKYO_BG).bg(TOKYO_ORANGE).add_modifier(Modifier::BOLD)                } else if is_selected {                    Style::default().fg(TOKYO_ORANGE).add_modifier(Modifier::BOLD)                } else {                    Style::default().fg(TOKYO_TEXT)                };                 ListItem::new(Line::from(vec![                    Span::styled(number, Style::default().fg(TOKYO_COMMENT)),                    Span::raw(" "),                    Span::styled(truncated, style),                ]))            })            .collect();         let list = List::new(items)            .block(block)            .style(Style::default().fg(TOKYO_TEXT));         frame.render_widget(list, area);    } else {        frame.render_widget(block, area);    }} /// Compact sidebar for when chat is open (narrow links panel)fn render_compact_sidebar(frame: &mut Frame, area: Rect, app: &App) {    let is_focused = app.focus == Focus::Sidebar;     // Animated green-blue border when focused    let border_color = if is_focused {        // Cycle between green and blue based on animation        let colors = [            Color::Rgb(148, 226, 213),  // Teal            Color::Rgb(137, 180, 250),  // Blue            Color::Rgb(166, 227, 161),  // Green            Color::Rgb(116, 199, 236),  // Cyan        ];        colors[app.animation_tick % colors.len()]    } else {        TOKYO_BLUE    };     let total_links = app.current_page().map(|p| p.links.len()).unwrap_or(0);    let selected = app.sidebar_selected();     let title = format!(" {} ", total_links);     let block = Block::default()        .borders(Borders::ALL)        .border_style(Style::default().fg(border_color))        .title(title)        .style(Style::default());     if let Some(page) = app.current_page() {        let inner = area.inner(Margin { horizontal: 1, vertical: 1 });        let mut visible_rows = inner.height.saturating_sub(1) as usize;        if visible_rows == 0 {            visible_rows = 1;        }         // Calculate offset for scrolling        let mut offset = 0;        if selected >= visible_rows {            offset = selected + 1 - visible_rows;        }         // Show link number and truncated text        let max_text_width = inner.width.saturating_sub(5) as usize; // Leave room for number and space        let items: Vec<ListItem> = page            .links            .iter()            .enumerate()            .skip(offset)            .take(visible_rows)            .map(|(i, link)| {                let is_selected = i == selected;                let style = if is_selected && is_focused {                    Style::default().fg(TOKYO_BG).bg(TOKYO_ORANGE).add_modifier(Modifier::BOLD)                } else if is_selected {                    Style::default().fg(TOKYO_ORANGE).add_modifier(Modifier::BOLD)                } else {                    Style::default().fg(TOKYO_TEXT)                };                 // Show number + truncated link text                let text = if link.text.is_empty() { &link.url } else { &link.text };                let truncated = truncate_to_width(text, max_text_width);                ListItem::new(Line::from(Span::styled(format!("{:2} {}", i + 1, truncated), style)))            })            .collect();         let list = List::new(items)            .block(block)            .style(Style::default().fg(TOKYO_TEXT));         frame.render_widget(list, area);    } else {        frame.render_widget(block, area);    }} fn render_status_bar(frame: &mut Frame, area: Rect, app: &mut App) {    let focus_name = match app.focus {        Focus::Content => "Content",        Focus::Sidebar => "Sidebar",        Focus::URLBar => "URL Bar",        Focus::TabBar => "Tab Bar",        Focus::Bookmarks => "Bookmarks",        Focus::History => "History",    };     // Tab info    let tab_info = format!("[{}/{}]", app.tabs.active_index() + 1, app.tabs.count());     // Status indicator    let companion = app.mascot.view_status();     let primary_line = Line::from(vec![        Span::styled(companion, Style::default().fg(AZUL_BLUE)),        Span::raw(" "),        Span::styled(tab_info, Style::default().fg(TOKYO_PURPLE)),        Span::raw(" "),        Span::styled(focus_name, Style::default().fg(AZUL_BLUE).add_modifier(Modifier::BOLD)),        Span::raw(" "),        Span::styled(&app.status_message, Style::default().fg(TOKYO_TEXT)),    ]);     // Update mascot position    app.mascot.set_screen_width(area.width.saturating_sub(4));    let mascot_sprite = app.mascot.walking_sprite();     // Shortcuts line with cute mascot at the end    let shortcuts = "/ search  t tab  b bookmarks  H history  c chat  ? help  q quit";    let secondary_line = Line::from(vec![        Span::styled(shortcuts, Style::default().fg(TOKYO_COMMENT)),

Lines 1–500 of 1209.