fork
Configure Feed
Select the types of activity you want to include in your feed.
command line structured editor in rust (bubbletea-rs TUI)
fork
Configure Feed
Select the types of activity you want to include in your feed.
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}