use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Color, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph},
};
use crate::app::{App, HelpState};
const HELP_ROWS: &[(&str, &str)] = &[
("Canvas", ""),
("?", "open or close this complete help"),
("ac / at", "search and add a call / type anchor"),
("tl / tr", "toggle the selected node's left / right branch"),
("r", "refresh both loaded directions of the selected node"),
(
"h j k l / arrows",
"select the nearest visible node by geometry",
),
(
"mouse click",
"select a node without moving it in the viewport",
),
(
"mouse double-click",
"send an exact source location to editor clients",
),
(
"mouse side button",
"select a node and toggle only that branch",
),
("mouse drag", "pan the viewport from a node or empty canvas"),
("dd", "unpin the selected anchor"),
("dp / dn", "clear the selected node's left / right branch"),
(
"ec",
"edit .cgraph.toml, reload it, and refresh loaded branches",
),
("g<", "open the bottom-aligned message-history pager"),
("w", "open the graph save dialog"),
("q / Esc", "quit cgraph"),
("", ""),
("Search", ""),
("text / Backspace", "edit the fuzzy workspace-symbol query"),
(
"Up / Down",
"move through results (Ctrl-p / Ctrl-n also work)",
),
("Enter / click", "accept the selected result"),
("mouse move / wheel", "highlight or scroll results"),
("Esc", "close search without adding an anchor"),
("", ""),
("Save", ""),
("text / Backspace", "edit the destination path"),
(
"Enter",
"create the destination without overwriting an existing file",
),
("Esc", "close save without writing"),
("", ""),
("Help", ""),
("Up / Down, j / k", "scroll one line"),
("PageUp / PageDown", "scroll one page"),
("Home / End", "jump to the first / last help row"),
("mouse wheel", "scroll help"),
("? / q / Esc", "close help without quitting cgraph"),
("", ""),
("Message pager", ""),
("j / k, Up / Down", "scroll one wrapped row"),
("Space / f / PageDown", "scroll one page down"),
("b / PageUp", "scroll one page up"),
("Ctrl-d / Ctrl-u", "scroll half a page down / up"),
("g / G, Home / End", "jump to the first / last message"),
("V", "start or cancel wrapped-line selection"),
("y", "copy the selected original text through OSC 52"),
(
"mouse drag",
"select with the terminal, then use its normal copy command",
),
("q / Esc", "close the message pager"),
];
pub(super) fn handle_key(app: &mut App, key: KeyEvent) {
let Some(help) = app.help.as_mut() else {
return;
};
match key.code {
KeyCode::Char('?') if matches!(key.modifiers, KeyModifiers::NONE | KeyModifiers::SHIFT) => {
app.close_help();
}
KeyCode::Char('q') | KeyCode::Esc if key.modifiers == KeyModifiers::NONE => {
app.close_help();
}
KeyCode::Up | KeyCode::Char('k') if key.modifiers == KeyModifiers::NONE => {
help.scroll = help.scroll.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') if key.modifiers == KeyModifiers::NONE => {
help.scroll = help.scroll.saturating_add(1).min(HELP_ROWS.len() - 1);
}
KeyCode::PageUp => help.scroll = help.scroll.saturating_sub(10),
KeyCode::PageDown => {
help.scroll = help.scroll.saturating_add(10).min(HELP_ROWS.len() - 1);
}
KeyCode::Home => help.scroll = 0,
KeyCode::End => help.scroll = HELP_ROWS.len() - 1,
_ => {}
}
}
pub(super) fn handle_mouse(app: &mut App, mouse: MouseEvent) {
let Some(help) = app.help.as_mut() else {
return;
};
match mouse.kind {
MouseEventKind::ScrollUp => help.scroll = help.scroll.saturating_sub(3),
MouseEventKind::ScrollDown => {
help.scroll = help.scroll.saturating_add(3).min(HELP_ROWS.len() - 1);
}
_ => {}
}
}
pub(super) fn render(frame: &mut Frame, help: &HelpState) {
let area = modal_area(frame.area());
frame.render_widget(Clear, area);
let block = Block::default()
.title(" Complete help ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan));
let inner = block.inner(area);
frame.render_widget(block, area);
let [body, footer] = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(inner);
let lines = HELP_ROWS
.iter()
.map(|(key, description)| {
if description.is_empty() {
Line::from(Span::styled(*key, Style::default().fg(Color::Yellow)))
} else {
Line::from(vec![
Span::styled(format!("{key:<22}"), Style::default().fg(Color::Cyan)),
Span::raw(*description),
])
}
})
.collect::<Vec<_>>();
let max_scroll = lines.len().saturating_sub(usize::from(body.height));
let scroll = help.scroll.min(max_scroll) as u16;
frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), body);
frame.render_widget(
Paragraph::new("↑↓/jk scroll · PgUp/PgDn page · Home/End jump · ?/q/Esc close")
.style(Style::default().fg(Color::DarkGray)),
footer,
);
}
fn modal_area(screen: Rect) -> Rect {
let width = screen.width.saturating_sub(2).min(100);
let height = screen.height.saturating_sub(2).min(36);
Rect::new(
screen.x + screen.width.saturating_sub(width) / 2,
screen.y + screen.height.saturating_sub(height) / 2,
width,
height,
)
}
#[cfg(test)]
mod tests {
use clap::Parser;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
use ratatui::{Terminal, backend::TestBackend, layout::Rect};
use super::super::{CanvasDragState, handle_event, render};
use crate::{app::App, cli::Cli};
#[test]
fn question_mark_help_lists_commands_scrolls_and_captures_canvas_input() {
let mut app = App::from_cli(Cli::try_parse_from(["cgraph", "call", "root"]).unwrap());
app.viewport.offset_x = 7;
let screen = Rect::new(0, 0, 110, 40);
let mut drag = CanvasDragState::default();
handle_event(
&mut app,
Event::Key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT)),
true,
screen,
&mut drag,
);
assert!(app.help.is_some());
let backend = TestBackend::new(screen.width, screen.height);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|frame| render(frame, &app)).unwrap();
let content =
terminal
.backend()
.buffer()
.content()
.iter()
.fold(String::new(), |mut output, cell| {
output.push_str(cell.symbol());
output
});
assert!(content.contains("Complete help"));
assert!(content.contains("edit .cgraph.toml"));
assert!(content.contains("PageUp / PageDown"));
handle_event(
&mut app,
Event::Key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)),
true,
screen,
&mut drag,
);
assert_eq!(app.pending_key, None);
assert_eq!(app.graph.anchors().len(), 1);
handle_event(
&mut app,
Event::Mouse(MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 10,
row: 10,
modifiers: KeyModifiers::NONE,
}),
true,
screen,
&mut drag,
);
assert_eq!(app.help.as_ref().unwrap().scroll, 3);
handle_event(
&mut app,
Event::Key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)),
true,
screen,
&mut drag,
);
let mut small_terminal = Terminal::new(TestBackend::new(60, 12)).unwrap();
small_terminal.draw(|frame| render(frame, &app)).unwrap();
let small_content = small_terminal.backend().buffer().content().iter().fold(
String::new(),
|mut output, cell| {
output.push_str(cell.symbol());
output
},
);
assert!(small_content.contains("mouse drag"));
assert!(small_content.contains("close the message pager"));
handle_event(
&mut app,
Event::Key(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT)),
true,
screen,
&mut drag,
);
assert!(app.help.is_none());
assert!(!app.should_quit);
assert_eq!(app.viewport.offset_x, 7);
}
}