use std::{
env, fs,
io::{self, stdout},
path::PathBuf,
process::Command,
};
use crossterm::{
event::{self, Event, KeyCode, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
Terminal,
};
const DESC_COLUMN: usize = 40;
#[derive(Clone)]
enum EntryKind {
Command { description: String },
Note,
}
#[derive(Clone)]
struct Entry {
text: String, kind: EntryKind,
}
impl Entry {
fn is_command(&self) -> bool {
matches!(self.kind, EntryKind::Command { .. })
}
fn description(&self) -> &str {
match &self.kind {
EntryKind::Command { description } => description,
EntryKind::Note => "",
}
}
fn matches(&self, query: &str) -> bool {
if query.is_empty() { return true; }
let q = query.to_lowercase();
self.text.to_lowercase().contains(&q) || self.description().to_lowercase().contains(&q)
}
fn placeholders(&self) -> Vec<String> {
let mut result = Vec::new();
let mut s = self.text.as_str();
while let Some(open) = s.find('<') {
let rest = &s[open + 1..];
if let Some(close) = rest.find('>') {
let name = &rest[..close];
if !name.is_empty() && !name.contains(' ') {
result.push(name.to_string());
}
s = &rest[close + 1..];
} else {
break;
}
}
result
}
fn fill_placeholders(&self, values: &[(String, String)]) -> String {
let mut cmd = self.text.clone();
for (name, value) in values {
cmd = cmd.replace(&format!("<{}>", name), value);
}
cmd
}
}
fn parse_file(path: &PathBuf) -> Vec<Entry> {
let content = fs::read_to_string(path).unwrap_or_default();
let mut entries = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() { continue; }
if let Some(note) = line.strip_prefix("# ") {
entries.push(Entry { text: note.to_string(), kind: EntryKind::Note });
} else if line.starts_with("##") {
continue;
} else {
let (command, description) = if let Some(idx) = line.find(" # ") {
(line[..idx].trim().to_string(), line[idx + 3..].trim().to_string())
} else {
(line.to_string(), String::new())
};
if !command.is_empty() {
entries.push(Entry { text: command, kind: EntryKind::Command { description } });
}
}
}
entries
}
fn default_entries() -> Vec<Entry> {
vec![Entry {
text: "mkdir -p $HOME/.config/helpme".to_string(),
kind: EntryKind::Command { description: "Create the commands directory".to_string() },
}]
}
fn default_file_path() -> PathBuf {
if let Ok(val) = env::var("CMDS_FILE") { return PathBuf::from(val); }
let home = env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".config/helpme/commands")
}
#[derive(PartialEq)]
enum Mode {
Browse,
FillPlaceholders,
Confirm,
}
struct App {
all_entries: Vec<Entry>,
filtered: Vec<usize>, query: String,
list_state: ListState,
file_path: PathBuf,
is_default: bool,
action: Option<Action>,
pending_action: Option<Action>, mode: Mode,
placeholders: Vec<String>, ph_values: Vec<String>, ph_current_input: String, ph_base_command: String, }
enum Action {
Execute(String),
Yank(String),
}
impl App {
fn new(file_path: PathBuf) -> Self {
let parsed = parse_file(&file_path);
let (all_entries, is_default) = if parsed.is_empty() {
(default_entries(), true)
} else {
(parsed, false)
};
let filtered: Vec<usize> = all_entries.iter().enumerate()
.filter(|(_, e)| e.is_command())
.map(|(i, _)| i)
.collect();
let mut list_state = ListState::default();
if !filtered.is_empty() { list_state.select(Some(0)); }
Self {
all_entries, filtered, query: String::new(), list_state,
file_path, is_default, action: None, pending_action: None,
mode: Mode::Browse,
placeholders: Vec::new(), ph_values: Vec::new(),
ph_current_input: String::new(), ph_base_command: String::new(),
}
}
fn refilter(&mut self) {
let prev_cmd = self.list_state.selected()
.and_then(|i| self.filtered.get(i).copied())
.and_then(|idx| self.all_entries.get(idx))
.map(|e| e.text.clone());
self.filtered = self.all_entries.iter().enumerate()
.filter(|(_, e)| e.is_command() && e.matches(&self.query))
.map(|(i, _)| i)
.collect();
let new_sel = prev_cmd
.and_then(|cmd| self.filtered.iter().position(|&i| self.all_entries[i].text == cmd))
.or_else(|| if self.filtered.is_empty() { None } else { Some(0) });
self.list_state.select(new_sel);
}
fn selected_entry(&self) -> Option<&Entry> {
self.list_state.selected()
.and_then(|i| self.filtered.get(i))
.and_then(|&idx| self.all_entries.get(idx))
}
fn move_up(&mut self) {
if self.filtered.is_empty() { return; }
let i = self.list_state.selected().unwrap_or(0);
self.list_state.select(Some(if i == 0 { self.filtered.len() - 1 } else { i - 1 }));
}
fn move_down(&mut self) {
if self.filtered.is_empty() { return; }
let i = self.list_state.selected().unwrap_or(0);
self.list_state.select(Some((i + 1) % self.filtered.len()));
}
fn start_fill(&mut self) {
if let Some(entry) = self.selected_entry() {
let phs = entry.placeholders();
if phs.is_empty() {
self.pending_action = Some(Action::Execute(entry.text.clone()));
self.mode = Mode::Confirm;
} else {
self.ph_base_command = entry.text.clone();
self.placeholders = phs;
self.ph_values = Vec::new();
self.ph_current_input = String::new();
self.mode = Mode::FillPlaceholders;
}
}
}
fn start_yank(&mut self) {
if let Some(entry) = self.selected_entry() {
let phs = entry.placeholders();
if phs.is_empty() {
self.pending_action = Some(Action::Yank(entry.text.clone()));
self.mode = Mode::Confirm;
} else {
self.ph_base_command = entry.text.clone();
self.placeholders = phs;
self.ph_values = Vec::new();
self.ph_current_input = String::new();
self.mode = Mode::FillPlaceholders;
}
}
}
fn ph_index(&self) -> usize { self.ph_values.len() }
fn ph_confirm(&mut self, yank: bool) {
self.ph_values.push(self.ph_current_input.clone());
self.ph_current_input = String::new();
if self.ph_values.len() == self.placeholders.len() {
let pairs: Vec<(String, String)> = self.placeholders.iter().cloned()
.zip(self.ph_values.iter().cloned()).collect();
let tmp = Entry { text: self.ph_base_command.clone(), kind: EntryKind::Command { description: String::new() } };
let filled = tmp.fill_placeholders(&pairs);
self.pending_action = Some(if yank { Action::Yank(filled) } else { Action::Execute(filled) });
self.mode = Mode::Confirm;
}
}
fn notes_for_selected(&self) -> Vec<String> {
self.all_entries.iter()
.filter(|e| !e.is_command())
.map(|e| e.text.clone())
.collect()
}
}
fn ui(f: &mut ratatui::Frame, app: &mut App) {
let area = f.size();
let notes: Vec<String> = app.notes_for_selected();
let notes_height = if notes.is_empty() { 0u16 } else { (notes.len() as u16 + 2).min(area.height / 3) };
let outer_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(if notes_height > 0 {
vec![
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(notes_height),
Constraint::Length(3),
]
} else {
vec![
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(3),
]
})
.split(area);
let (search_chunk, list_chunk, notes_chunk, status_chunk) = if notes_height > 0 {
(outer_chunks[0], outer_chunks[1], Some(outer_chunks[2]), outer_chunks[3])
} else {
(outer_chunks[0], outer_chunks[1], None, outer_chunks[2])
};
let (search_title, search_content, search_border_color) = if app.mode == Mode::FillPlaceholders {
let idx = app.ph_index();
let name = &app.placeholders[idx];
let title = format!(" fill <{}> ({}/{}) ", name, idx + 1, app.placeholders.len());
(title, format!(" {}", app.ph_current_input), Color::Yellow)
} else if app.mode == Mode::Confirm {
let cmd = match &app.pending_action {
Some(Action::Execute(c)) => c.clone(),
Some(Action::Yank(c)) => format!("copy: {}", c),
None => String::new(),
};
(" confirm ".to_string(), format!(" run {}? Y/n", cmd), Color::Red)
} else {
(" search ".to_string(), format!(" {}", app.query), Color::Blue)
};
let search = Paragraph::new(search_content)
.block(Block::default().borders(Borders::ALL).title(search_title)
.border_style(Style::default().fg(search_border_color)))
.style(Style::default().fg(Color::White));
f.render_widget(search, search_chunk);
let max_cmd_len = app.filtered.iter()
.filter_map(|&idx| app.all_entries.get(idx))
.map(|e| e.text.len())
.max()
.unwrap_or(0);
let desc_col = max_cmd_len.max(DESC_COLUMN) + 2;
let items: Vec<ListItem> = app.filtered.iter().map(|&idx| {
let entry = &app.all_entries[idx];
let cmd_display_len = 2 + entry.text.len();
let pad_len = desc_col.saturating_sub(cmd_display_len);
let cmd_spans = build_command_spans(&entry.text);
let mut spans = vec![Span::raw(" ")];
spans.extend(cmd_spans);
if !entry.description().is_empty() {
spans.push(Span::raw(" ".repeat(pad_len)));
spans.push(Span::styled(entry.description().to_string(), Style::default().fg(Color::Gray)));
}
ListItem::new(Line::from(spans))
}).collect();
let count_title = if app.is_default {
" no commands file found ".to_string()
} else {
format!(" commands ({}/{}) ", app.filtered.len(), app.all_entries.len())
};
let list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(count_title)
.border_style(Style::default().fg(if app.is_default { Color::Yellow } else { Color::DarkGray })))
.highlight_style(Style::default().bg(Color::DarkGray).fg(Color::White).add_modifier(Modifier::BOLD))
.highlight_symbol("โถ ");
f.render_stateful_widget(list, list_chunk, &mut app.list_state);
if let Some(nc) = notes_chunk {
let note_lines: Vec<Line> = notes.iter().map(|n| {
Line::from(vec![
Span::styled(" ", Style::default()),
Span::styled(n.clone(), Style::default().fg(Color::White)),
])
}).collect();
let notes_widget = Paragraph::new(note_lines)
.block(Block::default().borders(Borders::ALL).title(" notes ")
.border_style(Style::default().fg(Color::DarkGray)));
f.render_widget(notes_widget, nc);
}
let hint = if app.mode == Mode::Confirm {
" y / enter confirm โ n / esc cancel".to_string()
} else if app.mode == Mode::FillPlaceholders {
let done: Vec<String> = app.placeholders.iter().zip(app.ph_values.iter())
.map(|(k, v)| format!("<{}>={}", k, v))
.collect();
if done.is_empty() {
" enter confirm โ esc cancel".to_string()
} else {
format!(" {} โ enter confirm โ esc cancel", done.join(" "))
}
} else if let Some(entry) = app.selected_entry() {
format!(" {} โ โโ navigate โ enter execute โ y copy โ esc quit", entry.text)
} else {
" no commands found โ esc quit".to_string()
};
let file_label = format!(" {} ", app.file_path.display());
let pad = " ".repeat((area.width as usize).saturating_sub(hint.len() + file_label.len()));
let status = Paragraph::new(Line::from(vec![
Span::styled(hint, Style::default().fg(Color::DarkGray)),
Span::raw(pad),
Span::styled(file_label, Style::default().fg(if app.is_default { Color::Yellow } else { Color::Green })),
])).block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::DarkGray)));
f.render_widget(status, status_chunk);
}
fn build_command_spans(cmd: &str) -> Vec<Span<'static>> {
let mut spans = Vec::new();
let mut remaining = cmd.to_string();
loop {
if let Some(open) = remaining.find('<') {
let before = remaining[..open].to_string();
if !before.is_empty() {
spans.push(Span::styled(before, Style::default().fg(Color::Cyan)));
}
let rest = remaining[open + 1..].to_string();
if let Some(close) = rest.find('>') {
let name = rest[..close].to_string();
let placeholder = format!("<{}>", name);
spans.push(Span::styled(placeholder, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)));
remaining = rest[close + 1..].to_string();
} else {
spans.push(Span::styled(format!("<{}", rest), Style::default().fg(Color::Cyan)));
break;
}
} else {
if !remaining.is_empty() {
spans.push(Span::styled(remaining, Style::default().fg(Color::Cyan)));
}
break;
}
}
spans
}
fn main() -> io::Result<()> {
let file_path = default_file_path();
let mut app = App::new(file_path);
let mut yanking = false;
enable_raw_mode()?;
let mut out = stdout();
execute!(out, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(out);
let mut terminal = Terminal::new(backend)?;
loop {
terminal.draw(|f| ui(f, &mut app))?;
if let Event::Key(key) = event::read()? {
match app.mode {
Mode::FillPlaceholders => match (key.modifiers, key.code) {
(_, KeyCode::Esc) => {
app.mode = Mode::Browse;
yanking = false;
}
(_, KeyCode::Enter) => app.ph_confirm(yanking),
(_, KeyCode::Backspace) => { app.ph_current_input.pop(); }
(_, KeyCode::Char(c)) => app.ph_current_input.push(c),
_ => {}
},
Mode::Confirm => match (key.modifiers, key.code) {
(_, KeyCode::Char('y')) | (_, KeyCode::Enter) => {
app.action = app.pending_action.take();
app.mode = Mode::Browse;
break;
}
(_, KeyCode::Char('n')) | (_, KeyCode::Esc) => {
app.pending_action = None;
app.mode = Mode::Browse;
}
_ => {}
},
Mode::Browse => match (key.modifiers, key.code) {
(_, KeyCode::Esc) | (KeyModifiers::CONTROL, KeyCode::Char('c')) => break,
(_, KeyCode::Up) | (KeyModifiers::CONTROL, KeyCode::Char('k')) => app.move_up(),
(_, KeyCode::Down) | (KeyModifiers::CONTROL, KeyCode::Char('j')) => app.move_down(),
(_, KeyCode::Enter) => {
yanking = false;
app.start_fill();
if app.action.is_some() { break; }
}
(KeyModifiers::NONE, KeyCode::Char('y')) => {
yanking = true;
app.start_yank();
if app.action.is_some() { break; }
}
(_, KeyCode::Char(c)) => { app.query.push(c); app.refilter(); }
(_, KeyCode::Backspace) => { app.query.pop(); app.refilter(); }
_ => {}
},
}
}
if app.action.is_some() { break; }
}
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
match app.action {
Some(Action::Execute(cmd)) => {
eprintln!("ยป {}", cmd);
let status = Command::new("sh").arg("-c").arg(&cmd).status()?;
std::process::exit(status.code().unwrap_or(1));
}
Some(Action::Yank(cmd)) => {
let clipboard_cmds: &[(&str, &[&str])] = &[
("wl-copy", &[]),
("xclip", &["-selection", "clipboard"]),
("pbcopy", &[]),
("xsel", &["--clipboard", "--input"]),
];
let mut copied = false;
for (bin, args) in clipboard_cmds {
if let Ok(mut child) = Command::new(bin).args(*args)
.stdin(std::process::Stdio::piped()).spawn()
{
if let Some(mut stdin) = child.stdin.take() {
use std::io::Write;
let _ = stdin.write_all(cmd.as_bytes());
}
let _ = child.wait();
copied = true;
break;
}
}
if copied { println!("copied: {}", cmd); }
else { println!("(no clipboard utility found): {}", cmd); }
}
None => {}
}
Ok(())
}