From 6a79fb2c54a8b75d2280e847cd863379f0ed0062 Mon Sep 17 00:00:00 2001 From: Uber Veng Date: Fri, 22 May 2026 19:04:52 +0700 Subject: [PATCH] Added checkboxes & radio buttons; Updated uninstall script --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 57 +++++++++++++-- src/app.rs | 142 +++++++++++++++++++++++++++++++------ src/executor/mod.rs | 2 +- src/executor/structured.rs | 44 +++++++++++- src/ui.rs | 55 +++++++++++++- uninstall.sh | 48 +++++++------ 8 files changed, 296 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4e4c564..08fc970 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1123,7 +1123,7 @@ dependencies = [ [[package]] name = "ostiary" -version = "1.0.9" +version = "1.0.10" dependencies = [ "anyhow", "crossterm", diff --git a/Cargo.toml b/Cargo.toml index b017d04..8eb4c5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ostiary" -version = "1.0.9" +version = "1.0.10" edition = "2024" [dependencies] diff --git a/README.md b/README.md index b39622b..06271d7 100644 --- a/README.md +++ b/README.md @@ -75,13 +75,14 @@ INSTALL_DIR=~/.local/bin bash install.sh ### Popup запущенного скрипта -| Клавиша | Действие | -|---|---| -| `↑` / `↓` или `k` / `j` | Прокрутка лога / навигация по селектору | -| `PgUp` / `PgDn` | Прокрутка лога на 10 строк | -| `Y` / `N` или `l` / `h` | Ответ на подтверждение (без Enter) | -| `Enter` или `l` | Выбрать пункт селектора / подтвердить | -| `Esc` или `h` | Прервать скрипт и закрыть popup | +| Клавиша | Контекст | Действие | +|---|---|---| +| `↑` / `↓` или `k` / `j` | Лог / селектор / форма | Прокрутка лога или навигация между элементами | +| `PgUp` / `PgDn` | Лог | Прокрутка на 10 строк | +| `Space` | Форма | Переключить чекбокс / выбрать радиокнопку | +| `Y` / `N` или `l` / `h` | Подтверждение | Ответ без Enter | +| `Enter` или `l` | Любой ввод | Отправить / выбрать / применить форму | +| `Esc` или `h` | — | Прервать скрипт и закрыть popup | > Vim-motions (`j`/`k`/`l`/`h`) отключаются автоматически во время текстового ввода — символы идут в поле ввода как обычно. @@ -431,6 +432,48 @@ notify '{"type":"progress","percent":100,"message":"Готово!"}' --- +### Форма с чекбоксами и радиокнопками (`form`) + +Отображает список полей с возможностью навигации и переключения. Возвращает строку с ID всех включённых/выбранных полей через пробел. + +```json +{ + "type": "form", + "prompt": "Параметры поиска:", + "fields": [ + {"id": "verbose", "label": "Подробный вывод", "field_type": "checkbox", "default": false}, + {"id": "php", "label": "PHP файлы (*.php)", "field_type": "radio", "group": "ftype", "default": true}, + {"id": "js", "label": "JS файлы (*.js)", "field_type": "radio", "group": "ftype", "default": false} + ] +} +``` + +| Поле | Описание | +|---|---| +| `id` | Идентификатор, возвращается если поле включено | +| `label` | Отображаемый текст | +| `field_type` | `"checkbox"` или `"radio"` | +| `default` | Начальное состояние | +| `group` | Группа радиокнопок — одновременно активна только одна в группе | + +**Ответ:** строка с ID активных полей через пробел: `"verbose php"` + +**Клавиши внутри формы:** `↑`/`↓`/`j`/`k` — навигация, `Space` — переключить, `Enter`/`l` — применить, `Esc` — отмена. + +**Важно:** весь JSON должен быть на **одной строке** — протокол читает строки, не блоки. + +```bash +result=$(ask '{"type":"form","prompt":"Параметры:","fields":[{"id":"verbose","label":"Подробный вывод","field_type":"checkbox","default":false},{"id":"php","label":"PHP файлы","field_type":"radio","group":"t","default":true},{"id":"js","label":"JS файлы","field_type":"radio","group":"t","default":false}]}') + +is_on() { [[ " $result " == *" $1 "* ]]; } + +is_on "verbose" && FLAGS+=("-v") +is_on "php" && INCLUDE="--include=*.php" +is_on "js" && INCLUDE="--include=*.js" +``` + +--- + ### Запуск внешнего приложения (`exec`) Временно **передаёт терминал** внешней программе (mysql, vim, htop и др.). Клиент скрывает TUI, программа работает на полном экране, после завершения TUI восстанавливается. Возвращает код завершения. diff --git a/src/app.rs b/src/app.rs index d5bb320..d7d78eb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -61,6 +61,10 @@ pub enum Popup { input_buffer: String, /// Cursor position for Menu-type commands. 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, /// When true, keep position pinned to the bottom as new output arrives. @@ -197,11 +201,20 @@ impl App { if let Some(Popup::ExecutingStructured { current_command, menu_selected_index, + form_cursor, + form_values, .. }) = &mut self.popup { - if matches!(cmd, executor::StructuredCommand::Menu { .. }) { - *menu_selected_index = 0; + match &cmd { + executor::StructuredCommand::Menu { .. } => { + *menu_selected_index = 0; + } + executor::StructuredCommand::Form { fields, .. } => { + *form_cursor = 0; + *form_values = fields.iter().map(|f| f.default).collect(); + } + _ => {} } *current_command = Some(cmd); } @@ -285,6 +298,8 @@ impl App { input_buffer, current_command, menu_selected_index, + form_cursor, + form_values, log_scroll_pos, log_follow_bottom, log_buffer, @@ -298,6 +313,10 @@ impl App { current_command, Some(executor::StructuredCommand::Confirm { .. }) ); + let is_form = matches!( + current_command, + Some(executor::StructuredCommand::Form { .. }) + ); let has_command = current_command.is_some(); // Vim motions are disabled only when free text input is active. let is_text_input = matches!( @@ -319,6 +338,53 @@ impl App { *log_scroll_pos + 1 >= log_buffer.len(); } + // ── Form: navigation and toggle ────────────────── + 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 }; + 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 { + fields.get(cursor).map(|f| { + let group_indices: Vec = fields.iter() + .enumerate() + .filter(|(_, ff)| { + ff.group.is_some() + && ff.group == f.group + }) + .map(|(i, _)| i) + .collect(); + (f.field_type.clone(), group_indices) + }) + } else { None }; + + if let Some((ftype, group_indices)) = toggle { + match ftype { + executor::FormFieldType::Checkbox => { + if let Some(v) = form_values.get_mut(cursor) { + *v = !*v; + } + } + executor::FormFieldType::Radio => { + for i in group_indices { + if let Some(v) = form_values.get_mut(i) { + *v = i == cursor; + } + } + } + } + } + } + // ── Arrow keys ──────────────────────────────────── KeyCode::Up if is_menu => { if *menu_selected_index > 0 { @@ -359,23 +425,41 @@ impl App { } // ── Text input ──────────────────────────────────── - // Vim motion keys (j/k/l/h) are excluded here so they - // fall through to the vim-motion arms below. + // Excluded: vim motions (j/k/l/h) and form mode. KeyCode::Char(c) if !is_menu && !is_confirm + && !is_form && (is_text_input || !matches!(c, 'j' | 'k' | 'l' | 'h')) => { input_buffer.push(c); } - KeyCode::Backspace if !is_menu => { + KeyCode::Backspace if !is_menu && !is_form => { input_buffer.pop(); } // ── Enter: commit response ──────────────────────── KeyCode::Enter => { - if let Some(cmd) = current_command.take() { + // Form submit: collect checked IDs + if is_form { + 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()) + }) + .collect::>() + .join(" "); + let _ = reply_tx.send(response); + form_values.clear(); + *form_cursor = 0; + } + } else if let Some(cmd) = current_command.take() { let response = match &cmd { executor::StructuredCommand::Input { .. } => { input_buffer.clone() @@ -393,7 +477,6 @@ impl App { .get(*menu_selected_index) .map(|opt| opt.id.clone()) .unwrap_or_default(), - // Message / Progress: send empty ack. _ => String::new(), }; let _ = reply_tx.send(response); @@ -418,9 +501,9 @@ impl App { // ── Vim motions (off during text input) ─────────── KeyCode::Char('k') if !is_text_input => { if is_menu { - if *menu_selected_index > 0 { - *menu_selected_index -= 1; - } + if *menu_selected_index > 0 { *menu_selected_index -= 1; } + } else if is_form { + if *form_cursor > 0 { *form_cursor -= 1; } } else if !has_command { *log_scroll_pos = log_scroll_pos.saturating_sub(1); *log_follow_bottom = false; @@ -430,12 +513,16 @@ impl App { if is_menu { if let Some(executor::StructuredCommand::Menu { options, .. - }) = current_command - { + }) = 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 }; + if *form_cursor + 1 < len { *form_cursor += 1; } } else if !has_command { *log_scroll_pos = log_scroll_pos.saturating_add(1); *log_follow_bottom = @@ -443,18 +530,31 @@ impl App { } } KeyCode::Char('l') if !is_text_input => { - // Forward / confirm — same logic as Enter. - if let Some(cmd) = current_command.take() { + // Form submit + if is_form { + 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()) + }) + .collect::>().join(" "); + let _ = reply_tx.send(response); + form_values.clear(); + *form_cursor = 0; + } + } else if let Some(cmd) = current_command.take() { let response = match &cmd { executor::StructuredCommand::Confirm { .. } => { "y".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(|o| o.id.clone()) + .unwrap_or_default() + } _ => String::new(), }; let _ = reply_tx.send(response); @@ -691,6 +791,8 @@ impl App { current_command: None, input_buffer: String::new(), menu_selected_index: 0, + form_cursor: 0, + form_values: Vec::new(), log_scroll_pos: 0, log_follow_bottom: false, finished: None, diff --git a/src/executor/mod.rs b/src/executor/mod.rs index 0a5d62a..01b162b 100644 --- a/src/executor/mod.rs +++ b/src/executor/mod.rs @@ -1,4 +1,4 @@ pub mod structured; -pub use structured::{StructuredCommand, MessageLevel}; +pub use structured::{StructuredCommand, MessageLevel, FormField, FormFieldType}; // Здесь могут быть функции для download, http и т.д. diff --git a/src/executor/structured.rs b/src/executor/structured.rs index 76b0249..822b8a9 100644 --- a/src/executor/structured.rs +++ b/src/executor/structured.rs @@ -31,15 +31,34 @@ pub enum StructuredCommand { percent: u8, message: Option, }, + /// A list of checkboxes and radio buttons. + /// Response: space-separated IDs of all checked/selected fields. + Form { + prompt: String, + fields: Vec, + }, /// Run an interactive program that needs the real terminal. - /// The client suspends ratatui, inherits stdin/stdout/stderr, waits for - /// the process to exit, then restores the TUI. - /// The script receives the exit code as the response. Exec { shell: String, }, } +#[derive(Debug, Clone, PartialEq)] +pub enum FormFieldType { + Checkbox, + Radio, +} + +#[derive(Debug, Clone)] +pub struct FormField { + pub id: String, + pub label: String, + pub field_type: FormFieldType, + pub default: bool, + /// Radio buttons with the same group are mutually exclusive. + pub group: Option, +} + #[derive(Debug)] pub struct MenuOption { pub id: String, @@ -203,6 +222,25 @@ fn parse_command(json_str: &str, cmd_tx: UnboundedSender) -> let message = v["message"].as_str().map(String::from); StructuredCommand::Progress { percent, message } } + "form" => { + let prompt = v["prompt"].as_str().unwrap_or("").to_string(); + let fields = v["fields"].as_array() + .map(|arr| { + arr.iter().filter_map(|f| { + let id = f["id"].as_str()?.to_string(); + let label = f["label"].as_str()?.to_string(); + let field_type = match f["field_type"].as_str().unwrap_or("checkbox") { + "radio" => FormFieldType::Radio, + _ => FormFieldType::Checkbox, + }; + let default = f["default"].as_bool().unwrap_or(false); + let group = f["group"].as_str().map(String::from); + Some(FormField { id, label, field_type, default, group }) + }).collect() + }) + .unwrap_or_default(); + StructuredCommand::Form { prompt, fields } + } "exec" => { let shell = v["shell"].as_str().unwrap_or("").to_string(); StructuredCommand::Exec { shell } diff --git a/src/ui.rs b/src/ui.rs index 94bc391..1d941fb 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -132,6 +132,8 @@ fn render_popup(f: &mut Frame, popup: &mut Popup) { current_command, input_buffer, menu_selected_index, + form_cursor, + form_values, log_scroll_pos, log_follow_bottom, finished, @@ -140,6 +142,9 @@ fn render_popup(f: &mut Frame, popup: &mut Popup) { 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, }; @@ -229,7 +234,12 @@ fn render_popup(f: &mut Frame, popup: &mut Popup) { // ── Interactive command area ───────────────────────────────────── if cmd_height > 0 { if let Some(cmd) = current_command { - render_command(f, cmd, input_buffer, *menu_selected_index, chunks[1]); + render_command( + f, cmd, input_buffer, + *menu_selected_index, + *form_cursor, form_values, + chunks[1], + ); } } } @@ -364,6 +374,8 @@ fn render_command( cmd: &executor::StructuredCommand, input_buffer: &str, menu_selected_index: usize, + form_cursor: usize, + form_values: &[bool], area: Rect, ) { match cmd { @@ -492,6 +504,47 @@ fn render_command( f.render_widget(paragraph, area); } + executor::StructuredCommand::Form { prompt, fields } => { + let items: Vec = fields.iter().enumerate().map(|(i, field)| { + let checked = form_values.get(i).copied().unwrap_or(false); + let icon = match field.field_type { + executor::FormFieldType::Checkbox => { + if checked { "[✓]" } else { "[ ]" } + } + executor::FormFieldType::Radio => { + if checked { "(●)" } else { "( )" } + } + }; + let is_cursor = i == form_cursor; + let prefix = if is_cursor { "▶ " } else { " " }; + let style = if is_cursor { + Style::default().bg(Color::Blue).add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + ListItem::new(Line::from(vec![ + Span::raw(format!("{}{} {}", prefix, icon, field.label)), + ])).style(style) + }).collect(); + + let mut state = ListState::default(); + state.select(Some(form_cursor)); + + 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, 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 } => { diff --git a/uninstall.sh b/uninstall.sh index 907ca2c..ebbacdf 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -1,5 +1,4 @@ #!/bin/bash -set -e BINARY="ostiary" INSTALL_DIR="$HOME/.local/bin" @@ -12,49 +11,54 @@ info() { echo -e "${CYAN}[ostiary]${NC} $*"; } ok() { echo -e "${GREEN}[ostiary]${NC} $*"; } warn() { echo -e "${YELLOW}[ostiary]${NC} $*"; } +# Read from /dev/tty so the script works when piped through bash +# (e.g. curl ... | bash), where stdin is the pipe, not the terminal. +ask() { + local answer + read -rp "$1 [y/N] " answer