From 3d6fd77d853c142728de239aff082700c9670ca9 Mon Sep 17 00:00:00 2001 From: Uber Veng Date: Wed, 27 May 2026 22:21:34 +0700 Subject: [PATCH] Added endpoint selector, reworked config.toml structure, added movent features for text input --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/app.rs | 490 +++++++++++++++++++++++++++++++++++++++++++++++++- src/config.rs | 97 +++++++--- src/main.rs | 40 +++-- src/ui.rs | 312 +++++++++++++++++++++++++++++++- 6 files changed, 897 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 431faef..b103fab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1123,7 +1123,7 @@ dependencies = [ [[package]] name = "ostiary" -version = "1.0.11" +version = "1.1.0" dependencies = [ "anyhow", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index 58c7cc3..d1f6a1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ostiary" -version = "1.0.11" +version = "1.1.0" edition = "2024" [dependencies] diff --git a/src/app.rs b/src/app.rs index 0e24eb6..1b39c04 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,7 +5,7 @@ use crate::executor; use crate::menu::{Action, InteractionMode, MenuItem, MenuItemKind, MenuRoot}; use crate::network; use crate::updater; -use crossterm::event::{Event as CrosstermEvent, KeyCode, KeyEventKind}; +use crossterm::event::{Event as CrosstermEvent, KeyCode, KeyEventKind, KeyModifiers}; use tokio::sync::mpsc::UnboundedSender; #[derive(Debug)] @@ -60,6 +60,8 @@ pub enum Popup { log_buffer: Vec, current_command: Option, input_buffer: String, + /// Cursor position within input_buffer (char index). + input_cursor: usize, /// Cursor position for Menu-type commands. menu_selected_index: usize, /// Cursor position for Form-type commands. @@ -75,6 +77,37 @@ pub enum Popup { /// Set when the process has exited; popup stays open until Esc. finished: Option, }, + EndpointSelector { + selected: usize, + scroll_offset: usize, + }, + AddEndpoint { + url_buf: String, + name_buf: String, + /// 0 = name, 1 = url + active_field: u8, + /// Cursor position within the active field buffer (char index). + cursor: usize, + error: Option, + /// Restore EndpointSelector with this index on cancel/done + return_selected: usize, + }, + EditEndpoint { + /// Index into config.endpoints being edited + index: usize, + name_buf: String, + url_buf: String, + /// 0 = name, 1 = url + active_field: u8, + /// Cursor position within the active field buffer (char index). + cursor: usize, + error: Option, + return_selected: usize, + }, + ConfirmDeleteEndpoint { + index: usize, + return_selected: usize, + }, Downloading { progress: f32, message: String }, Message { text: String, level: MessageLevel }, UpdateConfirm { @@ -134,7 +167,7 @@ impl App { } pub async fn load_menu(&mut self) { - let server_url = self.config.server_url.clone(); + let server_url = self.config.active_url().to_string(); let timeout = self.config.timeout_sec; let tx = self.event_tx.clone(); @@ -313,6 +346,7 @@ impl App { reply_tx, kill_tx, input_buffer, + input_cursor, current_command, menu_selected_index, form_cursor, @@ -438,6 +472,18 @@ impl App { *log_follow_bottom = *log_scroll_pos + 1 >= log_buffer.len(); } + KeyCode::Left if is_text_input && key.modifiers.intersects(KeyModifiers::ALT | KeyModifiers::SUPER) => { + *input_cursor = buf_word_back(input_buffer, *input_cursor); + } + KeyCode::Right if is_text_input && key.modifiers.intersects(KeyModifiers::ALT | KeyModifiers::SUPER) => { + *input_cursor = buf_word_fwd(input_buffer, *input_cursor); + } + KeyCode::Left if is_text_input => { + *input_cursor = buf_move_left(input_buffer, *input_cursor); + } + KeyCode::Right if is_text_input => { + *input_cursor = buf_move_right(input_buffer, *input_cursor); + } KeyCode::Left if !is_text_input && !is_menu && !is_form => { *log_scroll_x = log_scroll_x.saturating_sub(4); } @@ -459,6 +505,12 @@ impl App { // ── Text input ──────────────────────────────────── // Excluded: vim motions (j/k/l/h/d/u) and form mode. + KeyCode::Char('b') if is_text_input && key.modifiers.intersects(KeyModifiers::ALT | KeyModifiers::SUPER) => { + *input_cursor = buf_word_back(input_buffer, *input_cursor); + } + KeyCode::Char('f') if is_text_input && key.modifiers.intersects(KeyModifiers::ALT | KeyModifiers::SUPER) => { + *input_cursor = buf_word_fwd(input_buffer, *input_cursor); + } KeyCode::Char(c) if !is_menu && !is_confirm @@ -466,10 +518,14 @@ impl App { && (is_text_input || !matches!(c, 'j' | 'k' | 'l' | 'h' | 'd' | 'u')) => { - input_buffer.push(c); + buf_insert(input_buffer, *input_cursor, c); + *input_cursor += 1; + } + KeyCode::Backspace if is_text_input && key.modifiers.intersects(KeyModifiers::ALT | KeyModifiers::SUPER) => { + *input_cursor = buf_word_delete_back(input_buffer, *input_cursor); } KeyCode::Backspace if !is_menu && !is_form => { - input_buffer.pop(); + *input_cursor = buf_backspace(input_buffer, *input_cursor); } // ── Enter: commit response ──────────────────────── @@ -514,6 +570,7 @@ impl App { }; let _ = reply_tx.send(response); input_buffer.clear(); + *input_cursor = 0; *menu_selected_index = 0; } } @@ -592,6 +649,7 @@ impl App { }; let _ = reply_tx.send(response); input_buffer.clear(); + *input_cursor = 0; *menu_selected_index = 0; } else { *log_scroll_x = log_scroll_x.saturating_add(4); @@ -630,6 +688,363 @@ impl App { return Ok(false); } + Popup::EndpointSelector { selected, scroll_offset: _ } => { + match key.code { + KeyCode::Up | KeyCode::Char('k') => { + if *selected > 0 { *selected -= 1; } + } + KeyCode::Down | KeyCode::Char('j') => { + let len = self.config.endpoints.len(); + if *selected + 1 < len { *selected += 1; } + } + KeyCode::Enter | KeyCode::Char('l') => { + let sel = *selected; + self.popup = None; + let url = self.config.endpoints.get(sel) + .map(|ep| ep.url.clone()); + if let Some(url) = url { + if url != self.config.active_endpoint { + self.config.active_endpoint = url; + let _ = self.config.save(); + self.menu = None; + self.breadcrumbs.clear(); + self.selected_index = 0; + self.menu_scroll_offset = 0; + self.load_menu().await; + } + } + } + KeyCode::Char('d') | KeyCode::Char('D') => { + let sel = *selected; + if sel < self.config.endpoints.len() { + self.popup = Some(Popup::ConfirmDeleteEndpoint { + index: sel, + return_selected: sel, + }); + } + } + KeyCode::Char('e') | KeyCode::Char('E') => { + let sel = *selected; + if let Some(ep) = self.config.endpoints.get(sel) { + let name = ep.name.clone(); + let url = ep.url.clone(); + let name_len = name.chars().count(); + self.popup = Some(Popup::EditEndpoint { + index: sel, + name_buf: name, + url_buf: url, + active_field: 0, + cursor: name_len, + error: None, + return_selected: sel, + }); + } + } + KeyCode::Char('n') | KeyCode::Char('N') => { + let sel = *selected; + self.popup = Some(Popup::AddEndpoint { + url_buf: String::new(), + name_buf: String::new(), + active_field: 0, + cursor: 0, + error: None, + return_selected: sel, + }); + } + KeyCode::Esc | KeyCode::Tab => { + self.popup = None; + } + _ => {} + } + return Ok(false); + } + + Popup::AddEndpoint { + url_buf, + name_buf, + active_field, + cursor, + error, + return_selected, + } => { + let alt = key.modifiers.intersects(KeyModifiers::ALT | KeyModifiers::SUPER); + match key.code { + KeyCode::Left if alt => { + let buf = if *active_field == 0 { &**name_buf } else { &**url_buf }; + *cursor = buf_word_back(buf, *cursor); + } + KeyCode::Right if alt => { + let buf = if *active_field == 0 { &**name_buf } else { &**url_buf }; + *cursor = buf_word_fwd(buf, *cursor); + } + KeyCode::Left => { + let buf = if *active_field == 0 { &**name_buf } else { &**url_buf }; + *cursor = buf_move_left(buf, *cursor); + } + KeyCode::Right => { + let buf = if *active_field == 0 { &**name_buf } else { &**url_buf }; + *cursor = buf_move_right(buf, *cursor); + } + KeyCode::Char('b') if alt => { + let buf = if *active_field == 0 { &**name_buf } else { &**url_buf }; + *cursor = buf_word_back(buf, *cursor); + } + KeyCode::Char('f') if alt => { + let buf = if *active_field == 0 { &**name_buf } else { &**url_buf }; + *cursor = buf_word_fwd(buf, *cursor); + } + KeyCode::Char(c) => { + if *active_field == 0 { + buf_insert(name_buf, *cursor, c); + } else { + buf_insert(url_buf, *cursor, c); + } + *cursor += 1; + *error = None; + } + KeyCode::Backspace if alt => { + if *active_field == 0 { + *cursor = buf_word_delete_back(name_buf, *cursor); + } else { + *cursor = buf_word_delete_back(url_buf, *cursor); + } + *error = None; + } + KeyCode::Backspace => { + if *active_field == 0 { + *cursor = buf_backspace(name_buf, *cursor); + } else { + *cursor = buf_backspace(url_buf, *cursor); + } + *error = None; + } + KeyCode::Enter => { + if *active_field == 0 { + let name = name_buf.trim().to_string(); + if name.is_empty() { + *error = Some("Название не может быть пустым".to_string()); + } else if let Some(existing) = self.config.endpoints.iter() + .find(|ep| ep.name == name) + { + *error = Some(format!( + "Название \"{}\" уже используется для {}", + name, existing.url + )); + } else { + *name_buf = name; + *active_field = 1; + *cursor = 0; + } + } else { + let url = url_buf.trim().to_string(); + let name = name_buf.trim().to_string(); + if url.is_empty() { + *error = Some("URL не может быть пустым".to_string()); + } else if let Some(existing) = self.config.endpoints.iter() + .find(|ep| ep.url == url) + { + *error = Some(format!( + "URL уже используется для эндпоинта \"{}\"", + existing.name + )); + } else { + let ret = *return_selected; + self.config.endpoints.push( + crate::config::Endpoint { name, url } + ); + let _ = self.config.save(); + let new_sel = self.config.endpoints.len() - 1; + self.popup = Some(Popup::EndpointSelector { + selected: new_sel, + scroll_offset: ret, + }); + } + } + } + KeyCode::Esc => { + let ret = *return_selected; + self.popup = Some(Popup::EndpointSelector { + selected: ret, + scroll_offset: 0, + }); + } + _ => {} + } + return Ok(false); + } + + Popup::EditEndpoint { + index, + name_buf, + url_buf, + active_field, + cursor, + error, + return_selected, + } => { + let alt = key.modifiers.intersects(KeyModifiers::ALT | KeyModifiers::SUPER); + match key.code { + KeyCode::Left if alt => { + let buf = if *active_field == 0 { &**name_buf } else { &**url_buf }; + *cursor = buf_word_back(buf, *cursor); + } + KeyCode::Right if alt => { + let buf = if *active_field == 0 { &**name_buf } else { &**url_buf }; + *cursor = buf_word_fwd(buf, *cursor); + } + KeyCode::Left => { + let buf = if *active_field == 0 { &**name_buf } else { &**url_buf }; + *cursor = buf_move_left(buf, *cursor); + } + KeyCode::Right => { + let buf = if *active_field == 0 { &**name_buf } else { &**url_buf }; + *cursor = buf_move_right(buf, *cursor); + } + KeyCode::Char('b') if alt => { + let buf = if *active_field == 0 { &**name_buf } else { &**url_buf }; + *cursor = buf_word_back(buf, *cursor); + } + KeyCode::Char('f') if alt => { + let buf = if *active_field == 0 { &**name_buf } else { &**url_buf }; + *cursor = buf_word_fwd(buf, *cursor); + } + KeyCode::Char(c) => { + if *active_field == 0 { + buf_insert(name_buf, *cursor, c); + } else { + buf_insert(url_buf, *cursor, c); + } + *cursor += 1; + *error = None; + } + KeyCode::Backspace if alt => { + if *active_field == 0 { + *cursor = buf_word_delete_back(name_buf, *cursor); + } else { + *cursor = buf_word_delete_back(url_buf, *cursor); + } + *error = None; + } + KeyCode::Backspace => { + if *active_field == 0 { + *cursor = buf_backspace(name_buf, *cursor); + } else { + *cursor = buf_backspace(url_buf, *cursor); + } + *error = None; + } + KeyCode::Enter => { + if *active_field == 0 { + let name = name_buf.trim().to_string(); + let idx = *index; + if name.is_empty() { + *error = Some("Название не может быть пустым".to_string()); + } else if let Some(existing) = self.config.endpoints.iter() + .enumerate() + .find(|(i, ep)| *i != idx && ep.name == name) + .map(|(_, ep)| ep.url.clone()) + { + *error = Some(format!( + "Название \"{}\" уже используется для {}", + name, existing + )); + } else { + *name_buf = name; + *active_field = 1; + *cursor = url_buf.chars().count(); + } + } else { + let url = url_buf.trim().to_string(); + let name = name_buf.trim().to_string(); + let idx = *index; + let ret = *return_selected; + if url.is_empty() { + *error = Some("URL не может быть пустым".to_string()); + } else if let Some(existing_name) = self.config.endpoints.iter() + .enumerate() + .find(|(i, ep)| *i != idx && ep.url == url) + .map(|(_, ep)| ep.name.clone()) + { + *error = Some(format!( + "URL уже используется для эндпоинта \"{}\"", + existing_name + )); + } else { + let was_active = self.config.endpoints + .get(idx) + .map(|ep| ep.url == self.config.active_endpoint) + .unwrap_or(false); + if let Some(ep) = self.config.endpoints.get_mut(idx) { + ep.name = name; + ep.url = url.clone(); + } + if was_active { + self.config.active_endpoint = url; + } + let _ = self.config.save(); + self.popup = Some(Popup::EndpointSelector { + selected: ret, + scroll_offset: 0, + }); + } + } + } + KeyCode::Esc => { + let ret = *return_selected; + self.popup = Some(Popup::EndpointSelector { + selected: ret, + scroll_offset: 0, + }); + } + _ => {} + } + return Ok(false); + } + + Popup::ConfirmDeleteEndpoint { index, return_selected } => { + match key.code { + KeyCode::Char('y') | KeyCode::Char('Y') => { + let idx = *index; + let ret = *return_selected; + self.popup = None; + if idx < self.config.endpoints.len() { + let was_active = self.config.endpoints[idx].url + == self.config.active_endpoint; + self.config.endpoints.remove(idx); + let _ = self.config.save(); + if self.config.endpoints.is_empty() { + self.config.active_endpoint = String::new(); + self.menu = None; + } else { + if was_active { + self.config.active_endpoint = + self.config.endpoints[0].url.clone(); + self.menu = None; + self.breadcrumbs.clear(); + self.selected_index = 0; + self.menu_scroll_offset = 0; + self.load_menu().await; + } + let new_sel = ret.min(self.config.endpoints.len() - 1); + self.popup = Some(Popup::EndpointSelector { + selected: new_sel, + scroll_offset: 0, + }); + } + } + } + KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => { + let ret = *return_selected; + self.popup = Some(Popup::EndpointSelector { + selected: ret, + scroll_offset: 0, + }); + } + _ => {} + } + return Ok(false); + } + Popup::Message { .. } => { self.popup = None; return Ok(false); @@ -735,6 +1150,15 @@ impl App { KeyCode::Char('r') | KeyCode::Char('R') => { self.load_menu().await; } + KeyCode::Tab => { + let sel = self.config.endpoints.iter() + .position(|ep| ep.url == self.config.active_endpoint) + .unwrap_or(0); + self.popup = Some(Popup::EndpointSelector { + selected: sel, + scroll_offset: 0, + }); + } _ => {} } } @@ -750,6 +1174,8 @@ impl App { { *log_scroll_pos = log_scroll_pos.saturating_sub(3); *log_follow_bottom = false; + } else if let Some(Popup::EndpointSelector { selected, .. }) = &mut self.popup { + if *selected > 0 { *selected -= 1; } } else if self.popup.is_none() { let len = self.current_items().len(); if len > 0 { @@ -767,6 +1193,9 @@ impl App { { *log_scroll_pos = log_scroll_pos.saturating_add(3); *log_follow_bottom = *log_scroll_pos + 1 >= log_buffer.len(); + } else if let Some(Popup::EndpointSelector { selected, .. }) = &mut self.popup { + let len = self.config.endpoints.len(); + if *selected + 1 < len { *selected += 1; } } else if self.popup.is_none() { let len = self.current_items().len(); if len > 0 { @@ -880,6 +1309,7 @@ impl App { log_buffer: Vec::new(), current_command: None, input_buffer: String::new(), + input_cursor: 0, menu_selected_index: 0, form_cursor: 0, form_values: Vec::new(), @@ -893,3 +1323,55 @@ impl App { } } +// ── Text cursor helpers ─────────────────────────────────────────────────────── + +pub fn buf_move_left(_s: &str, cursor: usize) -> usize { + cursor.saturating_sub(1) +} + +pub fn buf_move_right(s: &str, cursor: usize) -> usize { + (cursor + 1).min(s.chars().count()) +} + +pub fn buf_word_back(s: &str, cursor: usize) -> usize { + let chars: Vec = s.chars().collect(); + let mut pos = cursor; + while pos > 0 && !is_word_char(chars[pos - 1]) { pos -= 1; } + while pos > 0 && is_word_char(chars[pos - 1]) { pos -= 1; } + pos +} + +pub fn buf_word_fwd(s: &str, cursor: usize) -> usize { + let chars: Vec = s.chars().collect(); + let len = chars.len(); + let mut pos = cursor; + while pos < len && !is_word_char(chars[pos]) { pos += 1; } + while pos < len && is_word_char(chars[pos]) { pos += 1; } + pos +} + +pub fn buf_insert(s: &mut String, cursor: usize, c: char) { + let byte_idx = s.char_indices().nth(cursor).map(|(b, _)| b).unwrap_or(s.len()); + s.insert(byte_idx, c); +} + +pub fn buf_backspace(s: &mut String, cursor: usize) -> usize { + if cursor == 0 { return 0; } + let byte_idx = s.char_indices().nth(cursor - 1).map(|(b, _)| b).unwrap_or(0); + s.remove(byte_idx); + cursor - 1 +} + +pub fn buf_word_delete_back(s: &mut String, cursor: usize) -> usize { + let new_cursor = buf_word_back(s, cursor); + if new_cursor == cursor { return cursor; } + let start_byte = s.char_indices().nth(new_cursor).map(|(b, _)| b).unwrap_or(0); + let end_byte = s.char_indices().nth(cursor).map(|(b, _)| b).unwrap_or(s.len()); + s.drain(start_byte..end_byte); + new_cursor +} + +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + diff --git a/src/config.rs b/src/config.rs index 963555b..601c359 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,9 +3,21 @@ use anyhow::Result; use std::fs; use std::path::PathBuf; +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct Endpoint { + pub name: String, + pub url: String, +} + #[derive(Debug, Deserialize, Serialize, Clone)] pub struct Config { - pub server_url: String, + // Legacy field — present only in old configs; migrated to endpoints on load. + #[serde(default, skip_serializing)] + server_url: Option, + #[serde(default)] + pub endpoints: Vec, + #[serde(default)] + pub active_endpoint: String, pub timeout_sec: u64, #[serde(default = "default_theme")] pub theme: Theme, @@ -30,7 +42,6 @@ fn default_theme() -> Theme { } pub fn config_path() -> PathBuf { - // Respect XDG_CONFIG_HOME; fall back to ~/.config on all platforms. let base = std::env::var("XDG_CONFIG_HOME") .map(PathBuf::from) .unwrap_or_else(|_| { @@ -42,41 +53,85 @@ pub fn config_path() -> PathBuf { } impl Config { - /// Returns true when a config file already exists. pub fn exists() -> bool { config_path().exists() } - /// Load config from disk. Panics-safe: returns error if file is missing or malformed. + pub fn active_url(&self) -> &str { + &self.active_endpoint + } + pub fn load() -> Result { let path = config_path(); let content = fs::read_to_string(&path)?; - Ok(toml::from_str(&content)?) + let mut cfg: Config = toml::from_str(&content)?; + // Migrate old single server_url format to endpoints list. + if cfg.endpoints.is_empty() { + if let Some(url) = cfg.server_url.take() { + cfg.active_endpoint = url.clone(); + cfg.endpoints.push(Endpoint { + name: "Default".to_string(), + url, + }); + } + } + if cfg.active_endpoint.is_empty() { + if let Some(ep) = cfg.endpoints.first() { + cfg.active_endpoint = ep.url.clone(); + } + } + Ok(cfg) } - /// Create and persist a new config with the given server URL. - pub fn create_with_url(server_url: &str) -> Result { + pub fn create_with_endpoint(url: &str, name: &str) -> Result { let path = config_path(); let config = Config { - server_url: server_url.to_string(), + server_url: None, + endpoints: vec![Endpoint { + name: name.to_string(), + url: url.to_string(), + }], + active_endpoint: url.to_string(), timeout_sec: 10, theme: default_theme(), update_api: Some("https://git.vainend.com/api/v1/repos/admin/ostiary".to_string()), }; - let contents = format!( - r#"server_url = "{}" -timeout_sec = {} -update_api = "https://git.vainend.com/api/v1/repos/admin/ostiary" - -[theme] -selected_bg = "{}" -"#, - config.server_url, - config.timeout_sec, - config.theme.selected_bg, - ); fs::create_dir_all(path.parent().unwrap())?; - fs::write(&path, contents)?; + fs::write(&path, config.to_toml())?; Ok(config) } + + pub fn save(&self) -> Result<()> { + let path = config_path(); + fs::create_dir_all(path.parent().unwrap())?; + fs::write(&path, self.to_toml())?; + Ok(()) + } + + fn to_toml(&self) -> String { + let mut s = format!( + "active_endpoint = \"{}\"\ntimeout_sec = {}\n", + escape_toml(&self.active_endpoint), + self.timeout_sec, + ); + if let Some(api) = &self.update_api { + s.push_str(&format!("update_api = \"{}\"\n", escape_toml(api))); + } + s.push_str(&format!( + "\n[theme]\nselected_bg = \"{}\"\n", + escape_toml(&self.theme.selected_bg), + )); + for ep in &self.endpoints { + s.push_str(&format!( + "\n[[endpoints]]\nname = \"{}\"\nurl = \"{}\"\n", + escape_toml(&ep.name), + escape_toml(&ep.url), + )); + } + s + } +} + +fn escape_toml(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") } diff --git a/src/main.rs b/src/main.rs index ea8188a..0720d27 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,8 +23,8 @@ async fn main() -> Result<()> { let config = if config::Config::exists() { config::Config::load()? } else { - let url = prompt_server_url()?; - config::Config::create_with_url(&url)? + let (url, name) = prompt_first_endpoint()?; + config::Config::create_with_endpoint(&url, &name)? }; enable_raw_mode()?; @@ -166,8 +166,8 @@ async fn run_exec( Ok(()) } -/// Runs before ratatui starts. Prompts for the server URL on first launch. -fn prompt_server_url() -> Result { +/// Runs before ratatui starts. Prompts for endpoint URL and name on first launch. +fn prompt_first_endpoint() -> Result<(String, String)> { use std::io::Write; println!(); @@ -179,22 +179,34 @@ fn prompt_server_url() -> Result { println!(" {}", config::config_path().display()); println!(); - loop { - print!(" URL сервера меню: "); + let name = loop { + print!(" Название эндпоинта: "); std::io::stdout().flush()?; + let mut buf = String::new(); + std::io::stdin().read_line(&mut buf)?; + let val = buf.trim().to_string(); + if val.is_empty() { + println!(" Название не может быть пустым. Попробуйте ещё раз.\n"); + continue; + } + break val; + }; - let mut url = String::new(); - std::io::stdin().read_line(&mut url)?; - let url = url.trim().to_string(); - - if url.is_empty() { + let url = loop { + print!(" URL эндпоинта: "); + std::io::stdout().flush()?; + let mut buf = String::new(); + std::io::stdin().read_line(&mut buf)?; + let val = buf.trim().to_string(); + if val.is_empty() { println!(" URL не может быть пустым. Попробуйте ещё раз.\n"); continue; } + break val; + }; - println!(); - return Ok(url); - } + println!(); + Ok((url, name)) } async fn read_crossterm_event() -> Result> { diff --git a/src/ui.rs b/src/ui.rs index 41aeb36..adbdca5 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -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 = 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)); }