Added endpoint selector, reworked config.toml structure, added movent features for text input

This commit is contained in:
Uber Veng
2026-05-27 22:21:34 +07:00
parent b438cbd6b4
commit 3d6fd77d85
6 changed files with 897 additions and 46 deletions

312
src/ui.rs
View File

@@ -1,4 +1,5 @@
use crate::ansi;
use crate::config::Config;
use crate::executor;
use crate::app::{App, MessageLevel, Popup, UpdatingStatus};
use ratatui::{
@@ -24,7 +25,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
render_description(f, app, chunks[1]);
if let Some(popup) = &mut app.popup {
render_popup(f, popup);
render_popup(f, popup, &app.config);
}
}
@@ -107,10 +108,15 @@ fn render_menu(f: &mut Frame, app: &mut App, area: Rect) {
})
.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(" Меню ")
.title(endpoint_title)
.title(
Line::from(Span::styled(
format!(" v{} ", env!("CARGO_PKG_VERSION")),
@@ -153,7 +159,7 @@ fn render_description(f: &mut Frame, app: &App, area: Rect) {
f.render_widget(paragraph, area);
}
fn render_popup(f: &mut Frame, popup: &mut Popup) {
fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) {
let area = f.area();
// Use a smaller area for update-related popups
@@ -206,6 +212,7 @@ fn render_popup(f: &mut Frame, popup: &mut Popup) {
log_buffer,
current_command,
input_buffer,
input_cursor,
menu_selected_index,
form_cursor,
form_values,
@@ -352,7 +359,7 @@ fn render_popup(f: &mut Frame, popup: &mut Popup) {
if cmd_height > 0 {
if let Some(cmd) = current_command {
render_command(
f, cmd, input_buffer,
f, cmd, input_buffer, *input_cursor,
*menu_selected_index,
*form_cursor, form_values,
chunks[1],
@@ -482,6 +489,300 @@ fn render_popup(f: &mut Frame, popup: &mut Popup) {
f.render_widget(paragraph, 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;
// Compute name column width
let name_col_w = endpoints.iter()
.map(|ep| ep.name.chars().count())
.max()
.unwrap_or(8)
.max(8);
// Clamp scroll_offset to keep selected visible
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<ListItem> = 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::AddEndpoint {
url_buf,
name_buf,
active_field,
cursor,
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);
let name_color = if *active_field == 0 { Color::Cyan } else { Color::DarkGray };
let url_color = if *active_field == 1 { Color::Cyan } else { Color::DarkGray };
f.render_widget(
Paragraph::new(name_buf.as_str()).block(
Block::default()
.borders(Borders::ALL)
.title(" Название ")
.border_style(Style::default().fg(name_color)),
),
chunks[0],
);
f.render_widget(
Paragraph::new(url_buf.as_str()).block(
Block::default()
.borders(Borders::ALL)
.title(" URL ")
.border_style(Style::default().fg(url_color)),
),
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],
);
}
// Position terminal cursor in the active field
let active_chunk = if *active_field == 0 { chunks[0] } else { chunks[1] };
let cx = (active_chunk.x + 1 + *cursor as u16)
.min(active_chunk.x + active_chunk.width.saturating_sub(2));
f.set_cursor_position((cx, active_chunk.y + 1));
let outer_block = Block::default()
.borders(Borders::ALL)
.title(" Добавить эндпоинт ")
.title(
Line::from(Span::styled(
" [Esc] ",
Style::default().fg(Color::DarkGray),
))
.alignment(Alignment::Right),
)
.title_bottom(
Line::from(Span::styled(
" Enter — далее / добавить ",
Style::default().fg(Color::DarkGray),
))
.alignment(Alignment::Right),
)
.border_style(Style::default().fg(Color::Cyan));
f.render_widget(outer_block, 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),
)),
];
let block = Block::default()
.borders(Borders::ALL)
.title(" Подтверждение ")
.border_style(Style::default().fg(Color::Yellow));
f.render_widget(
Paragraph::new(text).block(block).wrap(Wrap { trim: true }),
popup_area,
);
}
Popup::EditEndpoint {
name_buf,
url_buf,
active_field,
cursor,
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);
let name_color = if *active_field == 0 { Color::Cyan } else { Color::DarkGray };
let url_color = if *active_field == 1 { Color::Cyan } else { Color::DarkGray };
f.render_widget(
Paragraph::new(name_buf.as_str()).block(
Block::default()
.borders(Borders::ALL)
.title(" Название ")
.border_style(Style::default().fg(name_color)),
),
chunks[0],
);
f.render_widget(
Paragraph::new(url_buf.as_str()).block(
Block::default()
.borders(Borders::ALL)
.title(" URL ")
.border_style(Style::default().fg(url_color)),
),
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],
);
}
// Position terminal cursor in the active field
let active_chunk = if *active_field == 0 { chunks[0] } else { chunks[1] };
let cx = (active_chunk.x + 1 + *cursor as u16)
.min(active_chunk.x + active_chunk.width.saturating_sub(2));
f.set_cursor_position((cx, active_chunk.y + 1));
let outer_block = Block::default()
.borders(Borders::ALL)
.title(" Редактировать эндпоинт ")
.title(
Line::from(Span::styled(
" [Esc] ",
Style::default().fg(Color::DarkGray),
))
.alignment(Alignment::Right),
)
.title_bottom(
Line::from(Span::styled(
" Enter — далее / сохранить ",
Style::default().fg(Color::DarkGray),
))
.alignment(Alignment::Right),
)
.border_style(Style::default().fg(Color::Cyan));
f.render_widget(outer_block, popup_area);
}
_ => {}
}
}
@@ -490,6 +791,7 @@ fn render_command(
f: &mut Frame,
cmd: &executor::StructuredCommand,
input_buffer: &str,
input_cursor: usize,
menu_selected_index: usize,
form_cursor: usize,
form_values: &[bool],
@@ -509,7 +811,7 @@ fn render_command(
.border_style(Style::default().fg(Color::Cyan)),
);
f.render_widget(paragraph, area);
let cursor_x = (area.x + 1 + input_buffer.chars().count() as u16)
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));
}