From a83987af193935a8834f74c7bc645bf93b8ac36c Mon Sep 17 00:00:00 2001 From: Uber Veng Date: Wed, 27 May 2026 22:55:30 +0700 Subject: [PATCH] refactoring --- src/app.rs | 630 ++++++++++------------------------------------ src/main.rs | 1 + src/text_input.rs | 98 ++++++++ src/ui.rs | 563 ++++++++++++----------------------------- 4 files changed, 398 insertions(+), 894 deletions(-) create mode 100644 src/text_input.rs diff --git a/src/app.rs b/src/app.rs index 1b39c04..e47f9f7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,9 +1,9 @@ -// src/app.rs use crate::config::Config; use crate::error::Result; use crate::executor; use crate::menu::{Action, InteractionMode, MenuItem, MenuItemKind, MenuRoot}; use crate::network; +use crate::text_input::TextInput; use crate::updater; use crossterm::event::{Event as CrosstermEvent, KeyCode, KeyEventKind, KeyModifiers}; use tokio::sync::mpsc::UnboundedSender; @@ -25,7 +25,6 @@ pub enum Event { } /// A pending request to hand the terminal to an external interactive process. -/// Set by the structured-protocol handler; consumed by the main event loop. pub struct PendingExec { pub shell: String, pub reply_tx: tokio::sync::mpsc::UnboundedSender, @@ -40,9 +39,7 @@ pub struct App { pub menu_scroll_offset: usize, pub popup: Option, pub event_tx: UnboundedSender, - /// When set, main loop suspends ratatui, runs the command, then restores. pub pending_exec: Option, - /// Exe path to exec after update; captured before the binary was replaced. pub pending_restart: Option, } @@ -55,52 +52,28 @@ pub enum Popup { ExecutingBashTerminal { child: tokio::process::Child }, ExecutingStructured { reply_tx: tokio::sync::mpsc::UnboundedSender, - /// Fires SIGTERM to the script's process group on Esc. kill_tx: Option>, 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. + input: TextInput, menu_selected_index: usize, - /// Cursor position for Form-type commands. form_cursor: usize, - /// Checked/selected state for each Form field (parallel to fields vec). form_values: Vec, - /// Index of the first visible line in log_buffer (0 = top of output). log_scroll_pos: usize, - /// Horizontal scroll offset in chars (0 = leftmost column). log_scroll_x: usize, - /// When true, keep position pinned to the bottom as new output arrives. log_follow_bottom: bool, - /// 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, + /// Unified add/edit popup. `edit_index = None` → adding new endpoint. + UpsertEndpoint { + edit_index: Option, + /// [0] = name, [1] = url + fields: [TextInput; 2], + active_field: usize, error: Option, return_selected: usize, }, @@ -222,9 +195,6 @@ impl App { Ok(false) } Event::StructuredCommand(cmd) => { - // Exec hands the terminal to an external process — clone reply_tx, - // store as pending_exec, and skip setting current_command so the - // main loop can suspend ratatui immediately. if let executor::StructuredCommand::Exec { shell } = &cmd { if let Some(Popup::ExecutingStructured { reply_tx, .. }) = &self.popup { self.pending_exec = Some(PendingExec { @@ -258,17 +228,8 @@ impl App { Ok(false) } Event::StructuredOutput(line) => { - if let Some(Popup::ExecutingStructured { - log_buffer, - log_follow_bottom, - log_scroll_pos, - .. - }) = &mut self.popup - { + if let Some(Popup::ExecutingStructured { log_buffer, .. }) = &mut self.popup { log_buffer.push(line); - // log_follow_bottom: render will pin pos to bottom. - // Otherwise pos stays fixed → new line appears off-screen. - let _ = (log_follow_bottom, log_scroll_pos); // used by render } Ok(false) } @@ -280,9 +241,6 @@ impl App { }) = &mut self.popup { *finished = Some(exit_code); - // Clear any lingering non-interactive command (e.g. Progress - // left by a notify() call just before the script exited). - // Leaving it set would make has_command=true and block scroll. if matches!( current_command, Some(executor::StructuredCommand::Progress { .. }) @@ -294,7 +252,6 @@ impl App { Ok(false) } Event::UpdateAvailable(info) => { - // Only show if no popup is currently open (don't interrupt running scripts) if self.popup.is_none() { self.popup = Some(Popup::UpdateConfirm { info }); } @@ -345,8 +302,7 @@ impl App { Popup::ExecutingStructured { reply_tx, kill_tx, - input_buffer, - input_cursor, + input, current_command, menu_selected_index, form_cursor, @@ -369,9 +325,6 @@ impl App { current_command, Some(executor::StructuredCommand::Form { .. }) ); - let _has_command = current_command.is_some(); - // Only interactive commands block scroll/navigation. - // Progress and Message are display-only and must not block arrows. let blocks_scroll = matches!( current_command, Some(executor::StructuredCommand::Input { .. }) @@ -379,49 +332,40 @@ impl App { | Some(executor::StructuredCommand::Confirm { .. }) | Some(executor::StructuredCommand::Form { .. }) ); - // Vim motions are disabled only when free text input is active. let is_text_input = matches!( current_command, Some(executor::StructuredCommand::Input { .. }) ); + let alt = key.modifiers.intersects(KeyModifiers::ALT | KeyModifiers::SUPER); match key.code { - // ── Log scrolling (PageUp / PageDown always work) ─ + // ── Log scrolling ──────────────────────────────── KeyCode::PageUp => { *log_scroll_pos = log_scroll_pos.saturating_sub(10); *log_follow_bottom = false; } KeyCode::PageDown => { *log_scroll_pos = log_scroll_pos.saturating_add(10); - // Render will clamp to max; if we're at the - // bottom, mark as following. - *log_follow_bottom = - *log_scroll_pos + 1 >= log_buffer.len(); + *log_follow_bottom = *log_scroll_pos + 1 >= log_buffer.len(); } - // ── Form: navigation and toggle ────────────────── + // ── Form navigation ────────────────────────────── KeyCode::Up if is_form => { if *form_cursor > 0 { *form_cursor -= 1; } } KeyCode::Down if is_form => { - let len = if let Some( - executor::StructuredCommand::Form { fields, .. } - ) = current_command { fields.len() } else { 0 }; + let len = if let Some(executor::StructuredCommand::Form { fields, .. }) = current_command { + fields.len() + } else { 0 }; if *form_cursor + 1 < len { *form_cursor += 1; } } KeyCode::Char(' ') if is_form => { let cursor = *form_cursor; - // Collect toggle info before mutating form_values - let toggle = if let Some( - executor::StructuredCommand::Form { fields, .. } - ) = current_command { + let toggle = if let Some(executor::StructuredCommand::Form { fields, .. }) = current_command { fields.get(cursor).map(|f| { let group_indices: Vec = fields.iter() .enumerate() - .filter(|(_, ff)| { - ff.group.is_some() - && ff.group == f.group - }) + .filter(|(_, ff)| ff.group.is_some() && ff.group == f.group) .map(|(i, _)| i) .collect(); (f.field_type.clone(), group_indices) @@ -446,44 +390,29 @@ impl App { } } - // ── Arrow keys ──────────────────────────────────── + // ── Arrow / cursor keys ────────────────────────── KeyCode::Up if is_menu => { - if *menu_selected_index > 0 { - *menu_selected_index -= 1; - } + if *menu_selected_index > 0 { *menu_selected_index -= 1; } } KeyCode::Down if is_menu => { - if let Some(executor::StructuredCommand::Menu { - options, .. - }) = current_command - { + if let Some(executor::StructuredCommand::Menu { options, .. }) = current_command { if *menu_selected_index + 1 < options.len() { *menu_selected_index += 1; } } } - // Scroll log one line when no interactive command pending KeyCode::Up if !blocks_scroll => { *log_scroll_pos = log_scroll_pos.saturating_sub(1); *log_follow_bottom = false; } KeyCode::Down if !blocks_scroll => { *log_scroll_pos = log_scroll_pos.saturating_add(1); - *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); + *log_follow_bottom = *log_scroll_pos + 1 >= log_buffer.len(); } + KeyCode::Left if is_text_input && alt => { input.word_back(); } + KeyCode::Right if is_text_input && alt => { input.word_fwd(); } + KeyCode::Left if is_text_input => { input.move_left(); } + KeyCode::Right if is_text_input => { input.move_right(); } KeyCode::Left if !is_text_input && !is_menu && !is_form => { *log_scroll_x = log_scroll_x.saturating_sub(4); } @@ -491,56 +420,37 @@ impl App { *log_scroll_x = log_scroll_x.saturating_add(4); } - // ── Confirm shortcuts (immediate, no Enter needed) ─ + // ── Confirm shortcuts ──────────────────────────── KeyCode::Char('y') | KeyCode::Char('Y') if is_confirm => { let _ = current_command.take(); let _ = reply_tx.send("y".to_string()); - input_buffer.clear(); + input.clear(); } KeyCode::Char('n') | KeyCode::Char('N') if is_confirm => { let _ = current_command.take(); let _ = reply_tx.send("n".to_string()); - input_buffer.clear(); + input.clear(); } - // ── 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); - } + // ── Text input ─────────────────────────────────── + KeyCode::Char('b') if is_text_input && alt => { input.word_back(); } + KeyCode::Char('f') if is_text_input && alt => { input.word_fwd(); } KeyCode::Char(c) - if !is_menu - && !is_confirm - && !is_form - && (is_text_input - || !matches!(c, 'j' | 'k' | 'l' | 'h' | 'd' | 'u')) => + if !is_menu && !is_confirm && !is_form + && (is_text_input || !matches!(c, 'j' | 'k' | 'l' | 'h' | 'd' | 'u')) => { - 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_cursor = buf_backspace(input_buffer, *input_cursor); + input.insert(c); } + KeyCode::Backspace if is_text_input && alt => { input.word_delete_back(); } + KeyCode::Backspace if !is_menu && !is_form => { input.backspace(); } - // ── Enter: commit response ──────────────────────── + // ── Enter ──────────────────────────────────────── KeyCode::Enter => { - // Form submit: collect checked IDs if is_form { - if let Some(executor::StructuredCommand::Form { - fields, .. - }) = current_command.take() { + if let Some(executor::StructuredCommand::Form { fields, .. }) = current_command.take() { let response = fields.iter().enumerate() .filter_map(|(i, f)| { - form_values.get(i) - .copied() - .filter(|&v| v) - .map(|_| f.id.as_str()) + form_values.get(i).copied().filter(|&v| v).map(|_| f.id.as_str()) }) .collect::>() .join(" "); @@ -550,27 +460,17 @@ impl App { } } else if let Some(cmd) = current_command.take() { let response = match &cmd { - executor::StructuredCommand::Input { .. } => { - input_buffer.clone() - } + executor::StructuredCommand::Input { .. } => input.buf.clone(), executor::StructuredCommand::Confirm { .. } => { - if input_buffer.to_lowercase().starts_with('y') { - "y".to_string() - } else { - "n".to_string() - } + if input.buf.to_lowercase().starts_with('y') { "y".to_string() } else { "n".to_string() } + } + executor::StructuredCommand::Menu { options, .. } => { + options.get(*menu_selected_index).map(|o| o.id.clone()).unwrap_or_default() } - executor::StructuredCommand::Menu { - options, .. - } => options - .get(*menu_selected_index) - .map(|opt| opt.id.clone()) - .unwrap_or_default(), _ => String::new(), }; let _ = reply_tx.send(response); - input_buffer.clear(); - *input_cursor = 0; + input.clear(); *menu_selected_index = 0; } } @@ -588,7 +488,7 @@ impl App { } } - // ── Vim motions (off during text input) ─────────── + // ── Vim motions ────────────────────────────────── KeyCode::Char('k') if !is_text_input => { if is_menu { if *menu_selected_index > 0 { *menu_selected_index -= 1; } @@ -601,34 +501,27 @@ impl App { } KeyCode::Char('j') if !is_text_input => { if is_menu { - if let Some(executor::StructuredCommand::Menu { - options, .. - }) = current_command { + if let Some(executor::StructuredCommand::Menu { options, .. }) = current_command { if *menu_selected_index + 1 < options.len() { *menu_selected_index += 1; } } } else if is_form { - let len = if let Some( - executor::StructuredCommand::Form { fields, .. } - ) = current_command { fields.len() } else { 0 }; + let len = if let Some(executor::StructuredCommand::Form { fields, .. }) = current_command { + fields.len() + } else { 0 }; if *form_cursor + 1 < len { *form_cursor += 1; } } else if !blocks_scroll { *log_scroll_pos = log_scroll_pos.saturating_add(1); - *log_follow_bottom = - *log_scroll_pos + 1 >= log_buffer.len(); + *log_follow_bottom = *log_scroll_pos + 1 >= log_buffer.len(); } } KeyCode::Char('l') if !is_text_input => { - // Form submit if is_form { - if let Some(executor::StructuredCommand::Form { - fields, .. - }) = current_command.take() { + if let Some(executor::StructuredCommand::Form { fields, .. }) = current_command.take() { let response = fields.iter().enumerate() .filter_map(|(i, f)| { - form_values.get(i).copied() - .filter(|&v| v).map(|_| f.id.as_str()) + form_values.get(i).copied().filter(|&v| v).map(|_| f.id.as_str()) }) .collect::>().join(" "); let _ = reply_tx.send(response); @@ -637,19 +530,14 @@ impl App { } } else if let Some(cmd) = current_command.take() { let response = match &cmd { - executor::StructuredCommand::Confirm { .. } => { - "y".to_string() - } + executor::StructuredCommand::Confirm { .. } => "y".to_string(), executor::StructuredCommand::Menu { options, .. } => { - options.get(*menu_selected_index) - .map(|o| o.id.clone()) - .unwrap_or_default() + options.get(*menu_selected_index).map(|o| o.id.clone()).unwrap_or_default() } _ => String::new(), }; let _ = reply_tx.send(response); - input_buffer.clear(); - *input_cursor = 0; + input.clear(); *menu_selected_index = 0; } else { *log_scroll_x = log_scroll_x.saturating_add(4); @@ -663,20 +551,15 @@ impl App { *log_scroll_x = log_scroll_x.saturating_sub(4); } } - - // ── H / L = start / end of line (horizontal) ─── KeyCode::Char('H') if !is_text_input && !is_menu && !is_form => { *log_scroll_x = 0; } KeyCode::Char('L') if !is_text_input && !is_menu && !is_form => { *log_scroll_x = usize::MAX; } - - // ── d / u = PgDn / PgUp (vim half-page scroll) ── KeyCode::Char('d') if !is_text_input && !is_form => { *log_scroll_pos = log_scroll_pos.saturating_add(10); - *log_follow_bottom = - *log_scroll_pos + 1 >= log_buffer.len(); + *log_follow_bottom = *log_scroll_pos + 1 >= log_buffer.len(); } KeyCode::Char('u') if !is_text_input && !is_form => { *log_scroll_pos = log_scroll_pos.saturating_sub(10); @@ -700,8 +583,7 @@ impl App { KeyCode::Enter | KeyCode::Char('l') => { let sel = *selected; self.popup = None; - let url = self.config.endpoints.get(sel) - .map(|ep| ep.url.clone()); + 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; @@ -728,13 +610,10 @@ impl App { 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, + self.popup = Some(Popup::UpsertEndpoint { + edit_index: Some(sel), + fields: [TextInput::with_text(name), TextInput::with_text(url)], active_field: 0, - cursor: name_len, error: None, return_selected: sel, }); @@ -742,11 +621,10 @@ impl App { } KeyCode::Char('n') | KeyCode::Char('N') => { let sel = *selected; - self.popup = Some(Popup::AddEndpoint { - url_buf: String::new(), - name_buf: String::new(), + self.popup = Some(Popup::UpsertEndpoint { + edit_index: None, + fields: [TextInput::new(), TextInput::new()], active_field: 0, - cursor: 0, error: None, return_selected: sel, }); @@ -759,233 +637,80 @@ impl App { return Ok(false); } - Popup::AddEndpoint { - url_buf, - name_buf, + Popup::UpsertEndpoint { + edit_index, + fields, 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 name = fields[0].buf.trim().to_string(); + let edit_idx = *edit_index; 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() + } else if let Some(conflict_url) = self.config.endpoints.iter() .enumerate() - .find(|(i, ep)| *i != idx && ep.name == name) + .find(|(i, ep)| Some(*i) != edit_idx && ep.name == name) .map(|(_, ep)| ep.url.clone()) { *error = Some(format!( "Название \"{}\" уже используется для {}", - name, existing + name, conflict_url )); } else { - *name_buf = name; + fields[0].buf = name; *active_field = 1; - *cursor = url_buf.chars().count(); + *error = None; } } else { - let url = url_buf.trim().to_string(); - let name = name_buf.trim().to_string(); - let idx = *index; + let url = fields[1].buf.trim().to_string(); + let name = fields[0].buf.trim().to_string(); + let edit_idx = *edit_index; let ret = *return_selected; if url.is_empty() { *error = Some("URL не может быть пустым".to_string()); - } else if let Some(existing_name) = self.config.endpoints.iter() + } else if let Some(conflict_name) = self.config.endpoints.iter() .enumerate() - .find(|(i, ep)| *i != idx && ep.url == url) + .find(|(i, ep)| Some(*i) != edit_idx && ep.url == url) .map(|(_, ep)| ep.name.clone()) { *error = Some(format!( "URL уже используется для эндпоинта \"{}\"", - existing_name + conflict_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(); + match edit_idx { + None => { + 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, + }); + } + Some(idx) => { + 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, + }); + } } - if was_active { - self.config.active_endpoint = url; - } - let _ = self.config.save(); - self.popup = Some(Popup::EndpointSelector { - selected: ret, - scroll_offset: 0, - }); } } } @@ -996,7 +721,12 @@ impl App { scroll_offset: 0, }); } - _ => {} + _ => { + let af = *active_field; + if fields[af].handle_key(&key) { + *error = None; + } + } } return Ok(false); } @@ -1008,8 +738,7 @@ impl App { 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; + 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() { @@ -1017,8 +746,7 @@ impl App { self.menu = None; } else { if was_active { - self.config.active_endpoint = - self.config.endpoints[0].url.clone(); + self.config.active_endpoint = self.config.endpoints[0].url.clone(); self.menu = None; self.breadcrumbs.clear(); self.selected_index = 0; @@ -1065,34 +793,21 @@ impl App { let tx = self.event_tx.clone(); let info_clone = info.clone(); - // Forward progress events tokio::spawn(async move { while let Some(bytes) = progress_rx.recv().await { let _ = tx.send(Event::UpdateProgress(bytes)); } }); - // Download and apply let tx2 = self.event_tx.clone(); tokio::spawn(async move { - match updater::download_and_apply( - &info_clone, - progress_tx, - ) - .await - { + match updater::download_and_apply(&info_clone, progress_tx).await { Ok(exe_path) => { - // Write expected version before exec so - // the next startup can detect a failed - // replacement (wrong asset, etc.). - updater::write_update_target( - &info_clone.new_version, - ); + updater::write_update_target(&info_clone.new_version); let _ = tx2.send(Event::UpdateDone(exe_path)); } Err(e) => { - let _ = - tx2.send(Event::UpdateError(e.to_string())); + let _ = tx2.send(Event::UpdateError(e.to_string())); } } }); @@ -1111,7 +826,6 @@ impl App { if matches!(status, UpdatingStatus::Failed(_)) { self.popup = None; } - // Ignore Esc while downloading/applying } return Ok(false); } @@ -1120,7 +834,7 @@ impl App { } } - // ── Main menu navigation ───────────────────────────────────────── + // ── Main menu navigation ────────────────────────────────────────── match key.code { KeyCode::Char('q') => return Ok(true), KeyCode::Up | KeyCode::Char('k') => { @@ -1166,12 +880,7 @@ impl App { use crossterm::event::MouseEventKind; match mouse.kind { MouseEventKind::ScrollUp => { - if let Some(Popup::ExecutingStructured { - log_scroll_pos, - log_follow_bottom, - .. - }) = &mut self.popup - { + if let Some(Popup::ExecutingStructured { log_scroll_pos, log_follow_bottom, .. }) = &mut self.popup { *log_scroll_pos = log_scroll_pos.saturating_sub(3); *log_follow_bottom = false; } else if let Some(Popup::EndpointSelector { selected, .. }) = &mut self.popup { @@ -1185,12 +894,8 @@ impl App { } MouseEventKind::ScrollDown => { if let Some(Popup::ExecutingStructured { - log_scroll_pos, - log_follow_bottom, - log_buffer, - .. - }) = &mut self.popup - { + log_scroll_pos, log_follow_bottom, log_buffer, .. + }) = &mut self.popup { *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 { @@ -1237,24 +942,11 @@ impl App { async fn run_action(&mut self, action: Action) -> Result<()> { match action { - Action::Bash { - script, - interaction, - confirm: _, - confirm_message: _, - } => match interaction { - InteractionMode::Terminal => { - self.run_bash_terminal(&script).await?; - } - InteractionMode::Structured => { - self.run_bash_structured(&script).await?; - } + Action::Bash { script, interaction, confirm: _, confirm_message: _ } => match interaction { + InteractionMode::Terminal => self.run_bash_terminal(&script).await?, + InteractionMode::Structured => self.run_bash_structured(&script).await?, }, - Action::Download { - url, - confirm: _, - .. - } => { + Action::Download { url, confirm: _, .. } => { self.popup = Some(Popup::Message { text: format!("Скачивание {} пока не реализовано", url), level: MessageLevel::Info, @@ -1281,9 +973,7 @@ impl App { tokio::spawn(async move { let mut rx = output_rx; while let Some(line) = rx.recv().await { - if tx_output.send(Event::StructuredOutput(line)).is_err() { - break; - } + if tx_output.send(Event::StructuredOutput(line)).is_err() { break; } } }); @@ -1291,9 +981,7 @@ impl App { tokio::spawn(async move { let mut rx = command_rx; while let Some(cmd) = rx.recv().await { - if tx_cmd.send(Event::StructuredCommand(cmd)).is_err() { - break; - } + if tx_cmd.send(Event::StructuredCommand(cmd)).is_err() { break; } } }); @@ -1308,8 +996,7 @@ impl App { kill_tx: Some(kill_tx), log_buffer: Vec::new(), current_command: None, - input_buffer: String::new(), - input_cursor: 0, + input: TextInput::new(), menu_selected_index: 0, form_cursor: 0, form_values: Vec::new(), @@ -1322,56 +1009,3 @@ impl App { Ok(()) } } - -// ── 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/main.rs b/src/main.rs index 0720d27..dfd09f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod error; mod executor; mod menu; mod network; +mod text_input; mod ui; mod updater; diff --git a/src/text_input.rs b/src/text_input.rs new file mode 100644 index 0000000..a51a8ea --- /dev/null +++ b/src/text_input.rs @@ -0,0 +1,98 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +pub struct TextInput { + pub buf: String, + pub cursor: usize, +} + +impl TextInput { + pub fn new() -> Self { + Self { buf: String::new(), cursor: 0 } + } + + pub fn with_text(text: impl Into) -> Self { + let buf = text.into(); + let cursor = buf.chars().count(); + Self { buf, cursor } + } + + pub fn value(&self) -> &str { + &self.buf + } + + pub fn clear(&mut self) { + self.buf.clear(); + self.cursor = 0; + } + + pub fn move_left(&mut self) { + self.cursor = self.cursor.saturating_sub(1); + } + + pub fn move_right(&mut self) { + self.cursor = (self.cursor + 1).min(self.buf.chars().count()); + } + + pub fn word_back(&mut self) { + let chars: Vec = self.buf.chars().collect(); + let mut pos = self.cursor; + while pos > 0 && !is_word(chars[pos - 1]) { pos -= 1; } + while pos > 0 && is_word(chars[pos - 1]) { pos -= 1; } + self.cursor = pos; + } + + pub fn word_fwd(&mut self) { + let chars: Vec = self.buf.chars().collect(); + let len = chars.len(); + let mut pos = self.cursor; + while pos < len && !is_word(chars[pos]) { pos += 1; } + while pos < len && is_word(chars[pos]) { pos += 1; } + self.cursor = pos; + } + + pub fn insert(&mut self, c: char) { + let byte_idx = self.buf.char_indices().nth(self.cursor).map(|(b, _)| b).unwrap_or(self.buf.len()); + self.buf.insert(byte_idx, c); + self.cursor += 1; + } + + pub fn backspace(&mut self) { + if self.cursor == 0 { return; } + let byte_idx = self.buf.char_indices().nth(self.cursor - 1).map(|(b, _)| b).unwrap_or(0); + self.buf.remove(byte_idx); + self.cursor -= 1; + } + + pub fn word_delete_back(&mut self) { + let chars: Vec = self.buf.chars().collect(); + let mut new_cursor = self.cursor; + while new_cursor > 0 && !is_word(chars[new_cursor - 1]) { new_cursor -= 1; } + while new_cursor > 0 && is_word(chars[new_cursor - 1]) { new_cursor -= 1; } + if new_cursor == self.cursor { return; } + let start_byte = self.buf.char_indices().nth(new_cursor).map(|(b, _)| b).unwrap_or(0); + let end_byte = self.buf.char_indices().nth(self.cursor).map(|(b, _)| b).unwrap_or(self.buf.len()); + self.buf.drain(start_byte..end_byte); + self.cursor = new_cursor; + } + + /// Returns true if the key was consumed. + pub fn handle_key(&mut self, key: &KeyEvent) -> bool { + let alt = key.modifiers.intersects(KeyModifiers::ALT | KeyModifiers::SUPER); + match key.code { + KeyCode::Left if alt => { self.word_back(); true } + KeyCode::Right if alt => { self.word_fwd(); true } + KeyCode::Char('b') if alt => { self.word_back(); true } + KeyCode::Char('f') if alt => { self.word_fwd(); true } + KeyCode::Left => { self.move_left(); true } + KeyCode::Right => { self.move_right(); true } + KeyCode::Backspace if alt => { self.word_delete_back(); true } + KeyCode::Backspace => { self.backspace(); true } + KeyCode::Char(c) => { self.insert(c); true } + _ => false, + } + } +} + +fn is_word(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} diff --git a/src/ui.rs b/src/ui.rs index adbdca5..73a4539 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -2,6 +2,7 @@ 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}, @@ -38,7 +39,6 @@ fn render_menu(f: &mut Frame, app: &mut App, area: Rect) { 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 { .. } => "📁 ", @@ -47,7 +47,6 @@ fn render_menu(f: &mut Frame, app: &mut App, area: Rect) { 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; @@ -60,20 +59,14 @@ fn render_menu(f: &mut Frame, app: &mut App, area: Rect) { 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 *offset > sel { *offset = sel; } 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; } @@ -84,7 +77,6 @@ fn render_menu(f: &mut Frame, app: &mut App, area: Rect) { break; } } - // Clamp to valid range let max_off = total.saturating_sub(1); *offset = (*offset).min(max_off); } @@ -162,7 +154,6 @@ fn render_description(f: &mut Frame, app: &App, area: Rect) { fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) { 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), @@ -171,11 +162,7 @@ fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) { f.render_widget(Clear, popup_area); match popup { - Popup::Confirming { - action: _, - item_title, - confirm_message, - } => { + Popup::Confirming { action: _, item_title, confirm_message } => { let block = Block::default() .borders(Borders::ALL) .title("Подтверждение") @@ -199,11 +186,10 @@ fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) { )), ] }; - let paragraph = Paragraph::new(text) - .block(block) - .alignment(Alignment::Center) - .wrap(Wrap { trim: true }); - f.render_widget(paragraph, popup_area); + f.render_widget( + Paragraph::new(text).block(block).alignment(Alignment::Center).wrap(Wrap { trim: true }), + popup_area, + ); } Popup::ExecutingStructured { @@ -211,8 +197,7 @@ fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) { kill_tx: _, log_buffer, current_command, - input_buffer, - input_cursor, + input, menu_selected_index, form_cursor, form_values, @@ -222,12 +207,8 @@ fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) { 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(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, }; @@ -238,11 +219,9 @@ fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) { .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; @@ -274,9 +253,7 @@ fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) { 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() @@ -289,36 +266,19 @@ fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) { }; 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, - ), + 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) @@ -335,7 +295,6 @@ fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) { 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 { @@ -355,15 +314,9 @@ fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) { ); } - // ── Interactive command area ───────────────────────────────────── if cmd_height > 0 { if let Some(cmd) = current_command { - render_command( - f, cmd, input_buffer, *input_cursor, - *menu_selected_index, - *form_cursor, form_values, - chunks[1], - ); + render_command(f, cmd, input, *menu_selected_index, *form_cursor, form_values, chunks[1]); } } } @@ -378,20 +331,16 @@ fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) { .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); + 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 - ))), + 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; @@ -407,17 +356,10 @@ fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) { .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); + f.render_widget(Paragraph::new(lines).block(block).wrap(Wrap { trim: true }), popup_area); } - Popup::Updating { - info, - downloaded, - status, - } => { + Popup::Updating { info, downloaded, status } => { let (title, border_color) = match status { UpdatingStatus::Downloading => (" Загрузка обновления… ", Color::Cyan), UpdatingStatus::Applying => (" Применение обновления… ", Color::Yellow), @@ -428,23 +370,14 @@ fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) { 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 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 - ) + 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] = '█'; - } + for i in pos..((pos + thumb).min(bar_width)) { bar[i] = '█'; } format!("[{}]", bar.iter().collect::()) }; @@ -455,8 +388,7 @@ fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) { 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) + format!(" {} КБ загружено", *downloaded / 1024) } } UpdatingStatus::Applying => " Применяется…".to_string(), @@ -473,20 +405,14 @@ fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) { if matches!(status, UpdatingStatus::Failed(_)) { lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - " [Esc] Закрыть", - Style::default().fg(Color::Yellow), - ))); + 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); + f.render_widget(Paragraph::new(lines).block(block).wrap(Wrap { trim: true }), popup_area); } Popup::EndpointSelector { selected, scroll_offset } => { @@ -497,14 +423,12 @@ fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) { 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 { @@ -523,13 +447,8 @@ fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) { 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 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) @@ -561,11 +480,8 @@ fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) { .borders(Borders::ALL) .title(format!(" Эндпоинты [активен: {}] ", active_name)) .title( - Line::from(Span::styled( - " [Esc] ", - Style::default().fg(Color::DarkGray), - )) - .alignment(Alignment::Right), + Line::from(Span::styled(" [Esc] ", Style::default().fg(Color::DarkGray))) + .alignment(Alignment::Right), ) .title_bottom( Line::from(Span::styled( @@ -596,14 +512,7 @@ fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) { } } - Popup::AddEndpoint { - url_buf, - name_buf, - active_field, - cursor, - error, - .. - } => { + Popup::UpsertEndpoint { edit_index, fields, active_field, error, .. } => { let popup_area = centered_rect(60, 50, area); f.render_widget(Clear, popup_area); @@ -618,27 +527,8 @@ fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) { ]) .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], - ); + 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( @@ -649,149 +539,75 @@ fn render_popup(f: &mut Frame, popup: &mut Popup, config: &Config) { ); } - // 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)); + // 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 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); + 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 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), - )), + 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 }), + Paragraph::new(text) + .block(Block::default().borders(Borders::ALL).title(" Подтверждение ") + .border_style(Style::default().fg(Color::Yellow))) + .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); - } - _ => {} } } +/// 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_buffer: &str, - input_cursor: usize, + input: &TextInput, menu_selected_index: usize, form_cursor: usize, form_values: &[bool], @@ -800,19 +616,18 @@ fn render_command( match cmd { executor::StructuredCommand::Input { prompt, secret, .. } => { let display = if *secret { - "•".repeat(input_buffer.chars().count()) + "•".repeat(input.buf.chars().count()) } else { - input_buffer.to_string() + input.buf.clone() }; - 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::new(display.as_str()).block( + Block::default().borders(Borders::ALL).title(format!(" {} ", prompt)) + .border_style(Style::default().fg(Color::Cyan)), + ), + area, ); - f.render_widget(paragraph, area); - let cursor_x = (area.x + 1 + input_cursor as u16) - .min(area.x + area.width.saturating_sub(2)); + 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)); } @@ -824,12 +639,7 @@ fn render_command( 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), - ), + Span::styled(opt.label.as_str(), Style::default().bg(Color::Blue).add_modifier(Modifier::BOLD)), ])) } else { ListItem::new(Line::from(vec![ @@ -843,40 +653,32 @@ fn render_command( 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::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, ); - 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); + 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 } => { @@ -885,23 +687,17 @@ fn render_command( 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); + 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 } => { @@ -916,36 +712,26 @@ fn render_command( "░".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::new(vec![Line::from(bar), Line::from(msg)]) + .block(Block::default().borders(Borders::ALL).title(" Прогресс ") + .border_style(Style::default().fg(Color::Cyan))), + area, ); - f.render_widget(paragraph, area); } None => { - // Indeterminate: spinning braille dots - const SPINNER: &[&str] = - &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + const SPINNER: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; let frame = (std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() - .as_millis() - / 120) as usize; + .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::new(format!("{} {}", spinner, msg)) + .block(Block::default().borders(Borders::ALL).title(" Прогресс ") + .border_style(Style::default().fg(Color::Cyan))), + area, ); - f.render_widget(paragraph, area); } } } @@ -954,12 +740,8 @@ fn render_command( 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 { "( )" } - } + executor::FormFieldType::Checkbox => if checked { "[✓]" } else { "[ ]" }, + executor::FormFieldType::Radio => if checked { "(●)" } else { "( )" }, }; let is_cursor = i == form_cursor; let prefix = if is_cursor { "▶ " } else { " " }; @@ -976,33 +758,30 @@ fn render_command( 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::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, ); - 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); + 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, + ); } } } @@ -1010,29 +789,22 @@ fn render_command( 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(), - ) + .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(), - ) + .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()]; @@ -1072,7 +844,6 @@ fn wrap_to_lines(text: &str, max_width: usize) -> Vec { 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();