use crate::state::AppState;
use crossterm::event::{MouseEvent, MouseEventKind};
fn calculate_news_scroll_for_selection(
selected: usize,
total_items: usize,
visible_height: u16,
) -> u16 {
if total_items == 0 || visible_height == 0 {
return 0;
}
let selected_line = u16::try_from(selected).unwrap_or(u16::MAX);
let total_lines = u16::try_from(total_items).unwrap_or(u16::MAX);
let selected_line = selected_line.min(total_lines);
let middle_offset = visible_height / 2;
let desired_scroll = selected_line.saturating_sub(middle_offset);
let max_scroll = total_lines.saturating_sub(visible_height);
desired_scroll.min(max_scroll)
}
pub(super) fn handle_help_modal(
m: MouseEvent,
mx: u16,
my: u16,
is_left_down: bool,
app: &mut AppState,
) -> bool {
if let Some((x, y, w, h)) = app.help_rect
&& mx >= x
&& mx < x + w
&& my >= y
&& my < y + h
{
match m.kind {
MouseEventKind::ScrollUp => {
app.help_scroll = app.help_scroll.saturating_sub(1);
return false;
}
MouseEventKind::ScrollDown => {
app.help_scroll = app.help_scroll.saturating_add(1);
return false;
}
_ => {}
}
}
if is_left_down {
if let Some((x, y, w, h)) = app.help_rect {
let outer_x = x.saturating_sub(1);
let outer_y = y.saturating_sub(1);
let outer_w = w.saturating_add(2);
let outer_h = h.saturating_add(2);
if mx < outer_x || mx >= outer_x + outer_w || my < outer_y || my >= outer_y + outer_h {
app.modal = crate::state::Modal::None;
}
} else {
app.modal = crate::state::Modal::None;
}
return false;
}
false
}
pub(super) fn handle_virustotal_modal(
_m: MouseEvent,
mx: u16,
my: u16,
is_left_down: bool,
app: &AppState,
) -> bool {
if is_left_down
&& let Some((x, y, w, h)) = app.vt_url_rect
&& mx >= x
&& mx < x + w
&& my >= y
&& my < y + h
{
let url = "https://www.virustotal.com/gui/my-apikey";
std::thread::spawn(move || {
let _ = std::process::Command::new("xdg-open")
.arg(url)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
});
}
false
}
pub(super) fn handle_announcement_modal(
m: MouseEvent,
mx: u16,
my: u16,
is_left_down: bool,
app: &mut AppState,
) -> Option<bool> {
if let crate::state::Modal::Announcement { scroll, .. } = &mut app.modal {
if is_left_down {
for (url_x, url_y, url_width, url) in &app.announcement_urls {
if mx >= *url_x && mx < url_x.saturating_add(*url_width) && my == *url_y {
crate::util::open_url(url);
return Some(false);
}
}
if let Some((x, y, w, h)) = app.announcement_rect
&& (mx < x || mx >= x + w || my < y || my >= y + h)
{
app.modal = crate::state::Modal::None;
}
return Some(false);
}
match m.kind {
MouseEventKind::ScrollUp => {
if *scroll > 0 {
*scroll -= 1;
}
return Some(false);
}
MouseEventKind::ScrollDown => {
*scroll = scroll.saturating_add(1);
return Some(false);
}
_ => {}
}
return Some(false);
}
None
}
pub(super) fn handle_news_modal(
m: MouseEvent,
mx: u16,
my: u16,
is_left_down: bool,
app: &mut AppState,
) -> Option<bool> {
if let crate::state::Modal::News {
items,
selected,
scroll,
} = &mut app.modal
{
if is_left_down {
if let Some((x, y, w, h)) = app.news_list_rect
&& mx >= x
&& mx < x + w
&& my >= y
&& my < y + h
{
let relative_y = my.saturating_sub(y);
let clicked_row = (relative_y as usize).saturating_add(*scroll as usize);
if clicked_row < items.len() {
*selected = clicked_row;
*scroll = calculate_news_scroll_for_selection(*selected, items.len(), h);
if let Some(it) = items.get(*selected)
&& let Some(url) = &it.url
{
crate::util::open_url(url);
}
}
} else if let Some((x, y, w, h)) = app.news_rect
&& (mx < x || mx >= x + w || my < y || my >= y + h)
{
app.modal = crate::state::Modal::None;
}
return Some(false);
}
match m.kind {
MouseEventKind::ScrollUp => {
if *selected > 0 {
*selected -= 1;
if let Some((_, _, _, visible_h)) = app.news_list_rect {
*scroll =
calculate_news_scroll_for_selection(*selected, items.len(), visible_h);
}
}
return Some(false);
}
MouseEventKind::ScrollDown => {
if *selected + 1 < items.len() {
*selected += 1;
if let Some((_, _, _, visible_h)) = app.news_list_rect {
*scroll =
calculate_news_scroll_for_selection(*selected, items.len(), visible_h);
}
}
return Some(false);
}
_ => {}
}
return Some(false);
}
None
}
pub(super) fn handle_updates_modal(
m: MouseEvent,
mx: u16,
my: u16,
is_left_down: bool,
app: &mut AppState,
) -> Option<bool> {
let entry_line_starts = app.updates_modal_entry_line_starts.clone();
let total_lines = app.updates_modal_total_lines;
if let crate::state::Modal::Updates {
ref mut scroll,
ref entries,
ref mut selected,
filter_active,
ref filter_query,
ref mut last_selected_pkg_name,
ref mut filtered_indices,
..
} = app.modal
{
if filtered_indices.is_empty() && (!filter_active || filter_query.trim().is_empty()) {
*filtered_indices = (0..entries.len()).collect();
}
if is_left_down {
if let Some((x, y, w, h)) = app.updates_modal_content_rect
&& mx >= x
&& mx < x + w
&& my >= y
&& my < y + h
{
let relative_y = my.saturating_sub(y);
let clicked_render_line = (relative_y as usize).saturating_add(*scroll as usize);
let clicked_row = map_render_line_to_entry(
clicked_render_line,
&entry_line_starts,
filtered_indices.len(),
);
if let Some(&original_idx) = filtered_indices.get(clicked_row) {
*selected = original_idx;
if let Some((name, _, _)) = entries.get(*selected) {
*last_selected_pkg_name = Some(name.clone());
}
let visible_selected = filtered_indices
.iter()
.position(|&idx| idx == *selected)
.unwrap_or(0);
*scroll = crate::events::utils::compute_updates_modal_scroll_for_selection(
&entry_line_starts,
total_lines,
app.updates_modal_content_rect,
visible_selected,
filtered_indices.len(),
*scroll,
);
}
} else if let Some((x, y, w, h)) = app.updates_modal_rect {
if mx < x || mx >= x + w || my < y || my >= y + h {
app.modal = crate::state::Modal::None;
return Some(false); }
}
return Some(false);
}
match m.kind {
crossterm::event::MouseEventKind::ScrollUp => {
if let Some(visible) = filtered_indices.iter().position(|&idx| idx == *selected)
&& visible > 0
{
*selected = filtered_indices[visible - 1];
}
if let Some((name, _, _)) = entries.get(*selected) {
*last_selected_pkg_name = Some(name.clone());
}
let visible_selected = filtered_indices
.iter()
.position(|&idx| idx == *selected)
.unwrap_or(0);
*scroll = crate::events::utils::compute_updates_modal_scroll_for_selection(
&entry_line_starts,
total_lines,
app.updates_modal_content_rect,
visible_selected,
filtered_indices.len(),
*scroll,
);
return Some(false);
}
crossterm::event::MouseEventKind::ScrollDown => {
if let Some(visible) = filtered_indices.iter().position(|&idx| idx == *selected)
&& visible + 1 < filtered_indices.len()
{
*selected = filtered_indices[visible + 1];
}
if let Some((name, _, _)) = entries.get(*selected) {
*last_selected_pkg_name = Some(name.clone());
}
let visible_selected = filtered_indices
.iter()
.position(|&idx| idx == *selected)
.unwrap_or(0);
*scroll = crate::events::utils::compute_updates_modal_scroll_for_selection(
&entry_line_starts,
total_lines,
app.updates_modal_content_rect,
visible_selected,
filtered_indices.len(),
*scroll,
);
return Some(false);
}
_ => {}
}
return Some(false);
}
None
}
pub(super) fn handle_optional_deps_modal(
m: MouseEvent,
mx: u16,
my: u16,
is_left_down: bool,
app: &mut AppState,
) -> Option<bool> {
if let crate::state::Modal::OptionalDeps { rows, selected, .. } = &mut app.modal {
if is_left_down
&& let Some((x, y, w, h)) = app.optional_deps_wizard_rect
&& mx >= x
&& mx < x + w
&& my >= y
&& my < y + h
{
let ssh_command = crate::theme::settings().aur_vote_ssh_command;
app.pending_aur_ssh_help_check_result = Some(
crate::logic::ssh_setup::spawn_aur_ssh_help_check(ssh_command),
);
app.aur_ssh_help_ready = None;
app.modal = crate::state::Modal::StartupSetupSelector {
cursor: 0,
selected: std::collections::HashSet::new(),
active_privilege_tool: crate::logic::privilege::active_tool().ok(),
};
return Some(false);
}
if let Some((x, y, w, h)) = app.optional_deps_modal_rect
&& mx >= x
&& mx < x + w
&& my >= y
&& my < y + h
{
match m.kind {
MouseEventKind::ScrollUp => {
if *selected > 0 {
*selected -= 1;
}
return Some(false);
}
MouseEventKind::ScrollDown => {
if *selected + 1 < rows.len() {
*selected += 1;
}
return Some(false);
}
_ => {}
}
}
return Some(false);
}
None
}
pub(super) fn handle_system_update_modal(
m: MouseEvent,
mx: u16,
my: u16,
_is_left_down: bool,
app: &mut AppState,
) -> Option<bool> {
if let crate::state::Modal::SystemUpdate { cursor, .. } = &mut app.modal {
let in_modal = app
.system_update_modal_rect
.is_some_and(|(x, y, w, h)| mx >= x && mx < x + w && my >= y && my < y + h);
if in_modal {
match m.kind {
MouseEventKind::ScrollUp => {
if *cursor > 0 {
*cursor -= 1;
}
return Some(false);
}
MouseEventKind::ScrollDown => {
let max_cursor = 4;
if *cursor < max_cursor {
*cursor += 1;
}
return Some(false);
}
_ => {}
}
}
return Some(false);
}
None
}
pub(super) fn handle_repositories_modal(
m: MouseEvent,
mx: u16,
my: u16,
_is_left_down: bool,
app: &mut AppState,
) -> Option<bool> {
if let crate::state::Modal::Repositories {
selected,
scroll,
rows,
..
} = &mut app.modal
{
let row_count = rows.len();
let in_modal = app
.repositories_modal_rect
.is_some_and(|(x, y, w, h)| mx >= x && mx < x + w && my >= y && my < y + h);
if in_modal {
match m.kind {
MouseEventKind::ScrollUp => {
crate::events::modals::repositories_modal_wheel_step(
row_count, selected, scroll, false,
);
return Some(false);
}
MouseEventKind::ScrollDown => {
crate::events::modals::repositories_modal_wheel_step(
row_count, selected, scroll, true,
);
return Some(false);
}
_ => {}
}
}
return Some(false);
}
None
}
pub(super) fn handle_ssh_aur_setup_modal(
_m: MouseEvent,
mx: u16,
my: u16,
is_left_down: bool,
app: &mut AppState,
) -> Option<bool> {
if !is_left_down {
return None;
}
let (x, y, w, h) = app.ssh_setup_copy_key_rect?;
if mx < x || mx >= x.saturating_add(w) || my < y || my >= y.saturating_add(h) {
return None;
}
let status_lines = match &app.modal {
crate::state::Modal::SshAurSetup { status_lines, .. } => status_lines.as_slice(),
_ => return None,
};
match crate::logic::ssh_setup::try_copy_aur_ssh_public_key_from_status_lines(status_lines) {
None => None,
Some(Ok(())) => {
app.toast_message = Some(crate::i18n::t(app, "app.toasts.copied_to_clipboard"));
app.toast_expires_at =
Some(std::time::Instant::now() + std::time::Duration::from_secs(3));
Some(false)
}
Some(Err(msg)) => {
app.toast_message = Some(msg);
app.toast_expires_at =
Some(std::time::Instant::now() + std::time::Duration::from_secs(5));
Some(false)
}
}
}
fn map_render_line_to_entry(
render_line: usize,
entry_line_starts: &[u16],
entry_count: usize,
) -> usize {
if entry_count == 0 {
return 0;
}
let line = u16::try_from(render_line).unwrap_or(u16::MAX);
let idx = entry_line_starts.partition_point(|&start| start <= line);
idx.saturating_sub(1).min(entry_count.saturating_sub(1))
}
#[cfg(test)]
mod tests {
use super::{
handle_optional_deps_modal, handle_repositories_modal, handle_system_update_modal,
handle_updates_modal, map_render_line_to_entry,
};
use crate::state::types::{
OptionalDepRow, RepositoryKeyTrust, RepositoryModalRow, RepositoryPacmanStatus,
};
use crate::state::{AppState, Modal};
use crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
use std::collections::HashSet;
#[test]
fn map_render_line_to_entry_resolves_wrapped_ranges() {
let starts = vec![0, 3, 5];
assert_eq!(map_render_line_to_entry(0, &starts, 3), 0);
assert_eq!(map_render_line_to_entry(2, &starts, 3), 0);
assert_eq!(map_render_line_to_entry(3, &starts, 3), 1);
assert_eq!(map_render_line_to_entry(4, &starts, 3), 1);
assert_eq!(map_render_line_to_entry(5, &starts, 3), 2);
assert_eq!(map_render_line_to_entry(999, &starts, 3), 2);
}
#[test]
fn updates_modal_mouse_preserves_empty_active_filter_result_set() {
let mut app = AppState {
modal: Modal::Updates {
entries: vec![
("alpha".to_string(), "1".to_string(), "2".to_string()),
("bravo".to_string(), "1".to_string(), "2".to_string()),
],
scroll: 0,
selected: 0,
filter_active: true,
filter_query: "zzz".to_string(),
filter_caret: 3,
last_selected_pkg_name: None,
filtered_indices: Vec::new(),
selected_pkg_names: HashSet::new(),
},
..AppState::default()
};
let mouse_event = MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 0,
row: 0,
modifiers: KeyModifiers::empty(),
};
let handled = handle_updates_modal(mouse_event, 0, 0, false, &mut app);
assert_eq!(handled, Some(false));
if let Modal::Updates {
filtered_indices, ..
} = &app.modal
{
assert!(
filtered_indices.is_empty(),
"active non-empty filter must preserve an empty match set"
);
} else {
panic!("updates modal should remain open");
}
}
#[test]
fn system_update_wheel_inside_moves_cursor() {
let mut app = AppState {
modal: Modal::SystemUpdate {
do_mirrors: false,
do_pacman: false,
force_sync: false,
do_aur: false,
do_cache: false,
country_idx: 0,
countries: vec!["Worldwide".to_string()],
mirror_count: 10,
cursor: 0,
},
system_update_modal_rect: Some((10, 10, 40, 14)),
..AppState::default()
};
let m = MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 15,
row: 12,
modifiers: KeyModifiers::empty(),
};
let _ = handle_system_update_modal(m, 15, 12, false, &mut app);
match &app.modal {
Modal::SystemUpdate { cursor, .. } => assert_eq!(*cursor, 1),
_ => panic!("expected SystemUpdate"),
}
}
#[test]
fn system_update_wheel_outside_rect_ignores_scroll() {
let mut app = AppState {
modal: Modal::SystemUpdate {
do_mirrors: false,
do_pacman: false,
force_sync: false,
do_aur: false,
do_cache: false,
country_idx: 0,
countries: vec!["Worldwide".to_string()],
mirror_count: 10,
cursor: 0,
},
system_update_modal_rect: Some((10, 10, 40, 14)),
..AppState::default()
};
let m = MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 0,
row: 0,
modifiers: KeyModifiers::empty(),
};
let _ = handle_system_update_modal(m, 0, 0, false, &mut app);
match &app.modal {
Modal::SystemUpdate { cursor, .. } => assert_eq!(*cursor, 0),
_ => panic!("expected SystemUpdate"),
}
}
fn sample_repo_row(name: &str) -> RepositoryModalRow {
RepositoryModalRow {
pacman_section_name: name.to_string(),
results_filter_display: String::new(),
pacman_status: RepositoryPacmanStatus::Absent,
source_hint: None,
key_trust: RepositoryKeyTrust::NotApplicable,
}
}
#[test]
fn repositories_wheel_moves_selection() {
let rows = vec![sample_repo_row("a"), sample_repo_row("b")];
let mut app = AppState {
modal: Modal::Repositories {
rows,
selected: 0,
scroll: 0,
repos_conf_error: None,
pacman_warnings: Vec::new(),
},
repositories_modal_rect: Some((5, 5, 60, 20)),
..AppState::default()
};
let m = MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 10,
row: 10,
modifiers: KeyModifiers::empty(),
};
let _ = handle_repositories_modal(m, 10, 10, false, &mut app);
match &app.modal {
Modal::Repositories { selected, .. } => assert_eq!(*selected, 1),
_ => panic!("expected Repositories"),
}
}
#[test]
fn optional_deps_wheel_moves_selection() {
let rows = vec![
OptionalDepRow {
label: "a".to_string(),
package: "pkg-a".to_string(),
installed: false,
selectable: true,
note: None,
},
OptionalDepRow {
label: "b".to_string(),
package: "pkg-b".to_string(),
installed: false,
selectable: true,
note: None,
},
];
let mut app = AppState {
modal: Modal::OptionalDeps {
rows,
selected: 0,
selected_pkg_names: HashSet::new(),
},
optional_deps_modal_rect: Some((8, 8, 50, 18)),
..AppState::default()
};
let m = MouseEvent {
kind: MouseEventKind::ScrollDown,
column: 20,
row: 12,
modifiers: KeyModifiers::empty(),
};
let _ = handle_optional_deps_modal(m, 20, 12, false, &mut app);
match &app.modal {
Modal::OptionalDeps { selected, .. } => assert_eq!(*selected, 1),
_ => panic!("expected OptionalDeps"),
}
}
}