#[cfg_attr(not(test), expect(clippy::wildcard_imports, reason = "shared"))]
use super::*;
pub(super) fn draw_modal(frame: &mut Frame<'_>, app: &mut App, theme: &Theme) {
if matches!(app.modal, Some(Modal::Help { .. })) {
draw_help(frame, app, theme);
return;
}
draw_modal_content(frame, app, theme);
}
#[expect(
clippy::too_many_lines,
reason = "the exhaustive dispatcher keeps every modal and its hit targets together"
)]
pub(super) fn draw_modal_content(frame: &mut Frame<'_>, app: &mut App, theme: &Theme) {
let mut list_hits = Vec::new();
let mut list_len = 0;
let mut list_max_scroll = 0;
let mut list = ModalList::new(
&mut list_hits,
&mut list_len,
&mut list_max_scroll,
app.modal_scroll,
app.modal_free_scroll,
);
match app.modal.as_ref() {
None | Some(Modal::Help { .. }) => {}
Some(Modal::Commit { input, amend }) => {
let (input, amend) = (input.clone(), *amend);
draw_commit(
frame,
&mut app.geometry.modal_action_hits,
&input,
amend,
theme,
);
}
Some(Modal::PullRequestReviewComment { input, target }) => {
let title = match target {
crate::app::PullRequestReviewTarget::Line(_) => " Review line ",
crate::app::PullRequestReviewTarget::File(_) => " Review file ",
crate::app::PullRequestReviewTarget::Reply(_) => " Reply to review thread ",
crate::app::PullRequestReviewTarget::Edit { .. } => " Edit review comment ",
};
draw_review_editor(
frame,
&mut app.geometry.modal_action_hits,
title,
input,
None,
theme,
);
}
Some(Modal::PullRequestReviewThreadActions { items, selected }) => {
draw_review_thread_actions(
frame,
&mut app.geometry.modal_action_hits,
items,
*selected,
&mut list,
theme,
);
}
Some(Modal::PullRequestReviewSubmit { input, decision }) => {
draw_review_editor(
frame,
&mut app.geometry.modal_action_hits,
" Submit review ",
input,
Some(*decision),
theme,
);
}
Some(Modal::Prompt { title, input, .. }) => {
draw_prompt(frame, title, input, theme);
}
Some(Modal::PullRequestActions {
title,
items,
selected,
}) => draw_pr_actions(
frame,
&mut app.geometry.modal_action_hits,
title,
items,
*selected,
&mut list,
theme,
),
Some(Modal::Confirm { title, message, .. }) => {
let (title, message) = (title.clone(), message.clone());
draw_confirm(
frame,
&mut app.geometry.modal_action_hits,
&title,
&message,
theme,
);
}
Some(Modal::Branches {
items,
selected,
query,
loading,
..
}) => draw_branches(
frame, items, *selected, query, *loading, app, &mut list, theme,
),
Some(Modal::HistoryBranches {
items,
selected,
query,
loading,
}) => draw_history_branches(frame, items, *selected, query, *loading, &mut list, theme),
Some(Modal::CompareBranches {
items,
selected,
query,
loading,
}) => draw_compare_branches(frame, items, *selected, query, *loading, &mut list, theme),
Some(Modal::Stashes {
items,
selected,
query,
loading,
}) => draw_stashes(frame, items, *selected, query, *loading, &mut list, theme),
Some(modal @ Modal::Projects { .. }) => {
draw_ssh_project_modal(
frame,
modal,
&mut app.geometry.project_collapse_hits,
app.ssh_context.as_ref(),
&mut app.geometry.modal_action_hits,
&mut list,
theme,
);
}
Some(Modal::PullRequestRepositories {
items,
selected,
query,
loading,
}) => draw_pull_request_repositories(
frame, items, *selected, query, *loading, &mut list, theme,
),
Some(Modal::CommandPalette { query, selected }) => {
let query = query.clone();
let selected = *selected;
draw_palette(frame, app, &query, selected, &mut list, theme);
}
Some(Modal::Themes { selected, .. }) => {
draw_theme_picker(frame, *selected, app.theme_name, &mut list, theme);
}
Some(Modal::Appearances { selected, .. }) => {
draw_appearance_picker(frame, *selected, app.appearance_choice, &mut list, theme);
}
Some(Modal::Conflict { change }) => {
draw_conflict(frame, &mut app.geometry.modal_action_hits, change, theme);
}
}
app.geometry.modal_list_hits = list_hits;
app.geometry.modal_list_len = list_len;
app.geometry.modal_list_max_scroll = list_max_scroll;
}
pub(super) fn draw_commit(
frame: &mut Frame<'_>,
hits: &mut Vec<(Rect, ModalAction)>,
input: &crate::app::TextBuffer,
amend: bool,
theme: &Theme,
) {
let width = frame.area().width.saturating_sub(12).min(76);
let area = centered_rect(width, 12, frame.area());
frame.render_widget(Clear, area);
let block = modal_block(
if amend {
" Amend Commit "
} else {
" Commit Staged Changes "
},
theme,
);
let inner = block.inner(area);
frame.render_widget(block, area);
let input_area = Rect::new(
inner.x,
inner.y + 1,
inner.width,
inner.height.saturating_sub(3),
);
frame.render_widget(
Paragraph::new(input.value.as_str())
.style(Style::default().fg(theme.text).bg(theme.panel_alt))
.wrap(Wrap { trim: false })
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.border_focus)),
),
input_area,
);
set_text_cursor(frame, input_area.inner(Margin::new(1, 1)), input, true);
let buttons = Rect::new(inner.x, inner.bottom().saturating_sub(1), inner.width, 1);
let primary_label = if amend { "Amend" } else { "Commit" };
let cancel_label = "Cancel";
let mode_label = if amend { "Tab: New" } else { "Tab: Amend" };
let cancel_width = u16::try_from(cancel_label.width().saturating_add(2))
.unwrap_or(8)
.min(buttons.width.saturating_sub(3));
let arrow_width = u16::try_from(mode_label.width().saturating_add(2))
.unwrap_or(12)
.min(buttons.width.saturating_sub(cancel_width));
let label_width = buttons
.width
.saturating_sub(cancel_width.saturating_add(arrow_width));
let cancel_area = Rect::new(buttons.x, buttons.y, cancel_width, 1);
let label_area = Rect::new(
buttons.x.saturating_add(cancel_width),
buttons.y,
label_width,
1,
);
let arrow_area = Rect::new(
buttons
.x
.saturating_add(cancel_width.saturating_add(label_width)),
buttons.y,
arrow_width,
1,
);
frame.render_widget(
Paragraph::new("").style(Style::default().bg(theme.panel_alt)),
buttons,
);
frame.render_widget(
Paragraph::new(cancel_label)
.alignment(Alignment::Center)
.style(Style::default().fg(theme.muted).bg(theme.panel_alt)),
cancel_area,
);
frame.render_widget(
Paragraph::new(primary_label)
.alignment(Alignment::Center)
.style(
Style::default()
.fg(theme.accent)
.bg(theme.panel_alt)
.add_modifier(Modifier::BOLD),
),
label_area,
);
frame.render_widget(
Paragraph::new(mode_label)
.alignment(Alignment::Center)
.style(Style::default().fg(theme.modified).bg(theme.panel_alt)),
arrow_area,
);
hits.push((cancel_area, ModalAction::CommitCancel));
hits.push((label_area, ModalAction::CommitSubmit));
hits.push((arrow_area, ModalAction::CommitToggleAmend));
}
pub(super) fn draw_prompt(
frame: &mut Frame<'_>,
title: &str,
input: &crate::app::TextBuffer,
theme: &Theme,
) {
let area = centered_rect(
frame.area().width.saturating_sub(14).min(68),
7,
frame.area(),
);
frame.render_widget(Clear, area);
let block = modal_block(&format!(" {title} "), theme);
let inner = block.inner(area);
frame.render_widget(block, area);
let input_area = Rect::new(inner.x, inner.y + 1, inner.width, 1);
frame.render_widget(
Paragraph::new(input.value.as_str())
.style(Style::default().fg(theme.text).bg(theme.panel_alt)),
input_area,
);
set_text_cursor(frame, input_area, input, false);
draw_modal_hint(frame, area, "Enter accept Esc cancel", theme);
}
#[expect(
clippy::too_many_arguments,
reason = "the picker receives its modal state, hit targets, list geometry, and palette"
)]
fn draw_pr_actions(
frame: &mut Frame<'_>,
hits: &mut Vec<(Rect, ModalAction)>,
title: &str,
items: &[PrActionItem],
selected: usize,
list: &mut ModalList<'_>,
theme: &Theme,
) {
let width = items
.iter()
.map(|item| item.label().width())
.max()
.unwrap_or(24)
.saturating_add(6);
let height = items.len().saturating_add(3);
let area = centered_rect(
u16::try_from(width).unwrap_or(72).min(72),
u16::try_from(height).unwrap_or(20).min(20),
frame.area(),
);
frame.render_widget(Clear, area);
let block = modal_block(&format!(" {title} "), theme);
let inner = block.inner(area);
frame.render_widget(block, area);
let visible = inner.height.saturating_sub(1) as usize;
let offset = list.offset(selected, visible, items.len());
for (row, (index, item)) in items
.iter()
.enumerate()
.skip(offset)
.take(visible)
.enumerate()
{
let active = index == selected;
let row = Rect::new(
inner.x,
inner.y.saturating_add(u16::try_from(row).unwrap_or(0)),
inner.width,
1,
);
frame.render_widget(
Paragraph::new(format!(" {}", item.label())).style(if active {
Style::default().fg(theme.text).bg(theme.selected)
} else {
Style::default().fg(theme.text).bg(theme.panel)
}),
row,
);
hits.push((row, ModalAction::PullRequestAction(index)));
list.hit(row, index);
}
draw_modal_hint(frame, area, "j/k select Enter open Esc cancel", theme);
}
pub(super) fn draw_confirm(
frame: &mut Frame<'_>,
hits: &mut Vec<(Rect, ModalAction)>,
title: &str,
message: &str,
theme: &Theme,
) {
let line_count = message.lines().count().max(1);
let height = u16::try_from(line_count.saturating_add(6))
.unwrap_or(9)
.clamp(9, frame.area().height.saturating_sub(4).max(9));
let area = centered_rect(
frame.area().width.saturating_sub(14).min(72),
height,
frame.area(),
);
frame.render_widget(Clear, area);
let block = modal_block(&format!(" {title} "), theme);
let inner = block.inner(area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(message)
.style(Style::default().fg(theme.text))
.wrap(Wrap { trim: false }),
Rect::new(
inner.x,
inner.y + 1,
inner.width,
inner.height.saturating_sub(3),
),
);
let buttons = Rect::new(inner.x, inner.bottom().saturating_sub(1), inner.width, 1);
#[expect(clippy::integer_division, reason = "layout maths works in whole cells")]
let yes_width = buttons.width / 2;
let no_width = buttons.width.saturating_sub(yes_width);
let yes_area = Rect::new(buttons.x, buttons.y, yes_width, 1);
let no_area = Rect::new(buttons.x.saturating_add(yes_width), buttons.y, no_width, 1);
frame.render_widget(
Paragraph::new("").style(Style::default().bg(theme.panel_alt)),
buttons,
);
frame.render_widget(
Paragraph::new("Yes").alignment(Alignment::Center).style(
Style::default()
.fg(theme.accent)
.bg(theme.panel_alt)
.add_modifier(Modifier::BOLD),
),
yes_area,
);
frame.render_widget(
Paragraph::new("No")
.alignment(Alignment::Center)
.style(Style::default().fg(theme.muted).bg(theme.panel_alt)),
no_area,
);
hits.push((yes_area, ModalAction::ConfirmYes));
hits.push((no_area, ModalAction::ConfirmNo));
}