use crate::ansi; use crate::config::Config; use crate::executor; use crate::app::{App, MessageLevel, Popup, UpdatingStatus}; use crate::text_input::TextInput; 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, &app.config); } } 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 }; 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(); 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 }; let offset = &mut app.menu_scroll_offset; if total == 0 { *offset = 0; } else { if *offset > sel { *offset = sel; } if sel < offset.saturating_add(SCROLLOFF) && *offset > 0 { *offset = sel.saturating_sub(SCROLLOFF); } 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; } } 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 endpoint_title = app.config.endpoints.iter() .find(|ep| ep.url == app.config.active_endpoint) .map(|ep| format!(" {} ", ep.name)) .unwrap_or_else(|| " Меню ".to_string()); let list = List::new(list_items).block( Block::default() .borders(Borders::ALL) .title(endpoint_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, config: &Config) { let area = f.area(); 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), )), ] }; f.render_widget( Paragraph::new(text).block(block).alignment(Alignment::Center).wrap(Wrap { trim: true }), popup_area, ); } Popup::ExecutingStructured { reply_tx: _, kill_tx: _, log_buffer, current_command, input, 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); let visible = chunks[0].height.saturating_sub(2) as usize; let total = log_buffer.len(); 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; let scroll_hint = if total <= visible { 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]); 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); } 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, ); } if cmd_height > 0 { if let Some(cmd) = current_command { render_command(f, cmd, input, *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)); f.render_widget( Paragraph::new(text.as_str()).block(block).alignment(Alignment::Center).wrap(Wrap { trim: true }), 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)); f.render_widget(Paragraph::new(lines).block(block).wrap(Wrap { trim: true }), 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 { 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 { format!(" {} КБ загружено", *downloaded / 1024) } } 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)); f.render_widget(Paragraph::new(lines).block(block).wrap(Wrap { trim: true }), popup_area); } Popup::EndpointSelector { selected, scroll_offset } => { let popup_area = centered_rect(70, 60, area); f.render_widget(Clear, popup_area); let endpoints = &config.endpoints; let total = endpoints.len(); let inner_h = popup_area.height.saturating_sub(2) as usize; let name_col_w = endpoints.iter() .map(|ep| ep.name.chars().count()) .max() .unwrap_or(8) .max(8); if *selected < *scroll_offset { *scroll_offset = *selected; } else if inner_h > 0 && *selected >= *scroll_offset + inner_h { *scroll_offset = selected.saturating_sub(inner_h - 1); } let start = *scroll_offset; let end = (start + inner_h).min(total); let items: Vec = endpoints.get(start..end).unwrap_or(&[]) .iter() .enumerate() .map(|(i, ep)| { let abs = start + i; let is_selected = abs == *selected; let is_active = ep.url == config.active_endpoint; let prefix = if is_selected { "▶ " } else { " " }; let n = ep.name.chars().count(); let pad = if n < name_col_w { " ".repeat(name_col_w - n) } else { String::new() }; let (bg, name_fg, url_fg) = if is_selected { (Color::Blue, Color::White, Color::Gray) } else { (Color::Reset, Color::White, Color::DarkGray) }; let name_style = if is_active { Style::default().fg(name_fg).bg(bg).add_modifier(Modifier::BOLD) } else { Style::default().fg(name_fg).bg(bg) }; ListItem::new(Line::from(vec![ Span::styled(prefix, Style::default().bg(bg)), Span::styled(format!("{}{}", ep.name, pad), name_style), Span::styled(" ", Style::default().bg(bg)), Span::styled(ep.url.clone(), Style::default().fg(url_fg).bg(bg)), ])) }) .collect(); let active_name = config.endpoints.iter() .find(|ep| ep.url == config.active_endpoint) .map(|ep| ep.name.as_str()) .unwrap_or("—"); let block = Block::default() .borders(Borders::ALL) .title(format!(" Эндпоинты [активен: {}] ", active_name)) .title( Line::from(Span::styled(" [Esc] ", Style::default().fg(Color::DarkGray))) .alignment(Alignment::Right), ) .title_bottom( Line::from(Span::styled( " [N] добавить [E] редактировать [D] удалить ", Style::default().fg(Color::DarkGray), )) .alignment(Alignment::Right), ) .border_style(Style::default().fg(Color::Cyan)); f.render_widget(List::new(items).block(block), popup_area); if total > inner_h { let max_off = total.saturating_sub(inner_h); 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: popup_area.x + popup_area.width.saturating_sub(1), y: popup_area.y + 1, width: 1, height: popup_area.height.saturating_sub(2), }; f.render_stateful_widget(scrollbar, sb_area, &mut sb_state); } } Popup::UpsertEndpoint { edit_index, fields, active_field, error, .. } => { let popup_area = centered_rect(60, 50, area); f.render_widget(Clear, popup_area); let chunks = Layout::default() .direction(Direction::Vertical) .margin(1) .constraints([ Constraint::Length(3), Constraint::Length(3), Constraint::Length(2), Constraint::Min(0), ]) .split(popup_area); render_text_field(f, " Название ", &fields[0], *active_field == 0, chunks[0]); render_text_field(f, " URL ", &fields[1], *active_field == 1, chunks[1]); if let Some(err) = error { f.render_widget( Paragraph::new(err.as_str()) .style(Style::default().fg(Color::Red)) .wrap(Wrap { trim: true }), chunks[2], ); } // Real terminal cursor in the active field let af = *active_field; let cx = (chunks[af].x + 1 + fields[af].cursor as u16) .min(chunks[af].x + chunks[af].width.saturating_sub(2)); f.set_cursor_position((cx, chunks[af].y + 1)); let (title, hint) = if edit_index.is_some() { (" Редактировать эндпоинт ", " Enter — далее / сохранить ") } else { (" Добавить эндпоинт ", " Enter — далее / добавить ") }; f.render_widget( Block::default() .borders(Borders::ALL) .title(title) .title( Line::from(Span::styled(" [Esc] ", Style::default().fg(Color::DarkGray))) .alignment(Alignment::Right), ) .title_bottom( Line::from(Span::styled(hint, Style::default().fg(Color::DarkGray))) .alignment(Alignment::Right), ) .border_style(Style::default().fg(Color::Cyan)), popup_area, ); } Popup::ConfirmDeleteEndpoint { index, .. } => { let popup_area = centered_rect(50, 30, area); f.render_widget(Clear, popup_area); let name = config.endpoints.get(*index).map(|ep| ep.name.as_str()).unwrap_or("?"); let text = vec![ Line::from(""), Line::from(Span::raw(format!(" Удалить эндпоинт «{}»?", name))), Line::from(""), Line::from(Span::styled(" [Y] Да [N / Esc] Нет", Style::default().fg(Color::Yellow))), ]; f.render_widget( Paragraph::new(text) .block(Block::default().borders(Borders::ALL).title(" Подтверждение ") .border_style(Style::default().fg(Color::Yellow))) .wrap(Wrap { trim: true }), popup_area, ); } _ => {} } } /// Renders a labelled text field with colored border for active/inactive state. fn render_text_field(f: &mut Frame, label: &str, input: &TextInput, is_active: bool, area: Rect) { let color = if is_active { Color::Cyan } else { Color::DarkGray }; f.render_widget( Paragraph::new(input.buf.as_str()).block( Block::default().borders(Borders::ALL).title(label) .border_style(Style::default().fg(color)), ), area, ); } fn render_command( f: &mut Frame, cmd: &executor::StructuredCommand, input: &TextInput, 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.buf.chars().count()) } else { input.buf.clone() }; f.render_widget( Paragraph::new(display.as_str()).block( Block::default().borders(Borders::ALL).title(format!(" {} ", prompt)) .border_style(Style::default().fg(Color::Cyan)), ), area, ); let cursor_x = (area.x + 1 + input.cursor 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)); f.render_stateful_widget( 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)), ), area, &mut state, ); } executor::StructuredCommand::Confirm { prompt } => { f.render_widget( Paragraph::new(vec![ Line::from(Span::raw(prompt.as_str())), Line::from(""), Line::from(Span::styled(" [Y] Да [N / Esc] Нет", Style::default().fg(Color::Yellow))), ]) .block(Block::default().borders(Borders::ALL).title(" Подтверждение ") .border_style(Style::default().fg(Color::Yellow))) .wrap(Wrap { trim: true }), 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, }; f.render_widget( Paragraph::new(vec![ Line::from(Span::styled(text.as_str(), Style::default().fg(color))), Line::from(""), Line::from(Span::styled(" Нажмите Enter для продолжения", Style::default().fg(Color::DarkGray))), ]) .block(Block::default().borders(Borders::ALL).title(" Сообщение ") .border_style(Style::default().fg(color))) .wrap(Wrap { trim: true }), 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 ); f.render_widget( Paragraph::new(vec![Line::from(bar), Line::from(msg)]) .block(Block::default().borders(Borders::ALL).title(" Прогресс ") .border_style(Style::default().fg(Color::Cyan))), area, ); } None => { 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()]; f.render_widget( Paragraph::new(format!("{} {}", spinner, msg)) .block(Block::default().borders(Borders::ALL).title(" Прогресс ") .border_style(Style::default().fg(Color::Cyan))), 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)); f.render_stateful_widget( 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)), ), area, &mut state, ); } executor::StructuredCommand::Exec { shell } => { f.render_widget( Paragraph::new(format!("Запуск: {}", shell)) .block(Block::default().borders(Borders::ALL).title(" Внешнее приложение ") .border_style(Style::default().fg(Color::Magenta))) .wrap(Wrap { trim: true }), 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] } 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 } 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 }