fn stack_main_panes_vertically(area: Rect, sidebar_panel_count: usize) -> bool {
if sidebar_panel_count >= 3 {
area.width < 110
} else {
area.width < 90
}
}
fn footer_uses_compact_mode(area: Rect, sidebar_panel_count: usize) -> bool {
stack_main_panes_vertically(area, sidebar_panel_count)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Focus {
Diagram,
Objects,
XRefs,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FocusOwner {
Human,
Agent,
}
impl Focus {
fn cycle(self) -> Self {
match self {
Self::Diagram => Self::Objects,
Self::Objects => Self::XRefs,
Self::XRefs => Self::Diagram,
}
}
fn cycle_back(self) -> Self {
match self {
Self::Diagram => Self::XRefs,
Self::Objects => Self::Diagram,
Self::XRefs => Self::Objects,
}
}
}
fn panel_border_style_for_focus(active: Focus, panel: Focus, owner: FocusOwner) -> Style {
if active != panel {
return Style::default();
}
Style::default().fg(focus_color_for_owner(owner))
}
fn focus_color_for_owner(owner: FocusOwner) -> Color {
match owner {
FocusOwner::Human => FOCUS_COLOR,
FocusOwner::Agent => AGENT_FOCUS_COLOR,
}
}
fn view_title(label: &str, key: char, tail: Option<&str>) -> String {
let mut title = format!("─[{key}]─ {label}");
if let Some(tail) = tail {
let tail = tail.trim();
if !tail.is_empty() {
title.push(' ');
title.push_str(tail);
}
}
title.push(' ');
title
}
fn diagram_view_title(
diagram_id: &str,
is_focused: bool,
diagram_index: Option<usize>,
diagram_total: usize,
) -> Line<'static> {
let id_color = if is_focused {
Color::White
} else {
Color::Gray
};
let counter = diagram_counter_label(diagram_index, diagram_total);
Line::from(vec![
Span::raw("─ Diagram ".to_owned()),
Span::styled(counter, Style::default().fg(Color::LightGreen)),
Span::raw(" ".to_owned()),
Span::styled(diagram_id.to_owned(), Style::default().fg(id_color)),
Span::raw(" ".to_owned()),
])
}
fn diagram_note_title(note: &str) -> Line<'static> {
let note = note.lines().collect::<Vec<_>>().join(" ");
Line::from(vec![
Span::raw("─ ".to_owned()),
Span::styled(note, Style::default().fg(Color::DarkGray)),
Span::raw(" ".to_owned()),
])
}
fn diagram_counter_label(diagram_index: Option<usize>, diagram_total: usize) -> String {
if diagram_total == 0 {
return "[0/0]".to_owned();
}
let width = diagram_total.to_string().len();
let index = diagram_index.unwrap_or(0).min(diagram_total);
format!("[{index:0width$}/{diagram_total}]")
}
fn clamp_positive_i32_to_u16(value: i32) -> u16 {
value.max(0).min(u16::MAX as i32) as u16
}
fn pad_text(mut text: Text<'static>, left_pad: usize, top_pad: usize) -> Text<'static> {
if left_pad == 0 && top_pad == 0 {
return text;
}
if left_pad > 0 {
let pad = " ".repeat(left_pad);
for line in &mut text.lines {
line.spans.insert(0, Span::raw(pad.clone()));
}
}
if top_pad > 0 {
let blank = Line::from(String::new());
let mut lines = Vec::with_capacity(top_pad + text.lines.len());
for _ in 0..top_pad {
lines.push(blank.clone());
}
lines.extend(text.lines);
text.lines = lines;
}
text
}
fn style_for_diagram_char(mut style: Style, ch: char) -> Style {
if is_direction_marker(ch) {
style.fg = Some(Color::Cyan);
}
style
}
fn is_direction_marker(ch: char) -> bool {
matches!(ch, '▾' | '▴')
}
fn objects_item_bg(
is_cursor: bool,
is_selected: bool,
objects_has_focus: bool,
owner: FocusOwner,
) -> Option<Color> {
if is_cursor {
if is_selected && objects_has_focus {
Some(Color::LightGreen)
} else if is_selected {
Some(Color::DarkGray)
} else {
Some(focus_color_for_owner(owner))
}
} else if is_selected {
Some(Color::DarkGray)
} else {
None
}
}
fn xref_item_style(status: XRefStatus, indirectly_selected: bool) -> Style {
if indirectly_selected {
Style::default().fg(Color::White).bg(Color::DarkGray)
} else {
match status {
XRefStatus::Ok => Style::default(),
_ => Style::default().fg(Color::Red),
}
}
}
fn xrefs_cursor_highlight_style(focus: Focus, owner: FocusOwner) -> Style {
if focus == Focus::XRefs {
Style::default()
.fg(Color::White)
.bg(focus_color_for_owner(owner))
} else {
Style::default()
}
}
fn xref_involves_selected(selected: Option<&ObjectRef>, xref: &XRef) -> bool {
selected.is_some_and(|selected| xref.from() == selected || xref.to() == selected)
}
fn demo_palette_lines() -> Vec<Line<'static>> {
let mut bg_spans = Vec::<Span<'static>>::new();
let mut fg_spans = Vec::<Span<'static>>::new();
for (idx, color) in demo_palette_colors().iter().copied().enumerate() {
if idx > 0 {
bg_spans.push(Span::raw(" "));
fg_spans.push(Span::raw(" "));
}
bg_spans.push(Span::styled(
format!("{idx:>2}"),
Style::default()
.fg(demo_palette_bg_text_color(idx))
.bg(color)
.add_modifier(Modifier::BOLD),
));
fg_spans.push(Span::styled(
format!("{idx:>2}"),
Style::default().fg(color).add_modifier(Modifier::BOLD),
));
}
vec![Line::from(bg_spans), Line::from(fg_spans)]
}
fn demo_palette_bg_text_color(idx: usize) -> Color {
match idx {
0 | 1 | 4 | 5 | 8 => Color::White,
_ => Color::Black,
}
}
fn demo_palette_colors() -> [Color; 16] {
[
Color::Black,
Color::Red,
Color::Green,
Color::Yellow,
Color::Blue,
Color::Magenta,
Color::Cyan,
Color::Gray,
Color::DarkGray,
Color::LightRed,
Color::LightGreen,
Color::LightYellow,
Color::LightBlue,
Color::LightMagenta,
Color::LightCyan,
Color::White,
]
}
fn footer_help_line(app: &App, toast_suffix: &str, compact: bool) -> Line<'static> {
let mut spans = Vec::<Span<'static>>::new();
let follow_ai = if app.follow_ai { "a◼ " } else { "a◻ " };
let diagram_hotkeys_disabled = app.follow_ai && app.focus == Focus::Diagram;
if compact {
let compact_hint = match app.hint_mode {
HintMode::Inactive => "f",
HintMode::AwaitingFirst { .. } | HintMode::AwaitingSecond { .. } => "2 letters",
};
push_footer_entry_with_separator(&mut spans, "AI", follow_ai, " | ");
push_footer_entry_with_separator_maybe_disabled(
&mut spans,
"HINT",
compact_hint,
" | ",
diagram_hotkeys_disabled,
);
push_footer_entry_with_separator(&mut spans, "HELP", "?", " | ");
push_footer_entry_with_separator(&mut spans, "QUIT", "q", " | ");
} else {
match app.focus {
Focus::Diagram => match app.hint_mode {
HintMode::Inactive => {
push_footer_entry_maybe_disabled(
&mut spans,
"DIAGRAM",
"[]",
diagram_hotkeys_disabled,
);
push_footer_entry_maybe_disabled(
&mut spans,
"HINT",
"f",
diagram_hotkeys_disabled,
);
push_footer_entry_maybe_disabled(
&mut spans,
"CHAIN",
"c",
diagram_hotkeys_disabled,
);
push_footer_entry_maybe_disabled(
&mut spans,
"EDIT",
"e",
diagram_hotkeys_disabled,
);
push_footer_entry_maybe_disabled(
&mut spans,
"SELECT",
"⏡",
diagram_hotkeys_disabled,
);
push_footer_entry_maybe_disabled(
&mut spans,
"YANK",
"y",
diagram_hotkeys_disabled,
);
push_footer_entry_maybe_disabled(
&mut spans,
"XREF",
"g/t",
diagram_hotkeys_disabled,
);
if app.show_notes {
push_footer_entry_maybe_disabled(
&mut spans,
"NOTES",
"n◼ ",
diagram_hotkeys_disabled,
);
}
}
HintMode::AwaitingFirst { kind, .. } | HintMode::AwaitingSecond { kind, .. } => {
match kind {
HintKind::Jump => {
push_footer_entry_maybe_disabled(
&mut spans,
"HINT",
"2 letters",
diagram_hotkeys_disabled,
);
push_footer_entry_maybe_disabled(
&mut spans,
"CANCEL",
"Esc",
diagram_hotkeys_disabled,
);
}
HintKind::SelectChain => {
push_footer_entry_maybe_disabled(
&mut spans,
"CHAIN",
"2 letters",
diagram_hotkeys_disabled,
);
push_footer_entry_maybe_disabled(
&mut spans,
"DONE",
"Esc",
diagram_hotkeys_disabled,
);
}
}
}
},
Focus::Objects => {
push_footer_entry(&mut spans, "SELECT", "⏡");
push_footer_entry(&mut spans, "FILTER", "-");
push_footer_entry(&mut spans, "HINT", "f");
push_footer_entry(&mut spans, "CHAIN", "c");
push_footer_entry(&mut spans, "YANK", "y");
push_footer_entry(&mut spans, "JUMP", "g/t");
push_footer_entry(&mut spans, "DIAGRAM", "[]");
}
Focus::XRefs => {
push_footer_entry(&mut spans, "FILTER", "-/I");
push_footer_entry(&mut spans, "JUMP", "g/t");
push_footer_entry(&mut spans, "DIAGRAM", "[]");
}
}
push_footer_entry(&mut spans, "AI", follow_ai);
push_footer_entry(&mut spans, "HELP", "?");
push_footer_entry(&mut spans, "QUIT", "q");
}
let toast_message = toast_suffix
.strip_prefix(" | ")
.unwrap_or(toast_suffix)
.trim();
if !toast_message.is_empty() {
spans.push(Span::styled(" | ", Style::default().fg(FOOTER_LABEL_COLOR)));
spans.push(Span::styled(
"Toast:".to_owned(),
Style::default().fg(FOOTER_LABEL_COLOR),
));
spans.push(Span::raw(toast_message.to_owned()));
}
Line::from(spans)
}
fn search_footer_line(app: &App, toast_suffix: &str) -> Line<'static> {
let query = app.search_query.as_str();
let search_prefix = app.search_prefix();
let (idx, total) = match app.search_results.len() {
0 => (0usize, 0usize),
n => (app.search_result_index.saturating_add(1), n),
};
let count = if query.is_empty() {
None
} else if total == 0 {
Some("0".to_owned())
} else {
Some(format!("{idx}/{total}"))
};
let mut spans = vec![
Span::styled(
search_prefix.to_string(),
Style::default()
.fg(FOOTER_KEY_COLOR)
.add_modifier(Modifier::BOLD),
),
Span::raw(query.to_owned()),
Span::raw(" "),
];
if let Some(count) = count {
spans.push(Span::styled(count, Style::default().fg(Color::LightGreen)));
}
if app.search_mode == SearchMode::Results {
push_footer_entry_with_separator(&mut spans, "Next", "n/N", " | ");
}
push_footer_entry_with_separator(&mut spans, "Accept", "Enter", " | ");
push_footer_entry_with_separator(&mut spans, "Close", "Esc", " | ");
let toast_message = toast_suffix
.strip_prefix(" | ")
.unwrap_or(toast_suffix)
.trim();
if !toast_message.is_empty() {
spans.push(Span::styled(" | ", Style::default().fg(FOOTER_LABEL_COLOR)));
spans.push(Span::styled(
"Toast:".to_owned(),
Style::default().fg(FOOTER_LABEL_COLOR),
));
spans.push(Span::raw(toast_message.to_owned()));
}
Line::from(spans)
}
fn footer_brand_line() -> Line<'static> {
Line::from(vec![Span::styled(
FOOTER_BRAND.to_owned(),
Style::default().fg(FOOTER_BRAND_COLOR),
)])
}
fn help_key_style() -> Style {
Style::default()
.fg(FOOTER_KEY_COLOR)
.add_modifier(Modifier::BOLD)
}
fn help_header_style() -> Style {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
}
fn centered_rect(width_percent: u16, height_percent: u16, area: Rect) -> Rect {
let vertical_margin = (100u16.saturating_sub(height_percent)) / 2;
let horizontal_margin = (100u16.saturating_sub(width_percent)) / 2;
let vertical = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage(vertical_margin),
Constraint::Percentage(height_percent),
Constraint::Percentage(vertical_margin),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(horizontal_margin),
Constraint::Percentage(width_percent),
Constraint::Percentage(horizontal_margin),
])
.split(vertical[1])[1]
}
fn help_kv(key: &str, desc: &str, key_width: usize, key_style: Style) -> Line<'static> {
Line::from(vec![
Span::styled(format!("{key:>width$}", width = key_width), key_style),
Span::raw(" "),
Span::raw(desc.to_owned()),
])
}
fn render_help(frame: &mut Frame<'_>, app: &mut App, main_area: Rect) {
let area = centered_rect(82, 84, main_area);
frame.render_widget(Clear, area);
let key_style = help_key_style();
let header_style = help_header_style();
let dim_style = Style::default().fg(Color::DarkGray);
let key_col_width = [
"Tab/Shift-Tab",
"j/k, ↑/↓, PgUp/PgDn, Home/End",
"n/N",
"Enter/Backspace",
]
.iter()
.map(|s| s.len())
.max()
.unwrap_or(0);
let mut lines = vec![Line::from(Span::styled("--- Global ---", header_style))];
lines.push(help_kv("?", "Help (toggle)", key_col_width, key_style));
lines.push(help_kv("q", "Quit", key_col_width, key_style));
lines.push(help_kv("1", "Focus Diagram", key_col_width, key_style));
lines.push(help_kv(
"2/3",
"Toggle+focus Objects/XRefs",
key_col_width,
key_style,
));
lines.push(help_kv(
"4",
"Toggle inspector panel",
key_col_width,
key_style,
));
lines.push(help_kv(
"5/N",
"Toggle notes panel",
key_col_width,
key_style,
));
lines.push(help_kv(
"a",
"Toggle follow AI highlight",
key_col_width,
key_style,
));
lines.push(help_kv(
"d",
"Deselect all in current diagram",
key_col_width,
key_style,
));
lines.push(help_kv(
"Tab/Shift-Tab",
"Focus next/previous panel",
key_col_width,
key_style,
));
lines.push(help_kv(
"[/]",
"Previous/next diagram",
key_col_width,
key_style,
));
lines.push(help_kv("/", "Regular search", key_col_width, key_style));
lines.push(help_kv("\\", "Fuzzy search", key_col_width, key_style));
lines.push(help_kv(
"n/N",
"Search next/previous result",
key_col_width,
key_style,
));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("--- Search ---", header_style)));
lines.push(help_kv("Type", "Update query", key_col_width, key_style));
lines.push(help_kv(
"Enter",
"Commit results mode",
key_col_width,
key_style,
));
lines.push(help_kv(
"Backspace",
"Delete query char",
key_col_width,
key_style,
));
lines.push(help_kv("Esc", "Clear search", key_col_width, key_style));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("--- Diagram ---", header_style)));
lines.push(help_kv(
"↑↓←→ / h/j/k/l",
"Pan diagram by 1",
key_col_width,
key_style,
));
lines.push(help_kv(
"H/J/K/L",
"Pan diagram by 10",
key_col_width,
key_style,
));
lines.push(help_kv(
"PgUp/PgDn",
"Pan by page",
key_col_width,
key_style,
));
lines.push(help_kv(
"Home",
"Reset pan to origin",
key_col_width,
key_style,
));
lines.push(help_kv(
"n",
"Toggle notes (when not searching)",
key_col_width,
key_style,
));
lines.push(help_kv("f", "Hint jump mode", key_col_width, key_style));
lines.push(help_kv("c", "Chain hint mode", key_col_width, key_style));
lines.push(help_kv(
"e",
"Edit active diagram in $EDITOR",
key_col_width,
key_style,
));
lines.push(help_kv(
"Space",
"Toggle selected object",
key_col_width,
key_style,
));
lines.push(help_kv(
"y",
"Yank selected object ref",
key_col_width,
key_style,
));
lines.push(help_kv(
"g/t",
"Jump inbound/outbound",
key_col_width,
key_style,
));
lines.push(help_kv(
"Hint: 2 letters",
"Choose hint target",
key_col_width,
key_style,
));
lines.push(help_kv(
"Hint Esc",
"Cancel hint mode",
key_col_width,
key_style,
));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("--- Objects ---", header_style)));
lines.push(help_kv(
"↑/↓ or j/k",
"Move object cursor",
key_col_width,
key_style,
));
lines.push(help_kv(
"Home/End",
"First/last object",
key_col_width,
key_style,
));
lines.push(help_kv(
"Space",
"Toggle selected object",
key_col_width,
key_style,
));
lines.push(help_kv(
"-",
"Filter selected-only",
key_col_width,
key_style,
));
lines.push(help_kv("f", "Hint jump mode", key_col_width, key_style));
lines.push(help_kv("c", "Chain hint mode", key_col_width, key_style));
lines.push(help_kv(
"y",
"Yank selected object ref",
key_col_width,
key_style,
));
lines.push(help_kv(
"g/t",
"Jump inbound/outbound",
key_col_width,
key_style,
));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("--- XRefs ---", header_style)));
lines.push(help_kv(
"↑/↓ or j/k",
"Move xref cursor",
key_col_width,
key_style,
));
lines.push(help_kv(
"Home/End",
"First/last xref",
key_col_width,
key_style,
));
lines.push(help_kv(
"-",
"Toggle dangling-only filter",
key_col_width,
key_style,
));
lines.push(help_kv(
"I",
"Toggle involving-selection filter",
key_col_width,
key_style,
));
lines.push(help_kv(
"g/t",
"Jump to from/to endpoint",
key_col_width,
key_style,
));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled("--- Help ---", header_style)));
lines.push(help_kv(
"j/k, ↑/↓, PgUp/PgDn, Home/End",
"Scroll help",
key_col_width,
key_style,
));
lines.push(help_kv("Esc/?", "Close help", key_col_width, key_style));
lines.push(Line::from(vec![
Span::styled("Note: ", dim_style),
Span::styled("g/t", key_style),
Span::styled(
" follow the first matching xref (toast if multiple).",
dim_style,
),
]));
let block = Block::default()
.borders(Borders::ALL)
.title("─ Help ─")
.border_style(Style::default().fg(FOCUS_COLOR))
.title_style(
Style::default()
.fg(FOCUS_COLOR)
.add_modifier(Modifier::BOLD),
);
let inner = block.inner(area);
app.help_viewport_height = inner.height;
let max_scroll = lines
.len()
.saturating_sub(inner.height.max(1) as usize)
.min(u16::MAX as usize) as u16;
app.help_scroll = app.help_scroll.min(max_scroll);
let paragraph = Paragraph::new(lines)
.block(block)
.alignment(Alignment::Left)
.style(Style::default())
.wrap(Wrap { trim: false })
.scroll((app.help_scroll, 0));
frame.render_widget(paragraph, area);
}
fn push_footer_entry(spans: &mut Vec<Span<'static>>, label: &str, value: &str) {
push_footer_entry_maybe_disabled(spans, label, value, false);
}
fn push_footer_entry_maybe_disabled(
spans: &mut Vec<Span<'static>>,
label: &str,
value: &str,
disabled: bool,
) {
push_footer_entry_with_separator_maybe_disabled(spans, label, value, " | ", disabled);
}
fn push_footer_entry_with_separator(
spans: &mut Vec<Span<'static>>,
label: &str,
value: &str,
separator: &'static str,
) {
push_footer_entry_with_separator_maybe_disabled(spans, label, value, separator, false);
}
fn push_footer_entry_with_separator_maybe_disabled(
spans: &mut Vec<Span<'static>>,
label: &str,
value: &str,
separator: &'static str,
disabled: bool,
) {
if !spans.is_empty() {
spans.push(Span::styled(
separator.to_owned(),
Style::default().fg(FOOTER_LABEL_COLOR),
));
}
spans.push(Span::styled(
format!("{}:", footer_label_ucfirst(label)),
Style::default().fg(FOOTER_LABEL_COLOR),
));
spans.extend(footer_value_spans(value, disabled));
}
fn footer_label_ucfirst(label: &str) -> String {
let lower = label.to_lowercase();
let mut chars = lower.chars();
let Some(first) = chars.next() else {
return String::new();
};
let mut out = first.to_uppercase().collect::<String>();
out.push_str(chars.as_str());
out
}
fn footer_value_spans(value: &str, disabled: bool) -> Vec<Span<'static>> {
let color = if disabled {
Color::DarkGray
} else {
FOOTER_KEY_COLOR
};
vec![Span::styled(
value.to_owned(),
Style::default()
.fg(color)
.add_modifier(Modifier::BOLD),
)]
}