/
nkolentcev
/
devscripts
Обзор
Документация
Войти
/
nkolentcev
/
devscripts
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
onec/tui/src/ui.rs
592 строки
21 KB
Nikolay Kolentcev
orc: распознавание полных дистрибутивов + модалка прогресса с потоком
01 май 2026, 17:43
01 май 2026, 17:43
66d9872
Код
Авторство
О чём код?
use std::io::{self, stdout}; use std::path::PathBuf; use std::time::Duration; use crossterm::event::{self, Event, KeyCode, KeyEventKind}; use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, Gauge, List, ListItem, Paragraph}; use ratatui::{Frame, Terminal}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::app::{App, Focus, Kind, LineFilter, Phase}; pub fn run(app: &mut App) -> io::Result<()> { enable_raw_mode()?; let mut stdout = stdout(); execute!(stdout, EnterAlternateScreen)?; let backend = ratatui::backend::CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; loop { // Опрос фоновой загрузки: дренируем канал прогресса, собираем результат при завершении. app.poll_downloads(); terminal.draw(|f| draw(f, app))?; // Тик короче во время загрузки — модалка чаще обновляется. let tick = if app.download.is_some() { Duration::from_millis(80) } else { Duration::from_millis(250) }; if event::poll(tick)? { if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Release { continue; } if handle_key(app, key.code)? { break; } } } } disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; terminal.show_cursor()?; Ok(()) } fn draw(f: &mut Frame, app: &mut App) { let area = f.area(); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(2), Constraint::Min(8), Constraint::Length(2), ]) .split(area); let header = Paragraph::new(Line::from(vec![ Span::styled( " orc ", Style::default() .fg(Color::Black) .bg(Color::Cyan) .add_modifier(Modifier::BOLD), ), Span::raw(" releases.1c.ru · Yazi-подобная навигация"), ])); f.render_widget(header, chunks[0]); let cols = Layout::default() .direction(Direction::Horizontal) .constraints([ Constraint::Percentage(24), Constraint::Percentage(41), Constraint::Percentage(35), ]) .split(chunks[1]); draw_left(f, app, cols[0]); draw_center(f, app, cols[1]); draw_right(f, app, cols[2]); let footer = Paragraph::new(truncate(&app.status, area.width as usize)) .style(Style::default().fg(Color::Yellow)); f.render_widget(footer, chunks[2]); if app.download.is_some() { draw_download_modal(f, app, area); } } fn draw_download_modal(f: &mut Frame, app: &App, area: Rect) { let Some(dj) = app.download.as_ref() else { return; }; let modal = centered_rect(60, 9, area); f.render_widget(Clear, modal); let block = Block::default() .borders(Borders::ALL) .border_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) .title(Span::styled( " Загрузка ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), )); let inner = block.inner(modal); f.render_widget(block, modal); let layout = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), ]) .split(inner); let queue_text = format!( "Готово: {}/{}{}", dj.files_done, dj.files_done.max(dj.current_idx + 1), if !dj.errors.is_empty() { format!(" ошибок: {}", dj.errors.len()) } else { String::new() } ); f.render_widget(Paragraph::new(queue_text), layout[0]); let cur_name = if dj.current_name.is_empty() { "ожидание ссылок…".to_string() } else { truncate(&dj.current_name, inner.width as usize) }; f.render_widget(Paragraph::new(format!("Файл: {}", cur_name)), layout[1]); let bytes_text = match dj.current_total { Some(t) if t > 0 => format!("{} / {}", human_bytes(dj.current_bytes), human_bytes(t)), _ => human_bytes(dj.current_bytes), }; let elapsed = dj.started.elapsed().as_secs_f64().max(0.001); let speed = (dj.current_bytes as f64 / elapsed) as u64; f.render_widget( Paragraph::new(format!("{} ({}/s)", bytes_text, human_bytes(speed))), layout[2], ); if let Some(t) = dj.current_total { if t > 0 { let ratio = (dj.current_bytes as f64 / t as f64).clamp(0.0, 1.0); let pct = (ratio * 100.0) as u16; let gauge = Gauge::default() .gauge_style(Style::default().fg(Color::Cyan)) .percent(pct) .label(format!("{}%", pct)); f.render_widget(gauge, layout[3]); } } f.render_widget( Paragraph::new(Span::styled( "Esc — отменить загрузку (текущий .part будет удалён)", Style::default().fg(Color::Yellow), )), layout[4], ); } fn centered_rect(percent_x: u16, height: u16, r: Rect) -> Rect { let popup_h = height.min(r.height); let popup_w = (r.width as u32 * percent_x as u32 / 100) as u16; let popup_w = popup_w.min(r.width).max(40.min(r.width)); let x = r.x + (r.width.saturating_sub(popup_w)) / 2; let y = r.y + (r.height.saturating_sub(popup_h)) / 2; Rect { x, y, width: popup_w, height: popup_h, } } fn human_bytes(n: u64) -> String { const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"]; let mut v = n as f64; let mut i = 0; while v >= 1024.0 && i < UNITS.len() - 1 { v /= 1024.0; i += 1; } if i == 0 { format!("{} {}", n, UNITS[i]) } else { format!("{:.1} {}", v, UNITS[i]) } } /// Первая видимая строка списка, чтобы курсор оставался в окне (`viewport` — число строк). fn list_scroll_start(cursor: usize, total: usize, viewport: u16) -> usize { let v = viewport as usize; if total == 0 || v == 0 { return 0; } if total <= v { return 0; } let max_start = total - v; cursor.saturating_sub(v / 2).min(max_start) } fn truncate(s: &str, max_w: usize) -> String { if UnicodeWidthStr::width(s) <= max_w { return s.to_string(); } let mut out = String::new(); let mut w = 0usize; for ch in s.chars() { let cw = UnicodeWidthChar::width(ch).unwrap_or(0); if w + cw + 1 > max_w { break; } out.push(ch); w += cw; } out.push('…'); out } fn draw_left(f: &mut Frame, app: &mut App, area: Rect) { let title = match app.phase { Phase::PickKind => " Режим ", Phase::Browse => " Дерево / очередь ", }; let block = Block::default() .borders(Borders::ALL) .border_style(border_style(app.focus == Focus::Left)) .title(title); let inner = block.inner(area); f.render_widget(block, area); let mut lines: Vec<ListItem> = Vec::new(); match app.phase { Phase::PickKind => { lines.push(ListItem::new(Line::from(vec![Span::styled( "Выберите тип справа →", Style::default().fg(Color::DarkGray), )]))); } Phase::Browse => { let kind_lbl = match app.kind { Some(Kind::Platform) => "Платформа", Some(Kind::Configuration) => "Конфигурация", None => "—", }; let line_lbl = match app.line_filter { LineFilter::All => "все линии", LineFilter::V83 => "8.3.x", LineFilter::V85 => "8.5.x", }; lines.push(ListItem::new(format!( "Тип: {} | линия: {}", kind_lbl, line_lbl ))); lines.push(ListItem::new(Line::from("─".repeat(inner.width.saturating_sub(2) as usize)))); if app.queue.is_empty() { lines.push(ListItem::new(Line::from(vec![Span::styled( "(пусто — Space на версии)", Style::default().fg(Color::DarkGray), )]))); } else { let n = app.queue.len(); let header_lines = 2u16; let line_pairs = inner.height.saturating_sub(header_lines) / 2; let view_items = (line_pairs as usize).max(1); let scroll = list_scroll_start(app.left_cursor, n, view_items as u16); for (i, q) in app.queue.iter().enumerate().skip(scroll).take(view_items) { let kind_s = match q.kind { Kind::Platform => "Платформа", Kind::Configuration => "Конфигурация", }; let mark = if q.marked { "[×]" } else { "[ ]" }; let path = format!( "{} {} / {} / {}", mark, kind_s, q.line_label, q.version_name ); let sty = if i == app.left_cursor && app.focus == Focus::Left { Style::default().bg(Color::DarkGray).fg(Color::White) } else if q.marked { Style::default().fg(Color::Green) } else { Style::default() }; lines.push(ListItem::new(Line::from(vec![Span::styled(path, sty)]))); let sub = format!(" {}", truncate(&q.project_name, inner.width as usize)); lines.push(ListItem::new(Line::from(vec![Span::styled( sub, Style::default().fg(Color::Gray), )]))); } } } } let list = List::new(lines); f.render_widget(list, inner); } fn draw_center(f: &mut Frame, app: &mut App, area: Rect) { let title = match app.phase { Phase::PickKind => " Выбор типа ", Phase::Browse => " Проекты ", }; let block = Block::default() .borders(Borders::ALL) .border_style(border_style(app.focus == Focus::Center)) .title(title); let inner = block.inner(area); f.render_widget(block, area); match app.phase { Phase::PickKind => { let items = vec![ ListItem::new(Line::from(vec![Span::styled( if app.center_cursor == 0 { " ▸ Платформа " } else { " Платформа " }, Style::default().fg(if app.center_cursor == 0 { Color::Cyan } else { Color::White }), )])), ListItem::new(Line::from(vec![Span::styled( if app.center_cursor == 1 { " ▸ Конфигурация " } else { " Конфигурация " }, Style::default().fg(if app.center_cursor == 1 { Color::Cyan } else { Color::White }), )])), ]; let list = List::new(items); f.render_widget(list, inner); } Phase::Browse => { let mut items = Vec::new(); let n = app.center_indices.len(); let vh = inner.height.max(1); let start = list_scroll_start(app.center_cursor, n, vh); for i in start..start.saturating_add(vh as usize).min(n) { let idx = app.center_indices[i]; let row = &app.projects[idx]; let sty = if i == app.center_cursor && app.focus == Focus::Center { Style::default().bg(Color::DarkGray).fg(Color::White) } else if Some(idx) == app.selected_project_idx { Style::default().fg(Color::Yellow) } else { Style::default() }; let label = truncate(&format!("{} · {}", row.name, row.id), inner.width as usize); items.push(ListItem::new(Line::from(vec![Span::styled(label, sty)]))); } if items.is_empty() { items.push(ListItem::new(Line::from("(нет проектов для этого режима)"))); } let list = List::new(items); f.render_widget(list, inner); } } } fn draw_right(f: &mut Frame, app: &mut App, area: Rect) { let block = Block::default() .borders(Borders::ALL) .border_style(border_style(app.focus == Focus::Right)) .title(" Версии релиза "); let inner = block.inner(area); f.render_widget(block, area); let mut items = Vec::new(); let n = app.versions.len(); let vh = inner.height.max(1); let start = list_scroll_start(app.version_cursor, n, vh); for i in start..start.saturating_add(vh as usize).min(n) { let v = &app.versions[i]; let sty = if i == app.version_cursor && app.focus == Focus::Right { Style::default().bg(Color::DarkGray).fg(Color::White) } else { Style::default() }; let dt = v .published .map(|d| d.format("%d.%m.%y").to_string()) .unwrap_or_default(); let line = format!("{} {}", v.name, dt); items.push(ListItem::new(Line::from(vec![Span::styled( truncate(&line, inner.width as usize), sty, )]))); } if items.is_empty() { items.push(ListItem::new(Line::from( "← выберите проект, нажмите l", ))); } let list = List::new(items); f.render_widget(list, inner); } fn border_style(active: bool) -> Style { if active { Style::default().fg(Color::Cyan) } else { Style::default().fg(Color::DarkGray) } } fn handle_key(app: &mut App, code: KeyCode) -> io::Result<bool> { // Во время фоновой загрузки модалка перехватывает ввод: только Esc отменяет, // q/Q — игнорируется (чтобы случайно не убить процесс посреди записи `.part`). if app.download.is_some() { if matches!(code, KeyCode::Esc) { app.cancel_downloads(); } return Ok(false); } match code { KeyCode::Char('q') | KeyCode::Char('Q') => return Ok(true), KeyCode::Esc => return Ok(true), _ => {} } let step_center = |app: &mut App, dy: isize| match app.phase { Phase::PickKind => { let n = 2usize; let cur = app.center_cursor as isize + dy; app.center_cursor = cur.clamp(0, n as isize - 1) as usize; } Phase::Browse => { let n = app.center_indices.len(); if n > 0 { let cur = app.center_cursor as isize + dy; app.center_cursor = cur.clamp(0, n as isize - 1) as usize; } } }; let step_right = |app: &mut App, dy: isize| { let n = app.versions.len(); if n > 0 { let cur = app.version_cursor as isize + dy; app.version_cursor = cur.clamp(0, n as isize - 1) as usize; } }; let step_left = |app: &mut App, dy: isize| { if app.queue.is_empty() { return; } let n = app.queue.len(); let cur = app.left_cursor as isize + dy; app.left_cursor = cur.clamp(0, n as isize - 1) as usize; }; match code { KeyCode::Char(c) if matches!(c, 'd' | 'y') => { if app.feed_double_key(c) { let dest = download_dir(); let _ = std::fs::create_dir_all(&dest); app.start_downloads(dest); } } KeyCode::Char(' ') => match app.focus { Focus::Right => app.toggle_queue_current_version(), Focus::Left => app.toggle_queue_left(), _ => {} }, KeyCode::Char('1') if app.phase == Phase::Browse && app.kind == Some(Kind::Platform) => { app.line_filter = LineFilter::All; app.refresh_versions(); } KeyCode::Char('2') if app.phase == Phase::Browse && app.kind == Some(Kind::Platform) => { app.line_filter = LineFilter::V83; app.refresh_versions(); } KeyCode::Char('3') if app.phase == Phase::Browse && app.kind == Some(Kind::Platform) => { app.line_filter = LineFilter::V85; app.refresh_versions(); } KeyCode::Down | KeyCode::Char('j') => match app.focus { Focus::Left => step_left(app, 1), Focus::Center => step_center(app, 1), Focus::Right => step_right(app, 1), }, KeyCode::Up | KeyCode::Char('k') => match app.focus { Focus::Left => step_left(app, -1), Focus::Center => step_center(app, -1), Focus::Right => step_right(app, -1), }, // Центральная колонка «Проекты» (в т.ч. конфигурации): j/k зависят от фокуса — при фокусе // справа двигаются только версии. [ ] всегда листают список проектов. KeyCode::Char('[') => step_center(app, -1), KeyCode::Char(']') => step_center(app, 1), KeyCode::Right | KeyCode::Char('l') => match app.phase { Phase::PickKind => { let k = if app.center_cursor == 0 { Kind::Platform } else { Kind::Configuration }; app.enter_kind(k); } Phase::Browse => match app.focus { Focus::Center => { app.select_project(); app.focus = Focus::Right; } Focus::Left => app.focus = Focus::Center, Focus::Right => {} }, }, KeyCode::Left | KeyCode::Char('h') => match app.phase { Phase::Browse => match app.focus { Focus::Right => app.focus = Focus::Center, Focus::Center => app.back_to_root(), Focus::Left => app.focus = Focus::Center, }, Phase::PickKind => {} }, KeyCode::Tab => { if app.phase != Phase::Browse { return Ok(false); } match app.focus { Focus::Left => app.focus = Focus::Center, Focus::Center => { if !app.versions.is_empty() { app.focus = Focus::Right; } else { app.focus = Focus::Left; } } Focus::Right => app.focus = Focus::Left, } }, KeyCode::BackTab => { if app.phase != Phase::Browse { return Ok(false); } match app.focus { Focus::Left => { if !app.versions.is_empty() { app.focus = Focus::Right; } else { app.focus = Focus::Center; } } Focus::Center => app.focus = Focus::Left, Focus::Right => app.focus = Focus::Center, } }, _ => {} } Ok(false) } fn download_dir() -> PathBuf { std::env::var("ONEC_DOWNLOAD_DIR") .map(PathBuf::from) .unwrap_or_else(|_| std::env::current_dir().unwrap_or_default().join("downloads")) }