mod render;
use crate::analysis::engine::{self, Analysis};
use crate::analysis::strings::Located;
use crate::db::Db;
use crate::listing::{self, Line};
use crate::model::Binary;
use anyhow::Result;
use ratatui::crossterm::event::{
self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
MouseEventKind,
};
use std::collections::BTreeMap;
use std::io::stdout;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Functions,
Listing,
Xrefs,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Ask {
Filter,
Name,
Note,
Goto,
}
impl Ask {
fn label(self) -> &'static str {
match self {
Ask::Filter => "filter",
Ask::Name => "name",
Ask::Note => "note",
Ask::Goto => "goto",
}
}
}
pub struct Prompt {
pub ask: Ask,
pub input: String,
pub at: u64,
}
pub struct App {
pub bin: Binary,
pub bytes: Vec<u8>,
pub db: Db,
pub an: Analysis,
pub base: u64,
pub title: String,
pub order: Vec<usize>,
pub sel: usize,
pub filter: String,
pub cur: Option<u64>,
pub lines: Vec<Line>,
pub cursor: usize,
pub focus: Focus,
pub prompt: Option<Prompt>,
pub history: Vec<(u64, usize)>,
pub status: String,
pub help: bool,
pub quit: bool,
pub xsel: usize,
pub dims: (u16, u16),
pub strings: BTreeMap<u64, Located>,
}
impl App {
pub fn new(bin: Binary, bytes: Vec<u8>, db: Db, an: Analysis, title: String) -> App {
let base = engine::display_base(&bin);
let strings = listing::string_map(&bin, &bytes, base);
let mut app = App {
bin,
bytes,
db,
an,
base,
title,
order: Vec::new(),
sel: 0,
filter: String::new(),
cur: None,
lines: Vec::new(),
cursor: 0,
focus: Focus::Functions,
prompt: None,
history: Vec::new(),
status: String::new(),
help: false,
quit: false,
xsel: 0,
dims: (0, 0),
strings,
};
app.refilter();
if let Some(&i) = app.order.first() {
let addr = app.an.functions[i].addr;
app.open(addr, false);
}
app
}
pub fn refilter(&mut self) {
let keep = self.selected_addr();
let f = self.filter.to_lowercase();
self.order = self
.an
.functions
.iter()
.enumerate()
.filter(|(_, fun)| f.is_empty() || fun.name.to_lowercase().contains(&f))
.map(|(i, _)| i)
.collect();
self.sel = keep
.and_then(|a| {
self.order
.iter()
.position(|&i| self.an.functions[i].addr == a)
})
.unwrap_or(0);
}
pub fn selected_addr(&self) -> Option<u64> {
self.order.get(self.sel).map(|&i| self.an.functions[i].addr)
}
pub fn move_sel(&mut self, delta: isize) {
if self.order.is_empty() {
return;
}
let last = self.order.len() - 1;
self.sel = self.sel.saturating_add_signed(delta).min(last);
}
pub fn open(&mut self, addr: u64, push: bool) {
let target = self
.an
.find_function(addr)
.or_else(|| self.an.function_at(addr))
.map(|f| f.addr);
let Some(faddr) = target else {
if self.an.xrefs_from.contains_key(&addr) || self.is_mapped(addr) {
if push {
if let Some(prev) = self.cur {
self.history.push((prev, self.cursor));
}
}
self.lines = listing::data_view(&self.bin, self.base, &self.bytes, addr);
self.cur = Some(addr);
self.cursor = 0;
self.clamp_xsel();
return;
}
self.status = format!(
"0x{:x} is not inside a recovered function",
addr + self.base
);
return;
};
if push {
if let Some(prev) = self.cur {
self.history.push((prev, self.cursor));
}
}
let f = self.an.find_function(faddr).expect("just resolved");
self.lines = listing::function(&self.an, f, &self.db, self.base, &self.strings);
self.cur = Some(faddr);
self.clamp_xsel();
self.cursor = self
.lines
.iter()
.position(|l| l.addr() == addr)
.unwrap_or(0);
if let Some(p) = self
.order
.iter()
.position(|&i| self.an.functions[i].addr == faddr)
{
self.sel = p;
}
}
fn is_mapped(&self, addr: u64) -> bool {
engine::va_to_off(&self.bin, self.base, addr).is_some()
}
pub fn back(&mut self) {
let Some((addr, cursor)) = self.history.pop() else {
self.status = "nothing to go back to".into();
return;
};
self.open(addr, false);
self.cursor = cursor.min(self.lines.len().saturating_sub(1));
}
pub fn move_cursor(&mut self, delta: isize) {
if self.lines.is_empty() {
return;
}
let last = self.lines.len() - 1;
self.cursor = self.cursor.saturating_add_signed(delta).min(last);
}
pub fn cursor_addr(&self) -> Option<u64> {
match self.focus {
Focus::Listing => self.lines.get(self.cursor).map(Line::addr),
Focus::Functions => self.selected_addr(),
Focus::Xrefs => None,
}
}
pub fn follow(&mut self) {
let line = self.lines.get(self.cursor);
if let Some(t) = line.and_then(Line::target) {
if self.an.find_function(t).is_none() && self.an.function_at(t).is_none() {
self.status = match self.an.imports.get(&t) {
Some(n) => format!("{n} is imported; there is no body to show"),
None => format!("0x{:x} is not a recovered function", t + self.base),
};
return;
}
self.open(t, true);
self.focus = Focus::Listing;
return;
}
let at = line.map(Line::addr).unwrap_or(0);
let to = match self.an.xrefs_from.get(&at).map(|r| r.first().map(|r| r.to)) {
Some(Some(t)) => t,
_ => {
self.status = "nothing to follow here".into();
return;
}
};
self.open(to, true);
self.focus = Focus::Listing;
}
pub fn xrefs(&self) -> (u64, &[engine::Xref]) {
let at = self.cursor_addr().or(self.cur).unwrap_or(0);
(
at,
self.an.xrefs_to.get(&at).map(Vec::as_slice).unwrap_or(&[]),
)
}
pub fn selected_xref(&self) -> Option<engine::Xref> {
let (_, refs) = self.xrefs();
refs.get(self.xsel).copied()
}
pub fn clamp_xsel(&mut self) {
let n = self.xrefs().1.len();
if n == 0 {
self.xsel = 0;
} else {
self.xsel = self.xsel.min(n - 1);
}
}
pub fn move_xsel(&mut self, delta: isize) {
let n = self.xrefs().1.len();
if n == 0 {
self.xsel = 0;
return;
}
let last = n - 1;
self.xsel = self.xsel.saturating_add_signed(delta).min(last);
}
pub fn jump_xref(&mut self) {
let Some(x) = self.selected_xref() else {
self.status = "no reference under the cursor".into();
return;
};
if self.an.function_at(x.from).is_none() && self.an.find_function(x.from).is_none() {
self.status = format!("0x{:x} is not in a recovered function", x.from + self.base);
return;
}
self.open(x.from, true);
self.focus = Focus::Listing;
}
fn commit(&mut self, ask: Ask, at: u64, text: String) {
let stored = at.wrapping_sub(self.base);
match ask {
Ask::Filter => {
self.filter = text;
self.refilter();
}
Ask::Goto => {
match parse_addr(&text)
.map(|v| vec![v])
.filter(|v| !v.is_empty())
.or_else(|| Some(self.an.resolve(&text, None)))
.filter(|v| !v.is_empty())
{
Some(v) => self.open(v[0], true),
None => self.status = format!("no symbol or address '{text}'"),
}
}
Ask::Name => {
if text.is_empty() {
let (n, _) = self.db.clear(stored);
self.status = match n {
Some(old) => format!("cleared the name {old}"),
None => "nothing to clear".into(),
};
} else {
self.db.set_name(stored, &text);
self.status = format!("named 0x{at:x} {text}");
}
self.save();
self.rename_in_place(at);
}
Ask::Note => {
if text.is_empty() {
self.db.clear(stored);
self.status = "cleared the note".into();
} else {
self.db.set_note(stored, &text);
self.status = format!("noted 0x{at:x}");
}
self.save();
self.relist();
}
}
}
fn save(&mut self) {
if let Err(e) = self.db.save() {
self.status = format!("could not save: {e:#}");
}
}
fn rename_in_place(&mut self, at: u64) {
let name = self.db.names.get(&at.wrapping_sub(self.base)).cloned();
let known = self.an.find_function(at).is_some();
match (name, known) {
(Some(n), true) => {
self.an.names.insert(at, n.clone());
if let Some(f) = self.an.functions.iter_mut().find(|f| f.addr == at) {
f.name = n;
f.named = true;
}
}
(None, true) => {
self.an.names.remove(&at);
if let Some(f) = self.an.functions.iter_mut().find(|f| f.addr == at) {
f.name = format!("sub_{at:x}");
f.named = false;
}
}
_ => self.reanalyze(),
}
self.refilter();
self.relist();
}
fn relist(&mut self) {
if let Some(addr) = self.cur {
if let Some(f) = self.an.find_function(addr) {
self.lines = listing::function(&self.an, f, &self.db, self.base, &self.strings);
self.cursor = self.cursor.min(self.lines.len().saturating_sub(1));
} else if engine::va_to_off(&self.bin, self.base, addr).is_some() {
self.lines = listing::data_view(&self.bin, self.base, &self.bytes, addr);
self.cursor = self.cursor.min(self.lines.len().saturating_sub(1));
}
}
}
pub fn reanalyze(&mut self) {
self.an = engine::analyze(&self.bin, &self.bytes, 2_000_000, &self.db);
self.refilter();
self.relist();
}
pub fn on_key(&mut self, key: KeyEvent) {
if key.kind != KeyEventKind::Press {
return; }
if let Some(p) = &mut self.prompt {
match key.code {
KeyCode::Esc => self.prompt = None,
KeyCode::Enter => {
let p = self.prompt.take().expect("checked above");
self.commit(p.ask, p.at, p.input);
}
KeyCode::Backspace => {
p.input.pop();
if p.ask == Ask::Filter {
let text = p.input.clone();
self.filter = text;
self.refilter();
}
}
KeyCode::Char(c) => {
p.input.push(c);
if p.ask == Ask::Filter {
let text = p.input.clone();
self.filter = text;
self.refilter();
}
}
_ => {}
}
return;
}
if self.help {
self.help = false;
return;
}
self.status.clear();
let page = 20isize;
match key.code {
KeyCode::Char('q') | KeyCode::Esc => self.quit = true,
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => self.quit = true,
KeyCode::Char('?') => self.help = true,
KeyCode::Tab => {
self.focus = match self.focus {
Focus::Functions => Focus::Listing,
Focus::Listing => Focus::Xrefs,
Focus::Xrefs => Focus::Functions,
}
}
KeyCode::Down | KeyCode::Char('j') => self.step(1),
KeyCode::Up | KeyCode::Char('k') => self.step(-1),
KeyCode::PageDown => self.step(page),
KeyCode::PageUp => self.step(-page),
KeyCode::Home => self.step(isize::MIN / 2),
KeyCode::End => self.step(isize::MAX / 2),
KeyCode::Enter => match self.focus {
Focus::Functions => {
if let Some(a) = self.selected_addr() {
self.open(a, true);
self.focus = Focus::Listing;
}
}
Focus::Listing => self.follow(),
Focus::Xrefs => self.jump_xref(),
},
KeyCode::Backspace => self.back(),
KeyCode::Char('/') => self.ask(Ask::Filter),
KeyCode::Char('g') => self.ask(Ask::Goto),
KeyCode::Char('n') => self.ask(Ask::Name),
KeyCode::Char('c') => self.ask(Ask::Note),
KeyCode::Char('r') => {
self.reanalyze();
self.status = "re-analysed".into();
}
_ => {}
}
}
fn step(&mut self, delta: isize) {
match self.focus {
Focus::Functions => self.move_sel(delta),
Focus::Listing => self.move_cursor(delta),
Focus::Xrefs => self.move_xsel(delta),
}
}
fn ask(&mut self, ask: Ask) {
let at = self.cursor_addr().unwrap_or(0);
let stored = at.wrapping_sub(self.base);
let input = match ask {
Ask::Filter => self.filter.clone(),
Ask::Name => self.db.names.get(&stored).cloned().unwrap_or_default(),
Ask::Note => self.db.notes.get(&stored).cloned().unwrap_or_default(),
Ask::Goto => String::new(),
};
self.prompt = Some(Prompt { ask, input, at });
}
pub fn pane_at(&self, column: u16, row: u16) -> Option<(Focus, usize)> {
let (w, h) = self.dims;
if w == 0 || h == 0 {
return None;
}
if row < 1 || row >= h.saturating_sub(1) {
return None;
}
let body_h = h.saturating_sub(2);
let fns_w = 34.min(w);
let xrefs_h = 8u16.min(body_h);
let (focus, list_len, pane_row) = if column < fns_w {
(Focus::Functions, self.order.len(), row)
} else if row + xrefs_h < h.saturating_sub(1) {
(Focus::Listing, self.lines.len(), row)
} else {
(
Focus::Xrefs,
self.xrefs().1.len(),
row - (h.saturating_sub(1) - xrefs_h),
)
};
let idx = pane_row.saturating_sub(2) as usize;
Some((
focus,
if list_len == 0 {
0
} else {
idx.min(list_len - 1)
},
))
}
pub fn on_mouse(&mut self, m: MouseEvent) {
match m.kind {
MouseEventKind::ScrollUp => self.step(-1),
MouseEventKind::ScrollDown => self.step(1),
MouseEventKind::Down(MouseButton::Left) => {
let Some((focus, idx)) = self.pane_at(m.column, m.row) else {
return;
};
self.focus = focus;
match focus {
Focus::Functions => {
self.sel = idx;
if let Some(a) = self.selected_addr() {
self.open(a, false);
}
}
Focus::Listing => self.cursor = idx.min(self.lines.len().saturating_sub(1)),
Focus::Xrefs => self.xsel = idx,
}
}
_ => {}
}
}
}
fn parse_addr(s: &str) -> Option<u64> {
let t = s.trim();
t.strip_prefix("0x")
.or_else(|| t.strip_prefix("0X"))
.and_then(|h| u64::from_str_radix(h, 16).ok())
}
pub fn run(mut app: App) -> Result<()> {
let mut term = ratatui::try_init()
.map_err(|e| anyhow::anyhow!("cannot start the interactive view: {e}"))?;
let _ = ratatui::crossterm::execute!(&mut stdout(), event::EnableMouseCapture);
let res = loop {
if let Ok(area) = term.size() {
app.dims = (area.width, area.height);
}
if let Err(e) = term.draw(|f| render::draw(f, &app)) {
break Err(e.into());
}
match event::read() {
Ok(Event::Key(k)) => app.on_key(k),
Ok(Event::Mouse(m)) => app.on_mouse(m),
Ok(_) => {}
Err(e) => break Err(e.into()),
}
if app.quit {
break Ok(());
}
};
let _ = ratatui::crossterm::execute!(&mut stdout(), event::DisableMouseCapture);
ratatui::restore();
res
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Arch, Format, Section};
fn app_with(code: &[u8], vaddr: u64) -> App {
let mut bin = Binary::stub(Format::Elf, Arch::X86_64);
bin.entry = vaddr;
bin.sections = vec![Section {
name: ".text".into(),
vaddr,
vsize: code.len() as u64,
file_off: vaddr,
file_size: code.len() as u64,
entropy: 0.0,
read: true,
write: false,
exec: true,
}];
let mut bytes = vec![0u8; vaddr as usize];
bytes.extend_from_slice(code);
let db = Db::default();
let an = engine::analyze(&bin, &bytes, 10_000, &db);
App::new(bin, bytes, db, an, "t".into())
}
fn two_functions() -> App {
let mut code = vec![0u8; 12];
code[0] = 0xe8;
code[1..5].copy_from_slice(&6i32.to_le_bytes());
code[5] = 0xc3;
code[11] = 0xc3;
app_with(&code, 0x1000)
}
#[test]
fn opens_something_on_start() {
let app = two_functions();
assert!(app.cur.is_some(), "a pane should never start empty");
assert!(!app.lines.is_empty());
}
#[test]
fn filtering_keeps_the_selection_when_it_survives() {
let mut app = two_functions();
let before = app.selected_addr();
app.filter = "entry".into();
app.refilter();
assert_eq!(app.selected_addr(), before);
assert_eq!(app.order.len(), 1);
}
#[test]
fn filtering_that_matches_nothing_leaves_an_empty_list() {
let mut app = two_functions();
app.filter = "nothing_matches_this".into();
app.refilter();
assert!(app.order.is_empty());
assert_eq!(app.selected_addr(), None);
app.move_sel(1);
app.move_sel(-1);
}
#[test]
fn following_a_call_pushes_history_and_back_returns() {
let mut app = two_functions();
app.open(0x1000, false);
app.focus = Focus::Listing;
app.cursor = 0;
app.follow();
assert_eq!(app.cur, Some(0x100b), "followed into the callee");
app.back();
assert_eq!(app.cur, Some(0x1000), "and came back");
assert!(app.history.is_empty());
}
#[test]
fn back_with_no_history_says_so_instead_of_panicking() {
let mut app = two_functions();
app.history.clear();
app.back();
assert!(app.status.contains("nothing to go back to"));
}
#[test]
fn naming_updates_the_list_and_the_database() {
let mut app = two_functions();
app.open(0x100b, false);
app.commit(Ask::Name, 0x100b, "parse_header".into());
assert_eq!(app.an.label(0x100b), "parse_header");
assert_eq!(
app.db.names.get(&0x100b).map(String::as_str),
Some("parse_header")
);
assert!(app
.order
.iter()
.any(|&i| app.an.functions[i].name == "parse_header"));
}
#[test]
fn an_empty_name_clears_it() {
let mut app = two_functions();
app.commit(Ask::Name, 0x100b, "tmp".into());
assert_eq!(app.an.label(0x100b), "tmp");
app.commit(Ask::Name, 0x100b, String::new());
assert_eq!(app.an.label(0x100b), "sub_100b");
assert!(app.db.names.is_empty());
}
#[test]
fn a_note_shows_up_in_the_listing() {
let mut app = two_functions();
app.open(0x1000, false);
app.commit(Ask::Note, 0x1000, "starts here".into());
let annotated = app.lines.iter().any(|l| {
matches!(l, Line::Insn { annot: Some(crate::listing::Annot::Note(n)), .. } if n == "starts here")
});
assert!(annotated, "the note should appear against the instruction");
}
#[test]
fn goto_accepts_a_symbol_or_an_address() {
let mut app = two_functions();
app.commit(Ask::Goto, 0, "0x100b".into());
assert_eq!(app.cur, Some(0x100b));
app.commit(Ask::Goto, 0, "entry".into());
assert_eq!(app.cur, Some(0x1000));
app.commit(Ask::Goto, 0, "no_such_thing".into());
assert!(app.status.contains("no symbol or address"));
}
#[test]
fn opening_an_address_inside_a_function_lands_on_that_line() {
let mut app = two_functions();
app.open(0x1005, false); assert_eq!(app.cur, Some(0x1000), "resolves to the containing function");
assert_eq!(
app.lines.get(app.cursor).map(Line::addr),
Some(0x1005),
"and puts the cursor on the requested address"
);
}
fn rendered(app: &App, w: u16, h: u16) -> String {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
term.draw(|f| render::draw(f, app)).unwrap();
term.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect()
}
#[test]
fn the_interface_renders() {
let app = two_functions();
let out = rendered(&app, 110, 30);
assert!(out.contains("functions"), "the function list is drawn");
assert!(out.contains("entry"), "and lists what was recovered");
assert!(out.contains("xrefs"), "the xref pane is drawn");
assert!(out.contains("open/follow"), "the key hints are drawn");
}
#[test]
fn it_renders_in_a_very_small_terminal() {
let app = two_functions();
for (w, h) in [(20u16, 6u16), (40, 10), (1, 1), (200, 60)] {
let _ = rendered(&app, w, h);
}
}
#[test]
fn the_help_overlay_and_prompt_render() {
let mut app = two_functions();
app.help = true;
assert!(rendered(&app, 110, 30).contains("re-analyse"));
app.help = false;
app.prompt = Some(Prompt {
ask: Ask::Name,
input: "parse_header".into(),
at: 0x1000,
});
let out = rendered(&app, 110, 30);
assert!(out.contains("name:"), "the prompt shows what it wants");
assert!(out.contains("parse_header"), "and what has been typed");
}
#[test]
fn typing_in_a_prompt_edits_it_and_escape_abandons_it() {
let mut app = two_functions();
app.on_key(KeyEvent::from(KeyCode::Char('n')));
assert!(app.prompt.is_some());
for c in "abc".chars() {
app.on_key(KeyEvent::from(KeyCode::Char(c)));
}
app.on_key(KeyEvent::from(KeyCode::Backspace));
assert_eq!(app.prompt.as_ref().unwrap().input, "ab");
app.on_key(KeyEvent::from(KeyCode::Esc));
assert!(app.prompt.is_none(), "escape abandons without saving");
assert!(app.db.names.is_empty());
assert!(!app.quit, "escape closed the prompt, it did not quit");
}
#[test]
fn key_releases_are_ignored() {
let mut app = two_functions();
app.focus = Focus::Functions;
let before = app.sel;
app.on_key(KeyEvent::new_with_kind(
KeyCode::Down,
KeyModifiers::NONE,
KeyEventKind::Release,
));
assert_eq!(app.sel, before);
}
#[test]
fn tab_cycles_through_three_panes() {
let mut app = two_functions();
assert_eq!(app.focus, Focus::Functions);
for _ in 0..2 {
app.on_key(KeyEvent::from(KeyCode::Tab));
}
assert_eq!(app.focus, Focus::Xrefs);
app.on_key(KeyEvent::from(KeyCode::Tab));
assert_eq!(app.focus, Focus::Functions);
}
#[test]
fn the_xref_cursor_jumps_to_the_reference_site() {
let mut app = two_functions();
app.open(0x100b, false);
assert_eq!(app.xrefs().1.len(), 1);
app.focus = Focus::Xrefs;
app.on_key(KeyEvent::from(KeyCode::Enter));
assert_eq!(app.cur, Some(0x1000), "jumped to the call site");
assert_eq!(app.focus, Focus::Listing, "and landed in the listing");
}
#[test]
fn xsel_is_clamped_when_the_target_changes() {
let mut app = two_functions();
app.open(0x100b, false); app.focus = Focus::Xrefs;
app.xsel = 5;
app.open(0x1000, false); assert_eq!(app.xsel, 0);
}
#[test]
fn nothing_to_jump_to_says_so_instead_of_panicking() {
let mut app = two_functions();
app.open(0x1000, false); app.focus = Focus::Xrefs;
app.on_key(KeyEvent::from(KeyCode::Enter));
assert!(app.status.contains("no reference"));
}
fn data_ref_app() -> App {
let mut bytes = vec![0x48, 0x8d, 0x05, 0x15, 0x00, 0x00, 0x00, 0xc3];
bytes.extend_from_slice(b"hi there");
let mut bin = Binary::stub(Format::Elf, Arch::X86_64);
bin.entry = 0x1000;
bin.sections = vec![
Section {
name: ".text".into(),
vaddr: 0x1000,
vsize: 8,
file_off: 0,
file_size: 8,
entropy: 0.0,
read: true,
write: false,
exec: true,
},
Section {
name: ".rodata".into(),
vaddr: 0x101c,
vsize: 8,
file_off: 8,
file_size: 8,
entropy: 0.0,
read: true,
write: false,
exec: false,
},
];
let db = Db::default();
let an = engine::analyze(&bin, &bytes, 10_000, &db);
App::new(bin, bytes, db, an, "t".into())
}
#[test]
fn the_literal_is_annotated_in_the_listing() {
let app = data_ref_app();
assert!(
app.lines.iter().any(|l| matches!(
l,
Line::Insn {
annot: Some(crate::listing::Annot::Text(t)),
..
} if t == "hi there"
)),
"the lea should be annotated with the literal"
);
}
#[test]
fn following_a_string_operand_opens_its_bytes() {
let mut app = data_ref_app();
app.focus = Focus::Listing;
app.cursor = 0; app.follow();
assert_eq!(app.cur, Some(0x101c), "followed the data ref");
assert!(
app.lines.iter().all(|l| matches!(l, Line::Data { .. })),
"a string opens as a hex dump, not as code"
);
app.back();
assert_eq!(app.cur, Some(0x1000), "and back returns to the lea");
assert_eq!(app.cursor, 0);
}
fn mouse(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent {
MouseEvent {
kind,
column,
row,
modifiers: KeyModifiers::NONE,
}
}
#[test]
fn the_wheel_scrolls_the_focused_pane() {
let mut app = data_ref_app();
app.focus = Focus::Listing;
app.cursor = 0;
app.on_mouse(mouse(MouseEventKind::ScrollDown, 0, 0));
assert_eq!(app.cursor, 1, "wheel scrolls the listing");
app.focus = Focus::Functions;
let before = app.sel;
app.on_mouse(mouse(MouseEventKind::ScrollUp, 0, 0));
assert_eq!(app.sel, before.saturating_sub(1), "and the function list");
}
#[test]
fn a_click_focuses_that_pane_and_selects_the_row() {
let mut app = data_ref_app();
app.dims = (110, 30);
app.on_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 5, 4));
assert_eq!(app.focus, Focus::Functions);
assert_eq!(app.selected_addr(), Some(0x1000));
app.on_mouse(mouse(MouseEventKind::Down(MouseButton::Left), 60, 26));
assert_eq!(app.focus, Focus::Xrefs);
}
}