Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
788 changes: 782 additions & 6 deletions src/app.rs

Large diffs are not rendered by default.

188 changes: 188 additions & 0 deletions src/ui/layout.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
use crate::app::Modal;
use ratatui::layout::{Position, Rect};

/// Interactive bounds for a list panel, updated each frame during render.
#[derive(Debug, Clone, Copy, Default)]
pub struct ListRegion {
pub panel: Rect,
pub list_inner: Rect,
pub list_offset: usize,
}

impl ListRegion {
/// Inner area of a `Block` with `Borders::ALL`.
pub fn block_inner(area: Rect) -> Rect {
Rect {
x: area.x.saturating_add(1),
y: area.y.saturating_add(1),
width: area.width.saturating_sub(2),
height: area.height.saturating_sub(2),
}
}

pub fn contains_panel(&self, column: u16, row: u16) -> bool {
self.panel.contains(Position { x: column, y: row })
}

pub fn contains_list(&self, column: u16, row: u16) -> bool {
self.list_inner.contains(Position { x: column, y: row })
}

/// Map a click to a list index, accounting for scroll offset.
pub fn list_index_at(&self, column: u16, row: u16, item_count: usize) -> Option<usize> {
if item_count == 0 {
return None;
}
let inner = self.list_inner;
if column < inner.x || column >= inner.x.saturating_add(inner.width) {
return None;
}
if row < inner.y || row >= inner.y.saturating_add(inner.height) {
return None;
}
let rel = (row - inner.y) as usize;
let index = self.list_offset.saturating_add(rel);
(index < item_count).then_some(index)
}
}

/// Scrollable text viewport (preview, help body, job log, CVE detail).
#[derive(Debug, Clone, Copy, Default)]
pub struct ScrollRegion {
pub area: Rect,
pub visible_lines: usize,
}

impl ScrollRegion {
pub fn from_block(area: Rect) -> Self {
let inner = ListRegion::block_inner(area);
Self {
area: inner,
visible_lines: inner.height.max(1) as usize,
}
}

pub fn contains(&self, column: u16, row: u16) -> bool {
self.area.contains(Position { x: column, y: row })
}
}

