use crate::explore_regex::app::{App, InputFocus};
use crate::explore_regex::colors::{BG_DARK, FG_PRIMARY, styles};
use crate::explore_regex::quick_ref::QuickRefEntry;
use edtui::{
EditorEventHandler, EditorMode, EditorTheme, EditorView,
actions::Paste,
events::{KeyEventRegister, KeyInput},
};
use ratatui::{
Terminal,
backend::CrosstermBackend,
crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers},
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::Style,
text::{Line, Span},
widgets::{
Block, BorderType, Borders, Padding, Paragraph, Scrollbar, ScrollbarOrientation,
ScrollbarState, Widget,
},
};
use std::io::{self, Stdout};
use unicode_width::UnicodeWidthStr;
enum KeyAction {
Quit,
ToggleQuickRef,
ShowHelp,
CloseHelp,
SwitchFocus,
FocusRegex,
QuickRefUp,
QuickRefDown,
QuickRefPageUp,
QuickRefPageDown,
QuickRefLeft,
QuickRefRight,
QuickRefHome,
QuickRefInsert,
PassToEditor(event::KeyEvent),
None,
}
fn determine_action(app: &App, key: &event::KeyEvent) -> KeyAction {
if app.show_help {
return KeyAction::CloseHelp;
}
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('q') {
return KeyAction::Quit;
}
if key.code == KeyCode::F(1) {
return KeyAction::ToggleQuickRef;
}
if key.code == KeyCode::F(2) {
return KeyAction::ShowHelp;
}
if app.show_quick_ref && app.input_focus == InputFocus::QuickRef {
return match key.code {
KeyCode::Up | KeyCode::Char('k') => KeyAction::QuickRefUp,
KeyCode::Down | KeyCode::Char('j') => KeyAction::QuickRefDown,
KeyCode::PageUp => KeyAction::QuickRefPageUp,
KeyCode::PageDown => KeyAction::QuickRefPageDown,
KeyCode::Left | KeyCode::Char('h') => KeyAction::QuickRefLeft,
KeyCode::Right | KeyCode::Char('l') => KeyAction::QuickRefRight,
KeyCode::Home => KeyAction::QuickRefHome,
KeyCode::Enter => KeyAction::QuickRefInsert,
KeyCode::Esc | KeyCode::Tab | KeyCode::BackTab => KeyAction::FocusRegex,
_ => KeyAction::None,
};
}
if matches!(key.code, KeyCode::Tab | KeyCode::BackTab) {
return KeyAction::SwitchFocus;
}
if key.code == KeyCode::Esc {
return KeyAction::FocusRegex;
}
KeyAction::PassToEditor(*key)
}
fn execute_action(
app: &mut App,
action: KeyAction,
event_handler: &mut EditorEventHandler,
) -> bool {
match action {
KeyAction::Quit => return true,
KeyAction::ToggleQuickRef => app.toggle_quick_ref(),
KeyAction::ShowHelp => app.toggle_help(),
KeyAction::CloseHelp => app.show_help = false,
KeyAction::SwitchFocus => {
app.input_focus = match app.input_focus {
InputFocus::Regex => InputFocus::Sample,
InputFocus::Sample | InputFocus::QuickRef => InputFocus::Regex,
};
}
KeyAction::FocusRegex => {
if app.show_quick_ref && app.input_focus == InputFocus::QuickRef {
app.close_quick_ref();
} else {
app.input_focus = InputFocus::Regex;
}
}
KeyAction::QuickRefUp => app.quick_ref_up(),
KeyAction::QuickRefDown => app.quick_ref_down(),
KeyAction::QuickRefPageUp => app.quick_ref_page_up(),
KeyAction::QuickRefPageDown => app.quick_ref_page_down(),
KeyAction::QuickRefLeft => app.quick_ref_scroll_left(),
KeyAction::QuickRefRight => app.quick_ref_scroll_right(),
KeyAction::QuickRefHome => app.quick_ref_scroll_home(),
KeyAction::QuickRefInsert => app.insert_selected_quick_ref(),
KeyAction::PassToEditor(key) => handle_editor_input(app, key, event_handler),
KeyAction::None => {}
}
false
}
fn handle_editor_input(
app: &mut App,
key: event::KeyEvent,
event_handler: &mut EditorEventHandler,
) {
match app.input_focus {
InputFocus::Regex => {
let old_value = app.regex_input.lines.to_string();
event_handler.on_key_event(key, &mut app.regex_input);
if app.regex_input.lines.to_string() != old_value {
app.compile_regex();
}
}
InputFocus::Sample => {
let old_text = app.get_sample_text();
event_handler.on_key_event(key, &mut app.sample_text);
if app.get_sample_text() != old_text {
app.update_match_count();
}
}
InputFocus::QuickRef => {}
}
}
pub fn run_app_loop(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
app: &mut App,
) -> io::Result<()> {
let mut event_handler = EditorEventHandler::emacs_mode();
event_handler.key_handler.insert(
KeyEventRegister::new(vec![KeyInput::ctrl('v')], EditorMode::Insert),
Paste,
);
loop {
terminal.draw(|f| draw_ui(f, app))?;
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind != KeyEventKind::Press {
continue;
}
let action = determine_action(app, &key);
if execute_action(app, action, &mut event_handler) {
return Ok(());
}
}
}
fn draw_ui(f: &mut ratatui::Frame, app: &mut App) {
let outer_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(styles::border_unfocused())
.title(Line::from(vec![Span::styled(
" Regex Explorer ",
styles::focused(),
)]))
.title_alignment(Alignment::Left);
let inner_area = outer_block.inner(f.area());
f.render_widget(outer_block, f.area());
if app.show_quick_ref {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(40), Constraint::Length(40)])
.split(inner_area);
draw_main_content(f, app, chunks[0]);
draw_quick_ref_panel(f, app, chunks[1]);
} else {
draw_main_content(f, app, inner_area);
}
if app.show_help {
draw_help_modal_overlay(f, app, f.area());
}
}
fn draw_main_content(f: &mut ratatui::Frame, app: &mut App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(3), Constraint::Length(1), Constraint::Length(1), Constraint::Min(6), Constraint::Length(1), Constraint::Length(1), ])
.horizontal_margin(2)
.split(area);
draw_regex_section(f, app, chunks[1], chunks[2]);
draw_sample_section(f, app, chunks[4], chunks[5]);
draw_help(f, app, chunks[7]);
}
fn draw_regex_section(f: &mut ratatui::Frame, app: &mut App, label_area: Rect, input_area: Rect) {
let focused = app.input_focus == InputFocus::Regex;
let status = match (&app.regex_error, &app.compiled_regex) {
(Some(_), _) => Some(("invalid", styles::status_error())),
(None, Some(_)) => Some(("valid", styles::status_success())),
_ => None,
};
let label = build_label(
"Regex Pattern",
focused,
status.map(|(t, s)| (t.to_string(), s)),
);
f.render_widget(Paragraph::new(label), label_area);
let border_style = if focused {
if app.regex_error.is_some() {
styles::border_error()
} else {
styles::border_focused()
}
} else {
styles::border_unfocused()
};
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(border_style)
.padding(Padding::horizontal(1));
let theme = EditorTheme::default()
.block(block)
.base(Style::default()) .hide_cursor() .hide_status_line(); EditorView::new(&mut app.regex_input)
.theme(theme)
.single_line(true)
.render(input_area, f.buffer_mut());
if focused && let Some(pos) = app.regex_input.cursor_screen_position() {
f.set_cursor_position(pos);
}
}
fn draw_sample_section(
f: &mut ratatui::Frame,
app: &mut App,
label_area: Rect,
content_area: Rect,
) {
let focused = app.input_focus == InputFocus::Sample;
let status: Option<(String, Style)> = if app.match_count > 0 {
let text = if app.match_count == 1 {
"1 match".to_string()
} else {
format!("{} matches", app.match_count)
};
Some((text, styles::separator()))
} else if app.compiled_regex.is_some() {
Some(("no matches".to_string(), styles::status_warning()))
} else {
None
};
let label = build_label("Test String", focused, status);
f.render_widget(Paragraph::new(label), label_area);
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(if focused {
styles::border_focused()
} else {
styles::border_unfocused()
})
.padding(Padding::horizontal(1));
app.sample_text.set_highlights(app.get_highlights());
let theme = EditorTheme::default()
.block(block)
.base(Style::default())
.hide_cursor()
.hide_status_line();
EditorView::new(&mut app.sample_text)
.theme(theme)
.wrap(false)
.render(content_area, f.buffer_mut());
if focused && let Some(pos) = app.sample_text.cursor_screen_position() {
f.set_cursor_position(pos);
}
}
fn build_label(
title: &str,
focused: bool,
status: Option<(impl Into<String>, Style)>,
) -> Line<'static> {
let mut spans = if focused {
vec![
Span::styled("> ", styles::focus_indicator()),
Span::styled(title.to_string(), styles::focused()),
]
} else {
vec![
Span::styled(" ", styles::unfocused()),
Span::styled(title.to_string(), styles::unfocused()),
]
};
if let Some((text, style)) = status {
spans.push(Span::styled(" [", styles::status_bracket()));
spans.push(Span::styled(text.into(), style));
spans.push(Span::styled("]", styles::status_bracket()));
}
Line::from(spans)
}
fn draw_help(f: &mut ratatui::Frame, app: &App, area: Rect) {
let sep = Span::styled(" • ", styles::separator());
let mut spans = vec![
help_key("Tab"),
help_desc(" Switch Focus"),
sep.clone(),
help_key("Esc"),
help_desc(" Focus Regex"),
sep.clone(),
help_key("F1"),
help_desc(if app.show_quick_ref {
" Hide Quick Ref"
} else {
" Quick Ref"
}),
sep.clone(),
help_key("F2"),
help_desc(" Help"),
sep.clone(),
help_key("Ctrl+Q"),
help_desc(" Exit"),
];
if app.show_quick_ref && app.input_focus == InputFocus::QuickRef {
spans.push(sep);
spans.push(help_key("↑↓"));
spans.push(help_desc(" Navigate"));
spans.push(Span::styled(" ", styles::separator()));
spans.push(help_key("←→"));
spans.push(help_desc(" Scroll"));
spans.push(Span::styled(" ", styles::separator()));
spans.push(help_key("Enter"));
spans.push(help_desc(" Insert"));
}
f.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn help_key(text: &str) -> Span<'static> {
Span::styled(text.to_string(), styles::focused())
}
fn help_desc(text: &str) -> Span<'static> {
Span::styled(text.to_string(), styles::separator())
}
fn draw_quick_ref_panel(f: &mut ratatui::Frame, app: &mut App, area: Rect) {
let focused = app.input_focus == InputFocus::QuickRef;
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(if focused {
styles::border_focused()
} else {
styles::border_unfocused()
})
.title(Line::from(vec![Span::styled(
" Quick Reference ",
styles::focused(),
)]))
.title_alignment(Alignment::Center)
.padding(Padding::horizontal(1));
let inner = block.inner(area);
f.render_widget(block, area);
let visible_height = inner.height as usize;
let visible_width = inner.width;
app.quick_ref_view_height = visible_height;
app.quick_ref_view_width = visible_width;
if app.quick_ref_selected < app.quick_ref_scroll {
app.quick_ref_scroll = app.quick_ref_selected;
} else if app.quick_ref_selected >= app.quick_ref_scroll + visible_height {
app.quick_ref_scroll = app.quick_ref_selected - visible_height + 1;
}
let lines: Vec<Line> = app
.quick_ref_entries
.iter()
.enumerate()
.skip(app.quick_ref_scroll)
.take(visible_height)
.map(|(idx, entry)| build_quick_ref_line(entry, idx, app.quick_ref_selected, focused))
.collect();
let paragraph = Paragraph::new(lines).scroll((0, app.quick_ref_scroll_h));
f.render_widget(paragraph, inner);
if app.quick_ref_entries.len() > visible_height {
draw_scrollbar(f, area, app.quick_ref_entries.len(), app.quick_ref_scroll);
}
}
fn build_quick_ref_line(
entry: &QuickRefEntry,
idx: usize,
selected: usize,
focused: bool,
) -> Line<'static> {
const SYNTAX_WIDTH: usize = 14;
match entry {
QuickRefEntry::Category(name) => Line::from(vec![Span::styled(
format!("─ {} ─────────────────────────────────────", name),
styles::category_header(),
)]),
QuickRefEntry::Item(item) => {
let is_selected = idx == selected && focused;
let syntax = format!("{:<width$}", item.syntax, width = SYNTAX_WIDTH);
if is_selected {
Line::from(vec![
Span::styled(syntax, styles::selected_bold()),
Span::styled(" ", styles::selected()),
Span::styled(item.description.to_string(), styles::selected()),
Span::styled(" ", styles::selected()),
])
} else {
Line::from(vec![
Span::styled(syntax, styles::focused()),
Span::styled(" ", styles::unfocused()),
Span::styled(item.description.to_string(), styles::unfocused()),
])
}
}
}
}
fn draw_scrollbar(f: &mut ratatui::Frame, area: Rect, total: usize, position: usize) {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(Some("↑"))
.end_symbol(Some("↓"));
let mut state = ScrollbarState::new(total).position(position);
let scrollbar_area = Rect {
x: area.x + area.width - 2,
y: area.y + 1,
width: 1,
height: area.height.saturating_sub(2),
};
f.render_stateful_widget(scrollbar, scrollbar_area, &mut state);
}
fn draw_help_modal_overlay(f: &mut ratatui::Frame, _app: &App, area: Rect) {
let help_lines = vec![
"Global Shortcuts",
" Ctrl+Q Exit",
" F2 Toggle Help",
" F1 Toggle Quick Ref",
" Tab Switch Focus",
" Esc Focus Regex",
"",
"Quick Reference Panel",
" ↑↓ / jk Navigate",
" ←→ / hl Scroll",
" Enter Insert",
" PgUp/PgDn Page Scroll",
" Home Scroll to Start",
"",
"Regex Pattern Pane (single line)",
" ←→ Move cursor",
" Ctrl+F/B Forward/Back char",
" Ctrl+A/E Line head/end",
" Alt+F/B Forward/Back word",
" Backspace/Ctrl+H Delete char before",
" Delete/Ctrl+D Delete char after",
" Ctrl+K Delete to line end",
" Alt+U Delete to line head",
" Alt+Backspace Delete word before",
" Alt+D Delete word after",
" Ctrl+U Undo",
" Ctrl+R Redo",
" Ctrl+V / Ctrl+Y Paste from clipboard",
"",
"Test String Pane (multi-line) (same as above plus:)",
" Ctrl+N/P Next/Previous line",
" Enter/Ctrl+J Insert newline",
"",
"Press any key to close",
];
let content_height = help_lines.len() as u16;
let content_width = help_lines
.iter()
.map(|line| line.width() as u16)
.max()
.unwrap_or(30);
let modal_width = (content_width + 6).min(area.width - 4);
let modal_height = (content_height + 4).min(area.height - 4);
let modal_x = (area.width - modal_width) / 2;
let modal_y = (area.height - modal_height) / 2;
let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
let modal_bg = Style::default().bg(BG_DARK).fg(FG_PRIMARY);
let buf = f.buffer_mut();
for y in modal_area.y..modal_area.y + modal_area.height {
for x in modal_area.x..modal_area.x + modal_area.width {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_char(' ');
cell.set_style(modal_bg);
}
}
}
let modal_block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(styles::border_focused().bg(BG_DARK))
.style(modal_bg)
.title(Line::from(vec![Span::styled(
" Keybindings Help ",
styles::focused().bg(BG_DARK),
)]))
.title_alignment(Alignment::Center)
.padding(Padding::horizontal(2));
let inner_area = modal_block.inner(modal_area);
f.render_widget(modal_block, modal_area);
let help_text: Vec<Line> = help_lines
.into_iter()
.map(|line| {
if line.is_empty() {
Line::from(Span::styled(" ", modal_bg))
} else if line.starts_with(" ") {
let parts: Vec<&str> = line.splitn(2, " ").collect();
if parts.len() == 2 {
Line::from(vec![
Span::styled(parts[0].trim_end(), styles::focused().bg(BG_DARK)),
Span::styled(" ", modal_bg),
Span::styled(parts[1], styles::modal_desc().bg(BG_DARK)),
])
} else {
Line::from(vec![Span::styled(line, styles::modal_desc().bg(BG_DARK))])
}
} else {
Line::from(vec![Span::styled(
line,
styles::category_header().bg(BG_DARK),
)])
}
})
.collect();
let paragraph = Paragraph::new(help_text)
.style(modal_bg)
.wrap(ratatui::widgets::Wrap { trim: false })
.scroll((0, 0));
f.render_widget(paragraph, inner_area);
}