command line structured editor in rust (bubbletea-rs TUI)
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 586 lines 21 kB view raw
1use crate::acekey::assign_ace_keys; 2use crate::ui::model::leading_hyphen_count; 3use crate::ui::model::{ChooseItem, DEFAULT_WIDTH, Model}; 4use crate::ui::render::decorate::decorate_form; 5use crate::ui::render::styles::{STYLE_DESC, STYLE_LABEL, STYLE_LINENUM}; 6use crate::ui::render::util::normalize_and_pad; 7use std::collections::{HashMap, HashSet}; 8 9// Collect forms in baseline order for a numeric baseline subset 10fn baseline_subset_forms(nb: &[usize], items: &[ChooseItem]) -> Vec<String> { 11 let mut subset_forms = Vec::new(); 12 for &idx in nb.iter() { 13 if let Some(it) = items.get(idx) { 14 for f in &it.forms { 15 subset_forms.push(f.clone()); 16 } 17 } 18 } 19 subset_forms 20} 21 22// Given a list of forms and the typed buffer, produce the ace-key assignment map 23fn assign_prefix_map(forms: &[String], typed_raw: &str) -> HashMap<String, String> { 24 let assignments = assign_ace_keys(forms, typed_raw); 25 let mut assigned: HashMap<String, String> = forms.iter().cloned().map(|f| (f, String::new())).collect(); 26 if let Some(asg) = assignments { 27 for a in asg.iter() { 28 if a.index < forms.len() { 29 assigned.insert(forms[a.index].clone(), a.prefix.clone()); 30 } 31 } 32 } 33 assigned 34} 35 36pub fn assigned_map(m: &Model) -> HashMap<String, String> { 37 // When Numeric mode is active, compute assignments only for the numeric-filtered subset. 38 if let Some(nb) = &m.numeric_baseline { 39 // Build forms for the baseline subset in the same order as baseline 40 let subset_forms = baseline_subset_forms(nb, &m.items); 41 return assign_prefix_map(&subset_forms, &m.typed_raw); 42 } 43 44 // Default: use all items 45 let forms: Vec<String> = m 46 .items 47 .iter() 48 .flat_map(|it| it.forms.iter().cloned()) 49 .collect(); 50 assign_prefix_map(&forms, &m.typed_raw) 51} 52 53fn render_visible_items_numeric(nb: &[usize], m: &Model) -> Vec<ChooseItem> { 54 // typed_raw should be digits 55 if !m.typed_raw.is_empty() && m.typed_raw.chars().all(|c| c.is_ascii_digit()) { 56 let matches: Vec<usize> = nb 57 .iter() 58 .filter_map(|&orig_idx| { 59 let num = (orig_idx + 1).to_string(); 60 if num.starts_with(&m.typed_raw) { 61 Some(orig_idx) 62 } else { 63 None 64 } 65 }) 66 .collect(); 67 matches 68 .into_iter() 69 .filter_map(|i| m.items.get(i).cloned()) 70 .collect() 71 } else { 72 // no typed digits yet: return full baseline items in baseline order 73 nb.iter().filter_map(|&i| m.items.get(i).cloned()).collect() 74 } 75} 76 77fn render_visible_items_alpha(m: &Model) -> Vec<ChooseItem> { 78 let forms: Vec<String> = m 79 .items 80 .iter() 81 .flat_map(|it| it.forms.iter().cloned()) 82 .collect(); 83 let assignments = assign_ace_keys(&forms, &m.typed_raw); 84 let mut visible_forms: HashSet<String> = HashSet::new(); 85 86 if let Some(asg) = assignments { 87 for a in asg.iter() { 88 if a.index < forms.len() { 89 visible_forms.insert(forms[a.index].clone()); 90 } 91 } 92 } else if m.typed.is_empty() { 93 visible_forms = forms.into_iter().collect(); 94 } 95 96 m.items 97 .iter() 98 .filter(|it| it.forms.iter().any(|f| visible_forms.contains(f))) 99 .cloned() 100 .collect() 101} 102 103pub fn render_visible_items(m: &Model) -> Vec<ChooseItem> { 104 if let Some(nb) = &m.numeric_baseline { 105 render_visible_items_numeric(nb, m) 106 } else { 107 render_visible_items_alpha(m) 108 } 109} 110 111fn compute_gutter_width(total: usize) -> usize { 112 if total == 0 { 113 return 1; 114 } 115 let gw = ((total as f64).log10().floor() as usize) + 1; 116 usize::max(gw, 3) 117} 118 119fn format_num_str(num: usize, gutter_width: usize) -> String { 120 format!("{:>1$}", num, gutter_width) 121} 122 123// Build baseline numbers and order when numeric baseline is active 124fn build_baseline(m: &Model) -> Option<(Vec<String>, Vec<usize>)> { 125 if let Some(nb) = &m.numeric_baseline { 126 let v: Vec<String> = nb.iter().map(|&orig_idx| (orig_idx + 1).to_string()).collect(); 127 if v.is_empty() { 128 None 129 } else { 130 Some((v, nb.clone())) 131 } 132 } else { 133 None 134 } 135} 136 137// Given a baseline order and typed buffer, produce positions to render (vis_pos, orig_idx) 138fn collect_numeric_positions(nb_order: &[usize], typed: &str) -> Vec<(usize, usize)> { 139 let mut positions = Vec::new(); 140 if !typed.is_empty() && typed.chars().all(|c| c.is_ascii_digit()) { 141 for (vis_pos, &orig_idx) in nb_order.iter().enumerate() { 142 let num = (orig_idx + 1).to_string(); 143 if num.starts_with(typed) { 144 positions.push((vis_pos, orig_idx)); 145 } 146 } 147 } else { 148 for (vis_pos, &orig_idx) in nb_order.iter().enumerate() { 149 positions.push((vis_pos, orig_idx)); 150 } 151 } 152 positions 153} 154 155fn build_label(it: &ChooseItem, assigned: &HashMap<String, String>, t_hyph: usize, m: &Model) -> Option<String> { 156 let mut parts = Vec::new(); 157 for f in &it.forms { 158 if t_hyph >= 2 && leading_hyphen_count(f) < t_hyph { 159 continue; 160 } 161 parts.push(decorate_form(f, &m.typed_raw, assigned.get(f).cloned().unwrap_or_default())); 162 } 163 if parts.is_empty() { 164 None 165 } else { 166 Some(parts.join(", ")) 167 } 168} 169 170fn flag_suffix(it: &ChooseItem, m: &Model) -> Vec<String> { 171 let mut suffix = Vec::new(); 172 if let Some(fd) = &it.flag_def { 173 if fd.requires_value { 174 let mut placeholder = "VALUE".to_string(); 175 if !fd.longhand.is_empty() { 176 placeholder = fd.longhand.to_uppercase(); 177 } else if !fd.shorthand.is_empty() { 178 placeholder = fd.shorthand.to_uppercase(); 179 } 180 suffix.push(STYLE_DESC.render(&format!(" {placeholder}"))); 181 suffix.push(STYLE_DESC.render(" ")); 182 } else { 183 suffix.push(STYLE_DESC.render(" ")); 184 } 185 if !fd.usage.is_empty() { 186 suffix.push(STYLE_DESC.render(&fd.usage)); 187 } 188 let top_depth = m.ast.stack.len().saturating_sub(1); 189 if it.depth < top_depth && it.depth < m.ast.stack.len() { 190 let origin = &m.ast.stack[it.depth].name; 191 if !origin.is_empty() { 192 suffix.push(STYLE_DESC.render(&format!(" (from {origin})"))); 193 } 194 } 195 } 196 suffix 197} 198 199fn cmd_suffix(it: &ChooseItem) -> Option<String> { 200 let short_ref: &str = if !it.short.is_empty() { 201 it.short.as_str() 202 } else if let Some(cd) = &it.cmd_def { 203 cd.short.as_str() 204 } else { 205 "" 206 }; 207 if short_ref.is_empty() { 208 None 209 } else { 210 Some(STYLE_DESC.render(&format!(" {short_ref}"))) 211 } 212} 213 214// Render a single ChooseItem into a line (without trailing newline). Returns None when nothing should be rendered. 215fn render_item_line( 216 it: &ChooseItem, 217 assigned: &HashMap<String, String>, 218 t_hyph: usize, 219 num_str: String, 220 m: &Model, 221) -> Option<String> { 222 let label = build_label(it, assigned, t_hyph, m)?; 223 let mut line_pieces: Vec<String> = vec![STYLE_LINENUM.render(&num_str), STYLE_LABEL.render(&label)]; 224 line_pieces.extend(flag_suffix(it, m)); 225 if let Some(s) = cmd_suffix(it) { 226 line_pieces.push(s); 227 } 228 Some(line_pieces.join("")) 229} 230 231// Render when numeric baseline is active 232fn render_numeric_content(m: &Model, assigned: &HashMap<String, String>, bs: &Vec<String>, nb_order: &Vec<usize>, t_hyph: usize, gutter_width: usize) -> String { 233 let mut b = String::new(); 234 let positions = collect_numeric_positions(nb_order, &m.typed_raw); 235 if positions.is_empty() { 236 return b; 237 } 238 let total_positions = positions.len(); 239 let per_page = if m.per_page == 0 { total_positions } else { m.per_page }; 240 let start_pos = m.page.saturating_mul(per_page); 241 let end_pos = usize::min(start_pos + per_page, total_positions); 242 243 for pos_idx in start_pos..end_pos { 244 let (vis_pos, orig_idx) = positions[pos_idx]; 245 if let Some(it) = m.items.get(orig_idx) { 246 let num_str = if vis_pos < bs.len() { 247 format!("{:>1$}", bs[vis_pos], gutter_width) 248 } else { 249 format_num_str(orig_idx + 1, gutter_width) 250 }; 251 if let Some(line) = render_item_line(it, assigned, t_hyph, num_str, m) { 252 b.push_str(&line); 253 b.push('\n'); 254 } 255 } 256 } 257 b 258} 259 260// Default non-numeric render path 261fn render_default_content(m: &Model, visible: &[ChooseItem], baseline_num_strs: &Option<Vec<String>>, assigned: &HashMap<String, String>, t_hyph: usize, gutter_width: usize, start: usize, end: usize) -> String { 262 let mut b = String::new(); 263 for (idx, it) in visible.iter().enumerate().skip(start).take(end.saturating_sub(start)) { 264 let num_str = if let Some(bs) = baseline_num_strs { 265 if idx < bs.len() { 266 format!("{:>1$}", bs[idx], gutter_width) 267 } else { 268 format_num_str(idx + 1, gutter_width) 269 } 270 } else { 271 format_num_str(idx + 1, gutter_width) 272 }; 273 274 if let Some(line) = render_item_line(it, assigned, t_hyph, num_str, m) { 275 b.push_str(&line); 276 b.push('\n'); 277 } 278 } 279 b 280} 281 282pub fn render_list_content(m: &Model, visible: &[ChooseItem]) -> String { 283 let assigned = m.assigned_map(); 284 285 // If numeric baseline is active, compute total from baseline for gutter width 286 let (total, per) = if let Some(nb) = &m.numeric_baseline { 287 // total for gutter calculation should reflect the largest original index number 288 // use the maximum orig_idx+1 so gutter width does not shrink during numeric filtering 289 let max_num = nb.iter().map(|&i| i + 1).max().unwrap_or(0); 290 let t = max_num; 291 (t, if m.per_page == 0 { t } else { m.per_page }) 292 } else { 293 let t = visible.len(); 294 (t, if m.per_page == 0 { t } else { m.per_page }) 295 }; 296 297 if per == 0 { 298 return String::new(); 299 } 300 let start = m.page.saturating_mul(per); 301 let end = usize::min(start + per, total); 302 let t_hyph = leading_hyphen_count(&m.typed_raw); 303 let gutter_width = compute_gutter_width(total); 304 305 let baseline = build_baseline(m); 306 307 // Numeric baseline path 308 if let Some((bs, nb_order)) = baseline.as_ref() { 309 return render_numeric_content(m, &assigned, bs, &nb_order, t_hyph, gutter_width); 310 } 311 312 // Default non-numeric path 313 render_default_content(m, visible, &baseline.map(|(v, _)| v), &assigned, t_hyph, gutter_width, start, end) 314} 315 316pub fn render_main_content(m: &Model) -> String { 317 let total_width = if m.screen_width > 0 { 318 m.screen_width 319 } else { 320 DEFAULT_WIDTH 321 }; 322 323 if m.in_value_mode { 324 let lines: Vec<String> = vec![ 325 lipgloss::Style::new().bold(true).render("Value input: ") + &m.pending_value, 326 lipgloss::Style::new() 327 .faint(true) 328 .render("Press Enter to confirm, Esc to cancel"), 329 ]; 330 let per = if m.per_page == 0 { lines.len() } else { m.per_page }; 331 return normalize_and_pad(lines, total_width, per); 332 } 333 334 let visible = m.render_visible_items(); 335 let list_block = m.render_list_content(&visible); 336 let lines: Vec<String> = list_block.lines().map(|s| s.to_string()).collect(); 337 let per = if m.per_page == 0 { 338 lines.len() 339 } else { 340 m.per_page 341 }; 342 // Ensure we return exactly `per` lines each normalized to the terminal width. 343 normalize_and_pad(lines, total_width, per) 344} 345 346#[cfg(test)] 347mod tests { 348 use regex::Regex; 349 350 fn strip_ansi(s: &str) -> String { 351 let re = Regex::new(r"\x1b\[[0-9;?]*[ -/]*[@-~]").unwrap(); 352 re.replace_all(s, "").to_string() 353 } 354 355 #[test] 356 fn render_assigned_map_initial_prefixes_shows_labels() { 357 let mut m = crate::ui::initial_model(vec![]); 358 m.items = vec![ 359 crate::ui::ChooseItem { 360 kind: "flag".to_string(), 361 label: "--long".to_string(), 362 forms: vec!["--long".to_string()], 363 flag_def: None, 364 cmd_def: None, 365 short: String::new(), 366 depth: 0, 367 }, 368 crate::ui::ChooseItem { 369 kind: "flag".to_string(), 370 label: "-s".to_string(), 371 forms: vec!["-s".to_string()], 372 flag_def: None, 373 cmd_def: None, 374 short: String::new(), 375 depth: 0, 376 }, 377 crate::ui::ChooseItem { 378 kind: "cmd".to_string(), 379 label: "cmd".to_string(), 380 forms: vec!["cmd".to_string()], 381 flag_def: None, 382 cmd_def: None, 383 short: String::new(), 384 depth: 0, 385 }, 386 ]; 387 m.typed_raw = "".to_string(); 388 let visible = m.render_visible_items(); 389 let list = m.render_list_content(&visible); 390 let stripped = strip_ansi(&list); 391 assert!(stripped.contains("--long")); 392 assert!(stripped.contains("-s")); 393 assert!(stripped.contains("cmd")); 394 } 395 396 #[test] 397 fn render_build_items_from_command_includes_flags_and_subcommands() { 398 let mut m = crate::ui::initial_model(vec![]); 399 let def = crate::ast::CommandDef { 400 name: "root".to_string(), 401 short: "rootcmd".to_string(), 402 aliases: vec![], 403 flags: vec![crate::ast::FlagDef { 404 longhand: "verbose".to_string(), 405 shorthand: "v".to_string(), 406 usage: "v".to_string(), 407 requires_value: false, 408 }], 409 subcommands: vec![crate::ast::CommandDef { 410 name: "sub".to_string(), 411 short: "subcmd".to_string(), 412 aliases: vec![], 413 flags: vec![], 414 subcommands: vec![], 415 }], 416 }; 417 m.ast = crate::ast::Segment::new_empty("root"); 418 m.current = Some(def.clone()); 419 m.build_items_from_command(&def); 420 let visible = m.render_visible_items(); 421 let list = m.render_list_content(&visible); 422 let stripped = strip_ansi(&list); 423 assert!(stripped.contains("--verbose") || stripped.contains("-v")); 424 assert!(stripped.contains("sub")); 425 } 426 427 #[test] 428 fn render_flag_add_remove_toggle_and_render() { 429 let mut m = crate::ui::initial_model(vec![]); 430 let def = crate::ast::CommandDef { 431 name: "root".to_string(), 432 short: "rootcmd".to_string(), 433 aliases: vec![], 434 flags: vec![ 435 crate::ast::FlagDef { 436 longhand: "message".to_string(), 437 shorthand: "m".to_string(), 438 usage: "msg".to_string(), 439 requires_value: true, 440 }, 441 crate::ast::FlagDef { 442 longhand: "verbose".to_string(), 443 shorthand: "v".to_string(), 444 usage: "v".to_string(), 445 requires_value: false, 446 }, 447 ], 448 subcommands: vec![], 449 }; 450 m.ast = crate::ast::Segment::new_empty("root"); 451 m.current = Some(def.clone()); 452 m.build_items_from_command(&def); 453 m.ast.add_flag_to_depth(0, "--verbose", ""); 454 let preview = m.render_preview(); 455 let stripped = strip_ansi(&preview); 456 assert!(stripped.contains("--verbose")); 457 let removed = m.ast.remove_flag_from_depth("--verbose", 0); 458 assert!(removed); 459 let preview2 = m.render_preview(); 460 let stripped2 = strip_ansi(&preview2); 461 assert!(!stripped2.contains("--verbose")); 462 m.ast.add_flag_to_depth(0, "--message", "hello"); 463 let preview3 = m.render_preview(); 464 assert!(strip_ansi(&preview3).contains("--message")); 465 } 466 467 #[test] 468 fn render_add_positionals_and_undo_to_root() { 469 let mut m = crate::ui::initial_model(vec![]); 470 m.ast = crate::ast::Segment::new_empty("root"); 471 m.ast.push_subcommand("sub"); 472 m.ast.add_flag_to_depth(0, "--rootflag", ""); 473 m.ast.add_positional("a"); 474 m.ast.add_positional("b"); 475 let p = strip_ansi(&m.render_preview()); 476 assert_eq!(p, "root --rootflag sub a b"); 477 m.ast.remove_last(); 478 assert_eq!(strip_ansi(&m.render_preview()), "root --rootflag sub a"); 479 m.ast.remove_last(); 480 assert_eq!(strip_ansi(&m.render_preview()), "root --rootflag sub"); 481 m.ast.remove_last(); 482 assert_eq!(strip_ansi(&m.render_preview()), "root sub"); 483 m.ast.remove_last(); 484 assert_eq!(strip_ansi(&m.render_preview()).trim(), "root"); 485 } 486 487 #[test] 488 fn render_parent_and_subcommand_flags_preview_and_undo() { 489 let mut m = crate::ui::initial_model(vec![]); 490 m.ast = crate::ast::Segment::new_empty("root"); 491 m.ast.push_subcommand("sub"); 492 m.ast.add_flag_to_depth(0, "--rootflag", ""); 493 m.ast.add_flag_to_depth(1, "--subflag", ""); 494 assert!( 495 strip_ansi(&m.render_preview()).contains("--rootflag") 496 && strip_ansi(&m.render_preview()).contains("--subflag") 497 ); 498 m.ast.remove_last(); 499 assert!(!strip_ansi(&m.render_preview()).contains("--subflag")); 500 } 501 502 #[test] 503 fn render_typed_buffer_preserved_and_highlighted_on_ambiguity() { 504 let mut m = crate::ui::initial_model(vec![]); 505 m.items = vec![ 506 crate::ui::ChooseItem { 507 kind: "cmd".to_string(), 508 label: "chcpu".to_string(), 509 forms: vec!["chcpu".to_string()], 510 flag_def: None, 511 cmd_def: None, 512 short: String::new(), 513 depth: 0, 514 }, 515 crate::ui::ChooseItem { 516 kind: "cmd".to_string(), 517 label: "chgrp".to_string(), 518 forms: vec!["chgrp".to_string()], 519 flag_def: None, 520 cmd_def: None, 521 short: String::new(), 522 depth: 0, 523 }, 524 crate::ui::ChooseItem { 525 kind: "cmd".to_string(), 526 label: "chroot".to_string(), 527 forms: vec!["chroot".to_string()], 528 flag_def: None, 529 cmd_def: None, 530 short: String::new(), 531 depth: 0, 532 }, 533 crate::ui::ChooseItem { 534 kind: "cmd".to_string(), 535 label: "chpasswd".to_string(), 536 forms: vec!["chpasswd".to_string()], 537 flag_def: None, 538 cmd_def: None, 539 short: String::new(), 540 depth: 0, 541 }, 542 ]; 543 m.typed_raw = "c".to_string(); 544 545 // filtered visible items should include multiple candidates (ambiguity) 546 let visible = m.render_visible_items(); 547 assert!( 548 visible.len() >= 2, 549 "expected at least two visible candidates when typed 'c'" 550 ); 551 552 // assigned disambiguators should be present for the visible forms 553 let assigned = m.assigned_map(); 554 for it in &visible { 555 for f in &it.forms { 556 let pref = assigned.get(f).cloned().unwrap_or_default(); 557 assert!(!pref.is_empty(), "expected disambiguator for form {f}"); 558 } 559 } 560 561 // typed buffer should be preserved in the model mode 562 // model.mode() reflects the normalized typed buffer (`typed`), ensure it's set 563 m.typed = "c".to_string(); 564 assert_eq!(m.mode(), "Typed: c"); 565 566 // ACE highlight must still be present in the rendered output for at least 567 // one of the assigned disambiguators (ANSI-coded). We don't require it 568 // to be 'c' specifically because assign_ace_keys may choose a different 569 // disambiguator rune in the filtered set. 570 let list = m.render_list_content(&visible); 571 let mut found_ace = false; 572 for (_k, v) in assigned.iter() { 573 if !v.is_empty() { 574 let styled = crate::ui::render::styles::STYLE_ACE.render(v); 575 if list.contains(&styled) { 576 found_ace = true; 577 break; 578 } 579 } 580 } 581 assert!( 582 found_ace, 583 "expected at least one ACE-styled disambiguator present in rendered list" 584 ); 585 } 586}