use crate::ansi; use crate::executor; use crate::app::{App, MessageLevel, Popup, UpdatingStatus}; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{ Block, Borders, Clear, List, ListItem, ListState, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, Wrap, }, Frame, }; pub fn render(f: &mut Frame, app: &mut App) { let area = f.area(); let chunks = Layout::default() .direction(Direction::Vertical) .margin(1) .constraints([Constraint::Min(3), Constraint::Length(3)].as_ref()) .split(area); render_menu(f, app, chunks[0]); render_description(f, app, chunks[1]); if let Some(popup) = &mut app.popup { render_popup(f, popup); } } fn render_menu(f: &mut Frame, app: &App, area: Rect) { let items = app.current_items(); let list_items: Vec = items .iter() .enumerate() .map(|(i, item)| { let prefix = match &item.kind { crate::menu::MenuItemKind::Category { .. } => "📁 ", crate::menu::MenuItemKind::Action { .. } => "⚡ ", }; let content = Line::from(Span::raw(format!("{}{}", prefix, item.title))); if i == app.selected_index { ListItem::new(content).style(Style::default().bg(Color::Blue)) } else { ListItem::new(content) } }) .collect(); let list = List::new(list_items) .block( Block::default() .borders(Borders::ALL) .title(" Меню ") .title( Line::from(Span::styled( format!(" v{} ", env!("CARGO_PKG_VERSION")), Style::default().fg(Color::DarkGray), )) .alignment(Alignment::Right), ), ) .highlight_style(Style::default().add_modifier(Modifier::BOLD)); f.render_widget(list, area); } fn render_description(f: &mut Frame, app: &App, area: Rect) { let text = if let Some(error) = &app.error { format!("⚠️ Ошибка: {}", error) } else if let Some(item) = app.selected_item() { item.description.as_deref().unwrap_or("Нет описания").to_string() } else { "Выберите пункт меню".to_string() }; let paragraph = Paragraph::new(text) .block(Block::default().borders(Borders::ALL).title("Описание")) .wrap(Wrap { trim: true }); f.render_widget(paragraph, area); } fn render_popup(f: &mut Frame, popup: &mut Popup) { let area = f.area(); // Use a smaller area for update-related popups let popup_area = match popup { Popup::UpdateConfirm { .. } | Popup::Updating { .. } => centered_rect(50, 40, area), _ => centered_rect(80, 90, area), }; f.render_widget(Clear, popup_area); match popup { Popup::Confirming { action: _, item_title, confirm_message, } => { let block = Block::default() .borders(Borders::ALL) .title("Подтверждение") .border_style(Style::default().fg(Color::Yellow)); let text = if let Some(msg) = confirm_message { vec![ Line::from(Span::raw(msg.as_str())), Line::from(""), Line::from(Span::styled( " [Y] Подтвердить [N / Esc] Отмена", Style::default().fg(Color::Yellow), )), ] } else { vec![ Line::from(Span::raw(format!("Запустить '{}'?", item_title))), Line::from(""), Line::from(Span::styled( " [Y] Подтвердить [N / Esc] Отмена", Style::default().fg(Color::Yellow), )), ] }; let paragraph = Paragraph::new(text) .block(block) .alignment(Alignment::Center) .wrap(Wrap { trim: true }); f.render_widget(paragraph, popup_area); } Popup::ExecutingStructured { reply_tx: _, kill_tx: _, log_buffer, current_command, input_buffer, menu_selected_index, log_scroll_pos, log_follow_bottom, finished, } => { let cmd_height: u16 = match current_command { Some(executor::StructuredCommand::Menu { options, .. }) => { (options.len() as u16 + 2).min(14) } Some(_) => 5, None => 0, }; let chunks = Layout::default() .direction(Direction::Vertical) .margin(1) .constraints([Constraint::Min(3), Constraint::Length(cmd_height)].as_ref()) .split(popup_area); // ── Scrollable log ────────────────────────────────────────────── let visible = chunks[0].height.saturating_sub(2) as usize; let total = log_buffer.len(); // Clamp pos so we never show empty lines past the end. let max_pos = total.saturating_sub(visible); if *log_follow_bottom { *log_scroll_pos = max_pos; } else { *log_scroll_pos = (*log_scroll_pos).min(max_pos); } let pos = *log_scroll_pos; let start = pos; let end = (start + visible).min(total); let log_items: Vec = log_buffer[start..end] .iter() .map(|line| { let spans: Vec = ansi::parse_line(line) .into_iter() .map(|(style, text)| Span::styled(text, style)) .collect(); ListItem::new(Line::from(spans)) }) .collect(); let at_bottom = pos >= max_pos; let at_top = pos == 0; let (title, border_color): (String, Color) = match finished { Some(0) => ( " ✓ Завершено — Esc закрыть │ PgUp/↑↓ скролл ".to_string(), Color::Green, ), Some(code) => ( format!(" ✗ Ошибка (код {}) — Esc закрыть │ PgUp/↑↓ скролл ", code), Color::Red, ), None if at_top && at_bottom => ( " Выполняется… ".to_string(), Color::DarkGray, ), None if at_bottom => ( " Выполняется… │ PgUp/↑ прокрутить вверх ".to_string(), Color::DarkGray, ), None => ( format!(" Выполняется… │ {}/{} │ PgDn/↓ вниз ", pos + 1, total), Color::Yellow, ), }; let log_block = Block::default() .borders(Borders::ALL) .title(title.as_str()) .border_style(Style::default().fg(border_color)); f.render_widget(List::new(log_items).block(log_block), chunks[0]); // ── Scrollbar ─────────────────────────────────────────────────── if total > visible { let scrollbar = Scrollbar::default() .orientation(ScrollbarOrientation::VerticalRight) .begin_symbol(Some("▲")) .end_symbol(Some("▼")) .thumb_symbol("█"); let mut sb_state = ScrollbarState::new(max_pos).position(pos); let sb_area = Rect { x: chunks[0].x + chunks[0].width.saturating_sub(1), y: chunks[0].y + 1, width: 1, height: chunks[0].height.saturating_sub(2), }; f.render_stateful_widget(scrollbar, sb_area, &mut sb_state); } // ── Interactive command area ───────────────────────────────────── if cmd_height > 0 { if let Some(cmd) = current_command { render_command(f, cmd, input_buffer, *menu_selected_index, chunks[1]); } } } Popup::Message { text, level } => { let color = match level { MessageLevel::Info => Color::Green, MessageLevel::Warn => Color::Yellow, MessageLevel::Error => Color::Red, }; let block = Block::default() .borders(Borders::ALL) .title("Сообщение") .border_style(Style::default().fg(color)); let paragraph = Paragraph::new(text.as_str()) .block(block) .alignment(Alignment::Center) .wrap(Wrap { trim: true }); f.render_widget(paragraph, popup_area); } Popup::UpdateConfirm { info } => { let mut lines = vec![ Line::from(""), Line::from(Span::raw(format!( " {} → {}", info.current_version, info.new_version ))), ]; if info.size > 0 { let mb = info.size as f64 / 1_048_576.0; lines.push(Line::from(Span::raw(format!(" Размер: {:.1} МБ", mb)))); } lines.push(Line::from("")); lines.push(Line::from(Span::styled( " [Y] Обновить [N / Esc] Пропустить", Style::default().fg(Color::Yellow), ))); let block = Block::default() .borders(Borders::ALL) .title(" Доступно обновление ") .border_style(Style::default().fg(Color::Cyan)); let paragraph = Paragraph::new(lines) .block(block) .wrap(Wrap { trim: true }); f.render_widget(paragraph, popup_area); } Popup::Updating { info, downloaded, status, } => { let (title, border_color) = match status { UpdatingStatus::Downloading => (" Загрузка обновления… ", Color::Cyan), UpdatingStatus::Applying => (" Применение обновления… ", Color::Yellow), UpdatingStatus::Done => (" Обновление завершено ", Color::Green), UpdatingStatus::Failed(_) => (" Ошибка обновления ", Color::Red), }; let bar_width = popup_area.width.saturating_sub(4) as usize; let progress_line = if info.size > 0 { let percent = ((*downloaded as f64 / info.size as f64) * 100.0) .min(100.0) as usize; let filled = (percent * bar_width) / 100; format!( "[{}{}] {}%", "█".repeat(filled), "░".repeat(bar_width.saturating_sub(filled)), percent ) } else { // Indeterminate: animate based on downloaded bytes let pos = ((*downloaded / 4096) as usize) % bar_width.max(1); let thumb = 4.min(bar_width); let mut bar = vec!['░'; bar_width]; for i in pos..((pos + thumb).min(bar_width)) { bar[i] = '█'; } format!("[{}]", bar.iter().collect::()) }; let status_text = match status { UpdatingStatus::Downloading => { if info.size > 0 { let mb_done = *downloaded as f64 / 1_048_576.0; let mb_total = info.size as f64 / 1_048_576.0; format!(" {:.1} / {:.1} МБ", mb_done, mb_total) } else { let kb = *downloaded / 1024; format!(" {} КБ загружено", kb) } } UpdatingStatus::Applying => " Применяется…".to_string(), UpdatingStatus::Done => " Обновление успешно установлено. Перезапуск…".to_string(), UpdatingStatus::Failed(msg) => format!(" Ошибка: {}", msg), }; let mut lines = vec![ Line::from(""), Line::from(Span::raw(progress_line)), Line::from(""), Line::from(Span::raw(status_text)), ]; if matches!(status, UpdatingStatus::Failed(_)) { lines.push(Line::from("")); lines.push(Line::from(Span::styled( " [Esc] Закрыть", Style::default().fg(Color::Yellow), ))); } let block = Block::default() .borders(Borders::ALL) .title(title) .border_style(Style::default().fg(border_color)); let paragraph = Paragraph::new(lines) .block(block) .wrap(Wrap { trim: true }); f.render_widget(paragraph, popup_area); } _ => {} } } fn render_command( f: &mut Frame, cmd: &executor::StructuredCommand, input_buffer: &str, menu_selected_index: usize, area: Rect, ) { match cmd { executor::StructuredCommand::Input { prompt, secret, .. } => { let display = if *secret { "•".repeat(input_buffer.chars().count()) } else { input_buffer.to_string() }; let paragraph = Paragraph::new(display.as_str()).block( Block::default() .borders(Borders::ALL) .title(format!(" {} ", prompt)) .border_style(Style::default().fg(Color::Cyan)), ); f.render_widget(paragraph, area); let cursor_x = (area.x + 1 + input_buffer.chars().count() as u16) .min(area.x + area.width.saturating_sub(2)); f.set_cursor_position((cursor_x, area.y + 1)); } executor::StructuredCommand::Menu { prompt, options } => { let items: Vec = options .iter() .enumerate() .map(|(i, opt)| { if i == menu_selected_index { ListItem::new(Line::from(vec![ Span::styled(" ▶ ", Style::default().fg(Color::Cyan)), Span::styled( opt.label.as_str(), Style::default() .bg(Color::Blue) .add_modifier(Modifier::BOLD), ), ])) } else { ListItem::new(Line::from(vec![ Span::raw(" "), Span::raw(opt.label.as_str()), ])) } }) .collect(); let mut state = ListState::default(); state.select(Some(menu_selected_index)); let list = List::new(items).block( Block::default() .borders(Borders::ALL) .title(format!(" {} ", prompt)) .title_bottom( Line::from(Span::styled( " ↑↓ навигация Enter выбор ", Style::default().fg(Color::DarkGray), )) .alignment(Alignment::Right), ) .border_style(Style::default().fg(Color::Cyan)), ); f.render_stateful_widget(list, area, &mut state); } executor::StructuredCommand::Confirm { prompt } => { let text = vec![ Line::from(Span::raw(prompt.as_str())), Line::from(""), Line::from(Span::styled( " [Y] Да [N / Esc] Нет", Style::default().fg(Color::Yellow), )), ]; let paragraph = Paragraph::new(text) .block( Block::default() .borders(Borders::ALL) .title(" Подтверждение ") .border_style(Style::default().fg(Color::Yellow)), ) .wrap(Wrap { trim: true }); f.render_widget(paragraph, area); } executor::StructuredCommand::Message { level, text } => { let color = match level { executor::MessageLevel::Info => Color::Green, executor::MessageLevel::Warn => Color::Yellow, executor::MessageLevel::Error => Color::Red, }; let lines = vec![ Line::from(Span::styled(text.as_str(), Style::default().fg(color))), Line::from(""), Line::from(Span::styled( " Нажмите Enter для продолжения", Style::default().fg(Color::DarkGray), )), ]; let paragraph = Paragraph::new(lines) .block( Block::default() .borders(Borders::ALL) .title(" Сообщение ") .border_style(Style::default().fg(color)), ) .wrap(Wrap { trim: true }); f.render_widget(paragraph, area); } executor::StructuredCommand::Progress { percent, message } => { let bar_width = area.width.saturating_sub(4) as usize; let filled = (*percent as usize * bar_width) / 100; let bar = format!( "[{}{}] {}%", "█".repeat(filled), "░".repeat(bar_width.saturating_sub(filled)), percent ); let msg = message.as_deref().unwrap_or(""); let paragraph = Paragraph::new(vec![Line::from(bar), Line::from(msg)]).block( Block::default() .borders(Borders::ALL) .title(" Прогресс ") .border_style(Style::default().fg(Color::Cyan)), ); f.render_widget(paragraph, area); } // Exec is handled by the main loop before rendering; this arm is // never reached in practice but satisfies the exhaustiveness check. executor::StructuredCommand::Exec { shell } => { let paragraph = Paragraph::new(format!("Запуск: {}", shell)) .block( Block::default() .borders(Borders::ALL) .title(" Внешнее приложение ") .border_style(Style::default().fg(Color::Magenta)), ) .wrap(Wrap { trim: true }); f.render_widget(paragraph, area); } } } 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), ] .as_ref(), ) .split(r); Layout::default() .direction(Direction::Horizontal) .constraints( [ Constraint::Percentage((100 - percent_x) / 2), Constraint::Percentage(percent_x), Constraint::Percentage((100 - percent_x) / 2), ] .as_ref(), ) .split(popup_layout[1])[1] }