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. Load more
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)),