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: &mut App, area: Rect) { const SCROLLOFF: usize = 1; let items = app.current_items(); let total = items.len(); let inner_w = area.width.saturating_sub(2) as usize; let inner_h = area.height.saturating_sub(2) as usize; let sel = if total > 0 { app.selected_index.min(total - 1) } else { 0 }; // Wrap each item title into visual lines let item_lines: Vec> = items.iter().map(|item| { let prefix = match &item.kind { crate::menu::MenuItemKind::Category { .. } => "📁 ", crate::menu::MenuItemKind::Action { .. } => "⚡ ", }; wrap_to_lines(&format!("{}{}", prefix, item.title), inner_w) }).collect(); // How many items fit starting from offset let vis_from = |off: usize| -> usize { let mut rows = 0usize; let mut n = 0usize; for lines in item_lines.get(off..).unwrap_or(&[]) { let h = lines.len().max(1); if rows + h > inner_h { break; } rows += h; n += 1; } n }; // Clamp scroll offset for scrolloff margin let offset = &mut app.menu_scroll_offset; if total == 0 { *offset = 0; } else { // Ensure sel is not before offset if *offset > sel { *offset = sel; } // Scroll up: sel must not be within top SCROLLOFF items if sel < offset.saturating_add(SCROLLOFF) && *offset > 0 { *offset = sel.saturating_sub(SCROLLOFF); } // Scroll down: sel must not be within bottom SCROLLOFF items loop { let vis = vis_from(*offset); if vis == 0 { break; } let trigger = offset.saturating_add(vis).saturating_sub(SCROLLOFF); if sel >= trigger && *offset + vis < total { *offset += 1; } else { break; } } // Clamp to valid range let max_off = total.saturating_sub(1); *offset = (*offset).min(max_off); } let start = *offset; let vis = vis_from(start); let end = (start + vis).min(total); let list_items: Vec = item_lines[start..end] .iter() .enumerate() .map(|(i, lines)| { let abs = start + i; let text: Vec = lines.iter().map(|l| Line::from(l.clone())).collect(); let li = ListItem::new(ratatui::text::Text::from(text)); if abs == sel { li.style(Style::default().bg(Color::Blue).add_modifier(Modifier::BOLD)) } else { li } }) .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), ), ); f.render_widget(list, area); if total > vis { let max_off = total.saturating_sub(vis); let scrollbar = Scrollbar::default() .orientation(ScrollbarOrientation::VerticalRight) .begin_symbol(Some("▲")) .end_symbol(Some("▼")) .thumb_symbol("█"); let mut sb_state = ScrollbarState::new(max_off).position(start); let sb_area = Rect { x: area.x + area.width.saturating_sub(1), y: area.y + 1, width: 1, height: area.height.saturating_sub(2), }; f.render_stateful_widget(scrollbar, sb_area, &mut sb_state); } } 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, form_cursor, form_values, log_scroll_pos, log_scroll_x, log_follow_bottom, finished, } => { let cmd_height: u16 = match current_command { Some(executor::StructuredCommand::Menu { options, .. }) => { (options.len() as u16 + 2).min(14) } Some(executor::StructuredCommand::Form { fields, .. }) => { (fields.len() as u16 + 2).min(16) } 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 viewport_w = chunks[0].width.saturating_sub(2) as usize; let max_content_w: usize = log_buffer .iter() .map(|l| ansi::parse_line(l).iter().map(|(_, s)| s.chars().count()).sum::()) .max() .unwrap_or(0); let max_scroll_x = max_content_w.saturating_sub(viewport_w); *log_scroll_x = (*log_scroll_x).min(max_scroll_x); let scroll_x = *log_scroll_x; let log_items: Vec = log_buffer[start..end] .iter() .map(|line| { let spans: Vec = h_scroll(ansi::parse_line(line), scroll_x); ListItem::new(Line::from(spans)) }) .collect(); let at_bottom = pos >= max_pos; let at_top = pos == 0; // Reusable scroll hint based on current position let scroll_hint = if total <= visible { // All content fits — nothing to scroll String::new() } else if at_top && at_bottom { String::new() } else if at_top { format!(" │ PgDn/↓ вниз ({}/{})", end, total) } else if at_bottom { format!(" │ PgUp/↑ вверх ({}/{})", end, total) } else { format!(" │ PgUp ↑ PgDn ↓ ({}/{})", end, total) }; let (title, border_color): (String, Color) = match finished { Some(0) => ( format!(" ✓ Завершено — Esc закрыть{} ", scroll_hint), Color::Green, ), Some(code) => ( format!(" ✗ Ошибка (код {}) — Esc закрыть{} ", code, scroll_hint), 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); } // ── Horizontal scrollbar ───────────────────────────────────────── if max_content_w > viewport_w { let mut h_state = ScrollbarState::new(max_scroll_x).position(scroll_x); let hscroll_area = Rect { x: chunks[0].x + 1, y: chunks[0].y + chunks[0].height.saturating_sub(1), width: chunks[0].width.saturating_sub(2), height: 1, }; f.render_stateful_widget( Scrollbar::new(ScrollbarOrientation::HorizontalBottom) .begin_symbol(Some("◄")) .end_symbol(Some("►")) .thumb_symbol("▄") .track_symbol(Some("─")), hscroll_area, &mut h_state, ); } // ── Interactive command area ───────────────────────────────────── if cmd_height > 0 { if let Some(cmd) = current_command { render_command( f, cmd, input_buffer, *menu_selected_index, *form_cursor, form_values, 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, form_cursor: usize, form_values: &[bool], 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 msg = message.as_deref().unwrap_or(""); match percent { Some(pct) => { let bar_width = area.width.saturating_sub(4) as usize; let filled = (*pct as usize * bar_width) / 100; let bar = format!( "[{}{}] {}%", "█".repeat(filled), "░".repeat(bar_width.saturating_sub(filled)), pct ); 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); } None => { // Indeterminate: spinning braille dots const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; let frame = (std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() / 120) as usize; let spinner = SPINNER[frame % SPINNER.len()]; let line = format!("{} {}", spinner, msg); let paragraph = Paragraph::new(line).block( Block::default() .borders(Borders::ALL) .title(" Прогресс ") .border_style(Style::default().fg(Color::Cyan)), ); f.render_widget(paragraph, area); } } } executor::StructuredCommand::Form { prompt, fields } => { let items: Vec = fields.iter().enumerate().map(|(i, field)| { let checked = form_values.get(i).copied().unwrap_or(false); let icon = match field.field_type { executor::FormFieldType::Checkbox => { if checked { "[✓]" } else { "[ ]" } } executor::FormFieldType::Radio => { if checked { "(●)" } else { "( )" } } }; let is_cursor = i == form_cursor; let prefix = if is_cursor { "▶ " } else { " " }; let style = if is_cursor { Style::default().bg(Color::Blue).add_modifier(Modifier::BOLD) } else { Style::default() }; ListItem::new(Line::from(vec![ Span::raw(format!("{}{} {}", prefix, icon, field.label)), ])).style(style) }).collect(); let mut state = ListState::default(); state.select(Some(form_cursor)); let list = List::new(items).block( Block::default() .borders(Borders::ALL) .title(format!(" {} ", prompt)) .title_bottom( Line::from(Span::styled( " Space — переключить Enter/l — применить Esc — отмена ", Style::default().fg(Color::DarkGray), )).alignment(Alignment::Right), ) .border_style(Style::default().fg(Color::Cyan)), ); f.render_stateful_widget(list, area, &mut state); } // 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] } /// Wrap `text` into lines of at most `max_width` chars (word-wrap, hard-break on long words). fn wrap_to_lines(text: &str, max_width: usize) -> Vec { if max_width == 0 || text.is_empty() { return vec![text.to_string()]; } let mut lines: Vec = Vec::new(); let mut current = String::new(); for word in text.split_whitespace() { let wlen = word.chars().count(); if current.is_empty() { if wlen > max_width { let chars: Vec = word.chars().collect(); for chunk in chars.chunks(max_width) { let s: String = chunk.iter().collect(); if s.chars().count() == max_width { lines.push(s); } else { current = s; } } } else { current = word.to_string(); } } else if current.chars().count() + 1 + wlen <= max_width { current.push(' '); current.push_str(word); } else { lines.push(std::mem::take(&mut current)); current = word.to_string(); } } if !current.is_empty() { lines.push(current); } if lines.is_empty() { lines.push(String::new()); } lines } /// Skip `offset` display chars from the start of a parsed ANSI span list. fn h_scroll(parsed: Vec<(ratatui::style::Style, String)>, offset: usize) -> Vec> { let mut skip = offset; let mut result = Vec::new(); for (style, text) in parsed { if skip == 0 { result.push(Span::styled(text, style)); } else { let len = text.chars().count(); if skip >= len { skip -= len; } else { let trimmed: String = text.chars().skip(skip).collect(); skip = 0; if !trimmed.is_empty() { result.push(Span::styled(trimmed, style)); } } } } result }