use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
use crossterm::execute;
use tokio::sync::mpsc;
use crate::state::{AppState, PackageItem, PkgbuildCheckRequest};
const fn is_point_in_rect(mx: u16, my: u16, rect: Option<(u16, u16, u16, u16)>) -> bool {
if let Some((x, y, w, h)) = rect {
mx >= x && mx < x + w && my >= y && my < y + h
} else {
false
}
}
fn handle_url_click(m: MouseEvent, mx: u16, my: u16, app: &mut AppState) -> bool {
if let Some((_, ry, _, _)) = app.url_button_rect
&& my >= ry.saturating_sub(2)
&& my <= ry.saturating_add(2)
{
tracing::info!(
mx,
my,
rect = ?app.url_button_rect,
url = %app.details.url,
in_rect = is_point_in_rect(mx, my, app.url_button_rect),
"URL click check"
);
}
if is_point_in_rect(mx, my, app.url_button_rect)
&& !app.details.url.is_empty()
&& matches!(m.kind, MouseEventKind::Down(MouseButton::Left))
{
tracing::info!(url = %app.details.url, "opening URL via Ctrl+Shift+Click");
app.mouse_disabled_in_details = false;
crate::util::open_url(&app.details.url);
true
} else {
false
}
}
fn handle_pkgb_toggle_click(
mx: u16,
my: u16,
app: &mut AppState,
pkgb_tx: &mpsc::UnboundedSender<PackageItem>,
) -> bool {
if !is_point_in_rect(mx, my, app.pkgb_button_rect) {
return false;
}
app.mouse_disabled_in_details = false;
if app.pkgb_visible {
app.pkgb_visible = false;
app.pkgb_text = None;
app.pkgb_package_name = None;
app.pkgb_scroll = 0;
app.pkgb_rect = None;
} else {
app.pkgb_visible = true;
app.pkgb_text = None;
app.pkgb_package_name = None;
if let Some(item) = app.results.get(app.selected).cloned() {
let _ = pkgb_tx.send(item);
}
}
true
}
fn handle_comments_toggle_click(
mx: u16,
my: u16,
app: &mut AppState,
comments_tx: &mpsc::UnboundedSender<String>,
) -> bool {
if !is_point_in_rect(mx, my, app.comments_button_rect) {
return false;
}
let is_aur = app
.results
.get(app.selected)
.is_some_and(|item| matches!(item.source, crate::state::Source::Aur));
if !is_aur {
return false;
}
app.mouse_disabled_in_details = false;
if app.comments_visible {
app.comments_visible = false;
app.comments.clear();
app.comments_package_name = None;
app.comments_fetched_at = None;
app.comments_scroll = 0;
app.comments_rect = None;
app.comments_loading = false;
app.comments_error = None;
} else {
app.comments_visible = true;
app.comments_scroll = 0;
app.comments_error = None;
if let Some(item) = app.results.get(app.selected) {
if app
.comments_package_name
.as_ref()
.is_some_and(|cached_name| cached_name == &item.name && !app.comments.is_empty())
{
app.comments_loading = false;
return true;
}
app.comments.clear();
app.comments_package_name = None;
app.comments_fetched_at = None;
app.comments_loading = true;
let _ = comments_tx.send(item.name.clone());
}
}
true
}
fn copy_to_clipboard(text: String) -> String {
let suffix = {
let s = crate::theme::settings().clipboard_suffix;
if s.trim().is_empty() {
String::new()
} else {
format!("\n\n{s}\n")
}
};
let payload = if suffix.is_empty() {
text
} else {
format!("{text}{suffix}")
};
if std::env::var("WAYLAND_DISPLAY").is_ok()
&& let Ok(mut child) = std::process::Command::new("wl-copy")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
if let Some(mut sin) = child.stdin.take() {
let _ = std::io::Write::write_all(&mut sin, payload.as_bytes());
}
let _ = child.wait();
return "PKGBUILD is added to the Clipboard".to_string();
}
if let Ok(mut child) = std::process::Command::new("xclip")
.args(["-selection", "clipboard"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
if let Some(mut sin) = child.stdin.take() {
let _ = std::io::Write::write_all(&mut sin, payload.as_bytes());
}
let _ = child.wait();
return "PKGBUILD is added to the Clipboard".to_string();
}
if std::env::var("WAYLAND_DISPLAY").is_ok() {
"Clipboard tool not found. Please install 'wl-clipboard' (provides wl-copy) or 'xclip'."
.to_string()
} else {
"Clipboard tool not found. Please install 'xclip' or 'wl-clipboard' (wl-copy).".to_string()
}
}
fn handle_copy_pkgb_click(mx: u16, my: u16, app: &mut AppState) -> bool {
if !is_point_in_rect(mx, my, app.pkgb_check_button_rect) {
return false;
}
app.mouse_disabled_in_details = false;
if let Some(text) = app.pkgb_text.clone() {
let (tx_msg, rx_msg) = std::sync::mpsc::channel::<Option<String>>();
std::thread::spawn(move || {
let result = copy_to_clipboard(text);
let _ = tx_msg.send(Some(result));
});
app.toast_message = Some(crate::i18n::t(app, "app.toasts.copying_pkgbuild"));
app.toast_expires_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(3));
if let Ok(Some(msg)) = rx_msg.recv_timeout(std::time::Duration::from_millis(50)) {
app.toast_message = Some(msg);
app.toast_expires_at =
Some(std::time::Instant::now() + std::time::Duration::from_secs(4));
}
} else {
app.toast_message = Some(crate::i18n::t(app, "app.toasts.pkgbuild_not_loaded"));
app.toast_expires_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(3));
}
true
}
fn handle_reload_pkgb_click(mx: u16, my: u16, app: &mut AppState) -> bool {
if !is_point_in_rect(mx, my, app.pkgb_reload_button_rect) {
return false;
}
app.mouse_disabled_in_details = false;
if let Some(item) = app.results.get(app.selected).cloned() {
app.pkgb_reload_requested_at = Some(std::time::Instant::now());
app.pkgb_reload_requested_for = Some(item.name);
app.pkgb_text = None; app.pkgb_package_name = None;
}
true
}
fn handle_run_pkgb_checks_click(
mx: u16,
my: u16,
app: &mut AppState,
pkgb_check_tx: &mpsc::UnboundedSender<PkgbuildCheckRequest>,
) -> bool {
if !is_point_in_rect(mx, my, app.pkgb_run_checks_button_rect) {
return false;
}
let Some(text) = app.pkgb_text.clone() else {
app.toast_message = Some(crate::i18n::t(app, "app.toasts.pkgbuild_not_loaded"));
app.toast_expires_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(3));
return true;
};
let package_name = app
.results
.get(app.selected)
.map_or_else(String::new, |item| item.name.clone());
app.pkgb_check_status = crate::state::app_state::PkgbuildCheckStatus::Running;
app.pkgb_check_findings.clear();
app.pkgb_check_raw_results.clear();
app.pkgb_check_missing_tools.clear();
app.pkgb_check_last_error = None;
app.pkgb_check_scroll = 0;
app.pkgb_check_raw_scroll = 0;
app.pkgb_scroll = u16::MAX;
app.toast_message = Some("Running PKGBUILD checks...".to_string());
app.toast_expires_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(2));
if let Err(err) = pkgb_check_tx.send(PkgbuildCheckRequest {
package_name,
pkgbuild_text: text,
dry_run: app.dry_run,
}) {
app.pkgb_check_status = crate::state::app_state::PkgbuildCheckStatus::Complete;
app.pkgb_check_last_error = Some(format!("failed to queue PKGBUILD checks: {err}"));
app.toast_message = Some("Failed to start PKGBUILD checks".to_string());
app.toast_expires_at = Some(std::time::Instant::now() + std::time::Duration::from_secs(3));
}
true
}
#[allow(clippy::missing_const_for_fn)]
fn handle_details_scroll(m: MouseEvent, mx: u16, my: u16, app: &mut AppState) -> bool {
if !is_point_in_rect(mx, my, app.details_rect) {
return false;
}
match m.kind {
MouseEventKind::ScrollUp => {
if matches!(app.app_mode, crate::state::types::AppMode::News) {
app.news_content_scroll = app.news_content_scroll.saturating_sub(1);
} else {
app.details_scroll = app.details_scroll.saturating_sub(1);
}
true
}
MouseEventKind::ScrollDown => {
if matches!(app.app_mode, crate::state::types::AppMode::News) {
app.news_content_scroll = app.news_content_scroll.saturating_add(1);
} else {
app.details_scroll = app.details_scroll.saturating_add(1);
}
true
}
_ => false,
}
}
fn handle_text_selection_block(mx: u16, my: u16, app: &mut AppState) -> bool {
if !app.mouse_disabled_in_details {
return false;
}
if !is_point_in_rect(mx, my, app.details_rect) {
return false;
}
if !app.mouse_capture_enabled {
if std::env::var("PACSEA_TEST_HEADLESS").ok().as_deref() != Some("1") {
let _ = execute!(std::io::stdout(), crossterm::event::EnableMouseCapture);
}
app.mouse_capture_enabled = true;
}
true
}
fn handle_comment_url_click(mx: u16, my: u16, app: &AppState) -> bool {
if !is_point_in_rect(mx, my, app.comments_rect) {
return false;
}
for (url_x, url_y, url_width, url) in &app.comments_urls {
if mx >= *url_x && mx < url_x.saturating_add(*url_width) && my == *url_y {
crate::util::open_url(url);
return true;
}
}
false
}
fn handle_comment_author_click(mx: u16, my: u16, app: &AppState) -> bool {
if !is_point_in_rect(mx, my, app.comments_rect) {
return false;
}
for (author_x, author_y, author_width, username) in &app.comments_authors {
if mx >= *author_x && mx < author_x.saturating_add(*author_width) && my == *author_y {
let profile_url = format!("https://aur.archlinux.org/account/{username}");
crate::util::open_url(&profile_url);
return true;
}
}
false
}
fn handle_comment_date_click(mx: u16, my: u16, app: &AppState) -> bool {
if !is_point_in_rect(mx, my, app.comments_rect) {
return false;
}
for (date_x, date_y, date_width, url) in &app.comments_dates {
if mx >= *date_x && mx < date_x.saturating_add(*date_width) && my == *date_y {
crate::util::open_url(url);
return true;
}
}
false
}
#[allow(clippy::too_many_arguments)]
pub(super) fn handle_details_mouse(
m: MouseEvent,
mx: u16,
my: u16,
is_left_down: bool,
ctrl: bool,
shift: bool,
app: &mut AppState,
pkgb_tx: &mpsc::UnboundedSender<PackageItem>,
comments_tx: &mpsc::UnboundedSender<String>,
pkgb_check_tx: &mpsc::UnboundedSender<PkgbuildCheckRequest>,
) -> Option<bool> {
if is_left_down
&& matches!(app.app_mode, crate::state::types::AppMode::News)
&& is_point_in_rect(mx, my, app.url_button_rect)
&& !app.details.url.is_empty()
{
tracing::info!(
mx,
my,
url = %app.details.url,
rect = ?app.url_button_rect,
"News URL clicked"
);
crate::util::open_url(&app.details.url);
return Some(false);
}
if is_left_down && ctrl && shift {
tracing::info!(
mx,
my,
url_button_rect = ?app.url_button_rect,
details_rect = ?app.details_rect,
url = %app.details.url,
"Ctrl+Shift+Click in details area"
);
if handle_url_click(m, mx, my, app) {
return Some(false);
}
}
if is_left_down && handle_comment_url_click(mx, my, app) {
return Some(false);
}
if is_left_down && handle_comment_author_click(mx, my, app) {
return Some(false);
}
if is_left_down && handle_comment_date_click(mx, my, app) {
return Some(false);
}
if is_left_down {
if handle_pkgb_toggle_click(mx, my, app, pkgb_tx) {
return Some(false);
}
if handle_comments_toggle_click(mx, my, app, comments_tx) {
return Some(false);
}
if handle_copy_pkgb_click(mx, my, app) {
return Some(false);
}
if handle_reload_pkgb_click(mx, my, app) {
return Some(false);
}
if handle_run_pkgb_checks_click(mx, my, app, pkgb_check_tx) {
return Some(false);
}
}
if handle_details_scroll(m, mx, my, app) {
return Some(false);
}
if handle_text_selection_block(mx, my, app) {
return Some(false);
}
None
}