pub fn scroll_by(current: usize, delta: i32, max: usize) -> usize {
if delta >= 0 {
current.saturating_add(delta as usize).min(max)
} else {
current.saturating_sub((-delta) as usize)
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusBarAction {
Engagement,
Target,
Ap,
Pivot,
Creds,
Jobs,
}

#[derive(Debug, Clone, Copy)]
pub struct StatusChipHit {
pub area: Rect,
pub action: StatusBarAction,
}

#[derive(Debug, Clone, Default)]
pub struct StatusBarHits {
pub bar: Rect,
pub chips: Vec<StatusChipHit>,
}

impl StatusBarHits {
pub fn chip_at(&self, column: u16, row: u16) -> Option<StatusBarAction> {
if !self.bar.contains(Position { x: column, y: row }) {
return None;
}
self.chips
.iter()
.find(|c| c.area.contains(Position { x: column, y: row }))
.map(|c| c.action)
}
}

#[derive(Debug, Clone, Copy, Default)]
pub struct EditHitRegions {
pub textarea_panel: Rect,
pub textarea: ScrollRegion,
pub suggestions: Option<ListRegion>,
}

/// Last-frame layout snapshot for mouse hit-testing.
#[derive(Debug, Clone, Default)]
pub struct HitRegions {
pub frame: Rect,
pub categories: ListRegion,
pub commands: ListRegion,
pub preview: ScrollRegion,
pub jobs: ListRegion,
pub status_bar: StatusBarHits,
pub edit: Option<EditHitRegions>,
pub modal_popup: Option<Rect>,
pub help_scroll: Option<ScrollRegion>,
pub job_log_scroll: Option<ScrollRegion>,
pub search_list: Option<ListRegion>,
pub cve_list: Option<ListRegion>,
pub cve_detail_scroll: Option<ScrollRegion>,
pub engagement_list: Option<ListRegion>,
pub target_list: Option<ListRegion>,
pub ap_list: Option<ListRegion>,
pub pivot_list: Option<ListRegion>,
pub creds_list: Option<ListRegion>,
pub variables_list: Option<ListRegion>,
}

impl HitRegions {
pub fn modal_popup_rect(frame: Rect, modal: &Modal) -> Option<Rect> {
let (pct_x, pct_y) = match modal {
Modal::None => return None,
Modal::Help(_) => (70, 80),
Modal::Engagement(_) => (60, 60),
Modal::Target(_) => (70, 70),
Modal::Ap(_) => (75, 75),
Modal::Pivot(_) => (80, 78),
Modal::Creds(_) => (70, 70),
Modal::Variables(_) => (75, 75),
Modal::Tools(_) => (60, 70),
Modal::Search { .. } => (80, 70),
Modal::Edit(_) => (80, 55),
Modal::JobLog(_) => (88, 85),
Modal::Cve(_) => (90, 85),
};
Some(super::centered_rect(frame, pct_x, pct_y))
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn list_index_respects_offset_and_bounds() {
let region = ListRegion {
panel: Rect::new(0, 0, 10, 10),
list_inner: Rect::new(1, 1, 8, 5),
list_offset: 3,
};
assert_eq!(region.list_index_at(2, 1, 10), Some(3));
assert_eq!(region.list_index_at(2, 3, 10), Some(5));
assert_eq!(region.list_index_at(2, 6, 10), None);
assert_eq!(region.list_index_at(0, 2, 10), None);
assert_eq!(region.list_index_at(2, 3, 5), None);
}

#[test]
fn scroll_by_clamps() {
assert_eq!(scroll_by(2, 5, 4), 4);
assert_eq!(scroll_by(2, -3, 4), 0);
assert_eq!(scroll_by(2, 1, 4), 3);
}
}
76 changes: 61 additions & 15 deletions src/ui/mod.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
pub mod layout;
pub mod modals;
pub mod panels;
pub mod splash;
pub mod theme;
pub mod textarea_mouse;

use crate::app::{App, Focus, Modal};
use layout::HitRegions;
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};

pub fn draw(f: &mut Frame, app: &mut App) {
let area = f.area();
let mut hit = HitRegions {
frame: area,
..Default::default()
};

// Splash screen takes over the whole frame until the user dismisses it.
if let Some(splash) = app.splash.as_ref() {
app.hit = hit;
splash::draw(f, area, splash);
return;
}
Expand All @@ -33,35 +41,73 @@ pub fn draw(f: &mut Frame, app: &mut App) {
])
.split(body);

panels::categories::render(f, columns[0], app);
panels::commands::render(f, columns[1], app);
panels::categories::render(f, columns[0], app, &mut hit.categories);
panels::commands::render(f, columns[1], app, &mut hit.commands);

let right = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(columns[2]);

hit.preview = layout::ScrollRegion::from_block(right[0]);
panels::preview::render(f, right[0], app);
panels::jobs::render(f, right[1], app);
panels::jobs::render(f, right[1], app, &mut hit.jobs);

panels::status_bar::render(f, status, app);
panels::status_bar::render(f, status, app, &mut hit.status_bar);

hit.modal_popup = HitRegions::modal_popup_rect(area, &app.modal);
app.hit = hit;

let mut help_scroll = None;
let mut job_log_scroll = None;
let mut search_list = None;
let mut cve_list = None;
let mut cve_detail_scroll = None;
let mut engagement_list = None;
let mut target_list = None;
let mut ap_list = None;
let mut pivot_list = None;
let mut creds_list = None;
let mut variables_list = None;
let mut edit_hit = None;

match &app.modal {
Modal::None => {}
Modal::Help(_) => modals::help::render(f, area, app),
Modal::Engagement(_) => modals::engagement::render(f, area, app),
Modal::Target(_) => modals::target::render(f, area, app),
Modal::Ap(_) => modals::ap::render(f, area, app),
Modal::Pivot(_) => modals::pivot::render(f, area, app),
Modal::Creds(_) => modals::creds::render(f, area, app),
Modal::Variables(_) => modals::variables::render(f, area, app),
Modal::Help(_) => modals::help::render(f, area, app, &mut help_scroll),
Modal::Engagement(_) => modals::engagement::render(f, area, app, &mut engagement_list),
Modal::Target(_) => modals::target::render(f, area, app, &mut target_list),
Modal::Ap(_) => modals::ap::render(f, area, app, &mut ap_list),
Modal::Pivot(_) => modals::pivot::render(f, area, app, &mut pivot_list),
Modal::Creds(_) => modals::creds::render(f, area, app, &mut creds_list),
Modal::Variables(_) => modals::variables::render(f, area, app, &mut variables_list),
Modal::Tools(_) => modals::tools::render(f, area, app),
Modal::Search { .. } => modals::search::render(f, area, app),
Modal::Edit(_) => modals::edit::render(f, area, app),
Modal::JobLog(_) => modals::job_log::render(f, area, app),
Modal::Cve(_) => modals::cve::render(f, area, app),
Modal::Search { .. } => modals::search::render(f, area, app, &mut search_list),
Modal::Edit(_) => modals::edit::render(f, area, app, &mut edit_hit),
Modal::JobLog(_) => modals::job_log::render(f, area, app, &mut job_log_scroll),
Modal::Cve(_) => {
modals::cve::render(
f,
area,
app,
&mut cve_list,
&mut cve_detail_scroll,
);
}
}

app.hit.help_scroll = help_scroll;
app.hit.job_log_scroll = job_log_scroll;
app.hit.search_list = search_list;
app.hit.cve_list = cve_list;
app.hit.cve_detail_scroll = cve_detail_scroll;
app.hit.engagement_list = engagement_list;
app.hit.target_list = target_list;
app.hit.ap_list = ap_list;
app.hit.pivot_list = pivot_list;
app.hit.creds_list = creds_list;
app.hit.variables_list = variables_list;
app.hit.edit = edit_hit;

let _ = Focus::Categories; // keep import alive
let _ = Rect::default;
}
Expand Down
23 changes: 19 additions & 4 deletions src/ui/modals/ap.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
use crate::app::{App, ApEditField, ApModalState, Modal};
use crate::ui::centered_rect;
use crate::ui::layout::ListRegion;
use crate::ui::theme::Theme;
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph};

pub fn render(f: &mut Frame, area: Rect, app: &App) {
pub fn render(f: &mut Frame, area: Rect, app: &App, list_hit: &mut Option<ListRegion>) {
let r = centered_rect(area, 75, 75);
f.render_widget(Clear, r);
let block = Block::default()
Expand All @@ -23,12 +24,21 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
};

match &modal.state {
ApModalState::List { cursor } => render_list(f, inner, app, *cursor),
ApModalState::Edit { fields, focused, .. } => render_edit(f, inner, fields, *focused),
ApModalState::List { cursor } => render_list(f, inner, app, *cursor, list_hit),
ApModalState::Edit { fields, focused, .. } => {
*list_hit = None;
render_edit(f, inner, fields, *focused);
}
}
}

fn render_list(f: &mut Frame, area: Rect, app: &App, cursor: usize) {
fn render_list(
f: &mut Frame,
area: Rect,
app: &App,
cursor: usize,
list_hit: &mut Option<ListRegion>,
) {
let items: Vec<ListItem> = app
.engagement
.as_ref()
Expand Down Expand Up @@ -81,6 +91,11 @@ fn render_list(f: &mut Frame, area: Rect, app: &App, cursor: usize) {
.highlight_style(Theme::selected())
.highlight_symbol("▶ ");
f.render_stateful_widget(list, layout[0], &mut state);
*list_hit = Some(ListRegion {
panel: layout[0],
list_inner: layout[0],
list_offset: state.offset(),
});

let hints = Paragraph::new(Line::from(vec![
Span::styled("a", Theme::magenta()),
Expand Down
Loading
Loading