git.lucas.co / cce-system-interface
system settings
git clone https://git.lucas.co/cce-system-interface.git

src/pages/packages.rs (65.1K)

   1 use crate::app::{button_need, form_button, form_divider, wrap_to_width, AppAction, PageContent};
   2 use cce_ui::context::UiContext;
   3 use cce_ui::widget::Handle;
   4 use cce_ui::widget::ScrollRegion;
   5 use cce_ui::layout::{lay_row, render_widget_h, Cell, PageLayoutBuilder, PageFlow, RenderTarget};
   6 use cce_ui::scene::layout::Rect;
   7 use cce_ui::widget::{TextBox, InteractiveListItem};
   8 
   9 #[derive(Debug, Clone, Default)]
  10 pub struct PackageInfo {
  11     pub name: String,
  12     pub version: String,
  13     /// `pacman -Qe`: installed on purpose rather than pulled in as a dependency.
  14     pub explicit: bool,
  15     /// `pacman -Qdt`: a dependency nothing installed requires or optionally
  16     /// requires any more — what `pacman -Rns $(pacman -Qdtq)` would sweep.
  17     pub orphan: bool,
  18 }
  19 
  20 #[derive(Debug, Clone)]
  21 pub struct UpdateInfo {
  22     pub name: String,
  23     pub old_version: String,
  24     pub new_version: String,
  25 }
  26 
  27 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  28 pub enum PackageTab {
  29     Installed,
  30     Updates,
  31 }
  32 
  33 impl Default for PackageTab {
  34     fn default() -> Self {
  35         PackageTab::Installed
  36     }
  37 }
  38 
  39 /// Which slice of the installed list the Installed tab shows.
  40 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
  41 pub enum InstalledFilter {
  42     #[default]
  43     All,
  44     Explicit,
  45     Orphans,
  46 }
  47 
  48 impl InstalledFilter {
  49     fn admits(self, p: &PackageInfo) -> bool {
  50         match self {
  51             InstalledFilter::All => true,
  52             InstalledFilter::Explicit => p.explicit,
  53             InstalledFilter::Orphans => p.orphan,
  54         }
  55     }
  56 }
  57 
  58 /// What a removal would take, asked of `pacman -Rs --print` before anything
  59 /// is removed: the targets plus every dependency only they needed, or the
  60 /// error that stops it (a target another package still requires).
  61 #[derive(Debug, Clone)]
  62 pub struct RemovalPlan {
  63     pub targets: Vec<String>,
  64     pub outcome: Result<Vec<(String, u64)>, String>,
  65 }
  66 
  67 #[derive(Debug, Clone)]
  68 pub struct PackagesState {
  69     pub loaded: bool,
  70     pub installed: Vec<PackageInfo>,
  71     pub updates: Vec<UpdateInfo>,
  72     pub active_tab: PackageTab,
  73     pub search_box: Handle<cce_ui::widget::Adapted<TextBox>>,
  74     pub installed_list: ScrollRegion,
  75     pub installed_items: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
  76     pub updates_list: ScrollRegion,
  77     pub updates_items: Vec<Handle<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>>,
  78     pub updating: bool,
  79     pub last_update_res: Option<Result<(), String>>,
  80     pub selected_package: Option<String>,
  81     pub selected_package_info: Option<String>,
  82     pub loading_info: bool,
  83     pub uninstalling: bool,
  84     pub filter: InstalledFilter,
  85     /// Row clicks toggle membership in `checked` instead of opening details.
  86     pub select_mode: bool,
  87     pub checked: std::collections::BTreeSet<String>,
  88     /// The confirmation step of a removal; `previewing` while pacman is asked.
  89     pub removal: Option<RemovalPlan>,
  90     pub previewing: bool,
  91     pub marking: bool,
  92     /// Outcome of the last remove / mark, shown until the next one starts.
  93     pub last_action: Option<Result<String, String>>,
  94 }
  95 
  96 impl Default for PackagesState {
  97     fn default() -> Self {
  98         Self {
  99             loaded: false,
 100             installed: Vec::new(),
 101             updates: Vec::new(),
 102             active_tab: PackageTab::Installed,
 103             search_box: Handle::none(),
 104             installed_list: ScrollRegion::new(32.0, 4.0).with_frame(false).with_sink_behind(true),
 105             installed_items: Vec::new(),
 106             updates_list: ScrollRegion::new(32.0, 4.0).with_frame(false).with_sink_behind(true),
 107             updates_items: Vec::new(),
 108             updating: false,
 109             last_update_res: None,
 110             selected_package: None,
 111             selected_package_info: None,
 112             loading_info: false,
 113             uninstalling: false,
 114             filter: InstalledFilter::All,
 115             select_mode: false,
 116             checked: std::collections::BTreeSet::new(),
 117             removal: None,
 118             previewing: false,
 119             marking: false,
 120             last_action: None,
 121         }
 122     }
 123 }
 124 
 125 impl PackagesState {
 126     /// The page's state, its widgets inserted into `ctx`.
 127     pub fn new(ctx: &mut UiContext) -> Self {
 128         Self {
 129             search_box: ctx.insert(TextBox::new(String::new()).with_placeholder("Filter Packages...")),
 130             ..Self::default()
 131         }
 132     }
 133 }
 134 
 135 #[derive(Debug, Clone)]
 136 pub enum PackagesMessage {
 137     Refreshed(PackagesState),
 138     SetTab(PackageTab),
 139     StartUpdate,
 140     UpdateFinished(Result<(), String>),
 141     SelectPackage(Option<String>),
 142     SelectAndScrollPackage(String),
 143     InfoFetched(String, Result<String, String>),
 144     SetFilter(InstalledFilter),
 145     ToggleSelectMode,
 146     ToggleChecked(String),
 147     CheckAllVisible,
 148     ClearChecked,
 149     /// Ask pacman what removing these would take; nothing is removed yet.
 150     PreviewRemoval(Vec<String>),
 151     RemovalPreviewed(Vec<String>, Result<Vec<(String, u64)>, String>),
 152     CancelRemoval,
 153     /// Remove a previewed plan's targets (and the dependencies only they need).
 154     StartUninstall(Vec<String>),
 155     UninstallFinished(Vec<String>, Result<(), String>),
 156     /// `true` = mark explicitly installed, `false` = mark as a dependency.
 157     SetInstallReason(Vec<String>, bool),
 158     InstallReasonSet(Vec<String>, bool, Result<(), String>),
 159 }
 160 
 161 pub async fn fetch_packages_state() -> PackagesState {
 162     let installed = fetch_installed_packages().await;
 163     let updates = fetch_available_updates().await;
 164     PackagesState {
 165         loaded: true,
 166         installed,
 167         updates,
 168         updating: false,
 169         last_update_res: None,
 170         ..Default::default()
 171     }
 172 }
 173 
 174 async fn fetch_installed_packages() -> Vec<PackageInfo> {
 175     let (all, explicit, orphans) = tokio::join!(
 176         pacman_stdout(&["-Q"]),
 177         pacman_stdout(&["-Qeq"]),
 178         // Exits 1 when there are none; the empty stdout is the right answer.
 179         pacman_stdout(&["-Qdtq"]),
 180     );
 181     parse_installed(&all, &explicit, &orphans)
 182 }
 183 
 184 async fn pacman_stdout(args: &[&str]) -> String {
 185     match tokio::process::Command::new("pacman").args(args).output().await {
 186         Ok(o) => String::from_utf8_lossy(&o.stdout).into_owned(),
 187         Err(_) => String::new(),
 188     }
 189 }
 190 
 191 /// `pacman -Q` lines joined with the `-Qeq` and `-Qdtq` name lists.
 192 pub fn parse_installed(all: &str, explicit: &str, orphans: &str) -> Vec<PackageInfo> {
 193     let explicit: std::collections::HashSet<&str> = explicit.lines().map(str::trim).collect();
 194     let orphans: std::collections::HashSet<&str> = orphans.lines().map(str::trim).collect();
 195     let mut list: Vec<PackageInfo> = all
 196         .lines()
 197         .filter_map(|line| {
 198             let mut parts = line.split_whitespace();
 199             let name = parts.next()?;
 200             let version = parts.next()?;
 201             Some(PackageInfo {
 202                 name: name.to_string(),
 203                 version: version.to_string(),
 204                 explicit: explicit.contains(name),
 205                 orphan: orphans.contains(name),
 206             })
 207         })
 208         .collect();
 209     list.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
 210     list
 211 }
 212 
 213 async fn fetch_available_updates() -> Vec<UpdateInfo> {
 214     let mut list = Vec::new();
 215     if let Ok(output) = tokio::process::Command::new("checkupdates")
 216         .output()
 217         .await
 218     {
 219         let stdout = String::from_utf8_lossy(&output.stdout);
 220         for line in stdout.lines() {
 221             let parts: Vec<&str> = line.split_whitespace().collect();
 222             if parts.len() >= 4 && parts[2] == "->" {
 223                 list.push(UpdateInfo {
 224                     name: parts[0].to_string(),
 225                     old_version: parts[1].to_string(),
 226                     new_version: parts[3].to_string(),
 227                 });
 228             }
 229         }
 230     }
 231     list.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
 232     list
 233 }
 234 
 235 pub async fn run_update() -> Result<(), String> {
 236     let output = tokio::process::Command::new("pkexec")
 237         .args(["pacman", "-Syu", "--noconfirm"])
 238         .output()
 239         .await
 240         .map_err(|e| format!("Failed to run update: {}", e))?;
 241         
 242     if !output.status.success() {
 243         let err = String::from_utf8_lossy(&output.stderr).to_string();
 244         return Err(format!("Update process failed: {}", err));
 245     }
 246     
 247     Ok(())
 248 }
 249 
 250 /// pacman's own explanation of a failure. It writes the reason to stderr,
 251 /// and for a broken dependency the lines that NAME the dependency (`:: removing
 252 /// x breaks dependency 'x' required by y`) are what make the error useful —
 253 /// so keep every line, not just the first.
 254 fn pacman_error(output: &std::process::Output) -> String {
 255     let text = summarize_pacman_failure(
 256         &String::from_utf8_lossy(&output.stderr),
 257         &String::from_utf8_lossy(&output.stdout),
 258     );
 259     match output.status.code() {
 260         // pkexec's own codes (pacman itself exits 1): 126 = not authorized,
 261         // 127 = the dialog was dismissed or authentication failed.
 262         Some(126) | Some(127) => {
 263             if text.is_empty() {
 264                 "Authorization was cancelled or denied".to_string()
 265             } else {
 266                 format!("Authorization was cancelled or denied ({text})")
 267             }
 268         }
 269         _ if text.is_empty() => format!("pacman exited with {}", output.status),
 270         _ => text,
 271     }
 272 }
 273 
 274 /// pacman splits a refusal across both streams: the `error:` headline goes to
 275 /// stderr, the `:: removing x breaks dependency 'x' required by y` lines that
 276 /// say WHY go to stdout (with progress chatter like "checking dependencies...").
 277 /// Keep the headline, fold the break lines into one "x is still required by
 278 /// a, b, c" per package, and drop the chatter.
 279 pub fn summarize_pacman_failure(stderr: &str, stdout: &str) -> String {
 280     let mut lines: Vec<String> = stderr.lines().map(str::trim).filter(|l| !l.is_empty()).map(String::from).collect();
 281     let mut required_by: Vec<(String, Vec<String>)> = Vec::new();
 282     for line in stdout.lines().chain(stderr.lines()).map(str::trim) {
 283         let Some(rest) = line.strip_prefix(":: removing ") else {
 284             if line.starts_with("error:") && !lines.iter().any(|l| l == line) {
 285                 lines.push(line.to_string());
 286             }
 287             continue;
 288         };
 289         // "acl breaks dependency 'acl' required by coreutils"
 290         if let (Some(pkg), Some(by)) = (rest.split_whitespace().next(), rest.rsplit(" required by ").next()) {
 291             // One line per broken dependency, and a package can depend on
 292             // the target twice over (`acl` and the `libacl.so` it provides).
 293             match required_by.iter_mut().find(|(p, _)| p == pkg) {
 294                 Some((_, list)) if list.iter().any(|b| b == by) => {}
 295                 Some((_, list)) => list.push(by.to_string()),
 296                 None => required_by.push((pkg.to_string(), vec![by.to_string()])),
 297             }
 298         }
 299     }
 300     // The break lines were also counted from stderr above when pacman wrote
 301     // them there; they are summarized below instead.
 302     lines.retain(|l| !l.starts_with(":: removing "));
 303     for (pkg, by) in required_by {
 304         lines.push(format!("{pkg} is still required by {}", by.join(", ")));
 305     }
 306     lines.join("\n")
 307 }
 308 
 309 /// What `pacman -Rs` would remove for `targets`, as (name, installed bytes).
 310 /// Runs unprivileged: `--print` resolves the transaction without locking or
 311 /// touching the database.
 312 pub async fn preview_removal(targets: Vec<String>) -> Result<Vec<(String, u64)>, String> {
 313     let output = tokio::process::Command::new("pacman")
 314         .args(["-Rs", "--print", "--print-format", "%n %s", "--"])
 315         .args(&targets)
 316         .output()
 317         .await
 318         .map_err(|e| format!("Failed to run pacman: {}", e))?;
 319     if !output.status.success() {
 320         return Err(pacman_error(&output));
 321     }
 322     Ok(parse_removal_preview(&String::from_utf8_lossy(&output.stdout)))
 323 }
 324 
 325 pub fn parse_removal_preview(stdout: &str) -> Vec<(String, u64)> {
 326     stdout
 327         .lines()
 328         .filter_map(|line| {
 329             let mut parts = line.split_whitespace();
 330             let name = parts.next()?;
 331             let size = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0);
 332             Some((name.to_string(), size))
 333         })
 334         .collect()
 335 }
 336 
 337 /// Removes `targets` and the dependencies only they needed (`-Rs`), in ONE
 338 /// transaction — pacman orders it, so a target and the package that required
 339 /// it can go together. Removing them one call at a time fails on whichever
 340 /// comes first and silently leaves its dependencies behind as orphans.
 341 pub async fn run_uninstall(targets: Vec<String>) -> Result<(), String> {
 342     let output = tokio::process::Command::new("pkexec")
 343         .args(["pacman", "-Rs", "--noconfirm", "--"])
 344         .args(&targets)
 345         .output()
 346         .await
 347         .map_err(|e| format!("Failed to run uninstall: {}", e))?;
 348     if !output.status.success() {
 349         return Err(pacman_error(&output));
 350     }
 351     Ok(())
 352 }
 353 
 354 /// `pacman -D --asexplicit` / `--asdeps`: changes only the install reason.
 355 pub async fn run_set_install_reason(targets: Vec<String>, explicit: bool) -> Result<(), String> {
 356     let flag = if explicit { "--asexplicit" } else { "--asdeps" };
 357     let output = tokio::process::Command::new("pkexec")
 358         .args(["pacman", "-D", flag, "--"])
 359         .args(&targets)
 360         .output()
 361         .await
 362         .map_err(|e| format!("Failed to run pacman: {}", e))?;
 363     if !output.status.success() {
 364         return Err(pacman_error(&output));
 365     }
 366     Ok(())
 367 }
 368 
 369 pub fn human_size(bytes: u64) -> String {
 370     let b = bytes as f64;
 371     if b >= 1024.0 * 1024.0 * 1024.0 {
 372         format!("{:.1} GiB", b / (1024.0 * 1024.0 * 1024.0))
 373     } else if b >= 1024.0 * 1024.0 {
 374         format!("{:.1} MiB", b / (1024.0 * 1024.0))
 375     } else {
 376         format!("{:.0} KiB", b / 1024.0)
 377     }
 378 }
 379 
 380 /// "3 packages" / "1 package".
 381 fn count_noun(n: usize) -> String {
 382     if n == 1 { "1 package".to_string() } else { format!("{n} packages") }
 383 }
 384 
 385 pub async fn fetch_package_info(name: String, installed: bool) -> Result<String, String> {
 386     let arg = if installed { "-Qi" } else { "-Si" };
 387     let output = tokio::process::Command::new("pacman")
 388         .args([arg, &name])
 389         .output()
 390         .await
 391         .map_err(|e| format!("Failed to run pacman: {}", e))?;
 392         
 393     if !output.status.success() {
 394         let err = String::from_utf8_lossy(&output.stderr).to_string();
 395         return Err(format!("Command failed: {}", err));
 396     }
 397     
 398     let mut raw_info = String::from_utf8_lossy(&output.stdout).to_string();
 399     if installed {
 400         if let Ok(ql_out) = tokio::process::Command::new("pacman")
 401             .args(["-Ql", &name])
 402             .output()
 403             .await
 404         {
 405             let stdout = String::from_utf8_lossy(&ql_out.stdout);
 406             let mut binaries = Vec::new();
 407             for line in stdout.lines() {
 408                 let parts: Vec<&str> = line.split_whitespace().collect();
 409                 if parts.len() >= 2 {
 410                     let path = parts[1];
 411                     if !path.ends_with('/') && (
 412                         path.starts_with("/usr/bin/") || 
 413                         path.starts_with("/bin/") || 
 414                         path.starts_with("/usr/sbin/") || 
 415                         path.starts_with("/sbin/")
 416                     ) {
 417                         if let Some(filename) = path.split('/').last() {
 418                             binaries.push(filename.to_string());
 419                         }
 420                     }
 421                 }
 422             }
 423             if !binaries.is_empty() {
 424                 binaries.sort();
 425                 binaries.dedup();
 426                 raw_info.push_str(&format!("\nCommands        : {}\n", binaries.join("  ")));
 427             }
 428         }
 429     }
 430     Ok(raw_info)
 431 }
 432 
 433 #[derive(Debug, Clone, Default)]
 434 pub struct ParsedPackageInfo {
 435     pub name: String,
 436     pub version: String,
 437     pub description: String,
 438     pub website: String,
 439     pub size: String,
 440     pub licenses: String,
 441     pub packager: String,
 442     pub build_date: String,
 443     pub required_by: String,
 444     pub install_reason: String,
 445     pub commands: String,
 446 }
 447 
 448 pub fn parse_package_info(raw: &str) -> ParsedPackageInfo {
 449     let mut current_key = String::new();
 450     let mut map = std::collections::HashMap::new();
 451 
 452     for line in raw.lines() {
 453         if line.is_empty() {
 454             continue;
 455         }
 456         if !line.starts_with(' ') {
 457             if let Some(pos) = line.find(':') {
 458                 let key = line[..pos].trim().to_string();
 459                 let val = line[pos + 1..].trim().to_string();
 460                 current_key = key.clone();
 461                 map.insert(key, val);
 462             }
 463         } else if !current_key.is_empty() {
 464             if let Some(val) = map.get_mut(&current_key) {
 465                 val.push(' ');
 466                 val.push_str(line.trim());
 467             }
 468         }
 469     }
 470 
 471     let get_val = |k: &str| map.get(k).cloned().unwrap_or_default();
 472 
 473     let name = get_val("Name");
 474     let version = get_val("Version");
 475     let description = get_val("Description");
 476     let website = get_val("URL");
 477     
 478     let mut size = get_val("Installed Size");
 479     if size.is_empty() {
 480         size = get_val("Download Size");
 481     }
 482     
 483     let licenses = get_val("Licenses");
 484     let packager = get_val("Packager");
 485     let build_date = get_val("Build Date");
 486     let required_by = get_val("Required By");
 487     let install_reason = get_val("Install Reason");
 488     let commands = get_val("Commands");
 489 
 490     ParsedPackageInfo {
 491         name,
 492         version,
 493         description,
 494         website,
 495         size,
 496         licenses,
 497         packager,
 498         build_date,
 499         required_by,
 500         install_reason,
 501         commands,
 502     }
 503 }
 504 
 505 pub fn wrap_text(text: &str, max_chars: usize) -> Vec<String> {
 506     let mut lines = Vec::new();
 507     for paragraph in text.split('\n') {
 508         let mut current_line = String::new();
 509         for word in paragraph.split_whitespace() {
 510             if current_line.is_empty() {
 511                 current_line.push_str(word);
 512             } else if current_line.len() + 1 + word.len() > max_chars {
 513                 lines.push(current_line);
 514                 current_line = word.to_string();
 515             } else {
 516                 current_line.push(' ');
 517                 current_line.push_str(word);
 518             }
 519         }
 520         if !current_line.is_empty() {
 521             lines.push(current_line);
 522         }
 523     }
 524     lines
 525 }
 526 
 527 const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
 528 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
 529 const ACCENT: [f32; 4] = [0.36, 0.56, 0.38, 1.0];
 530 const TOGGLE_OFF: [f32; 4] = [0.16, 0.16, 0.24, 1.0];
 531 const BTN_HOVER: [f32; 4] = [0.25, 0.30, 0.26, 1.0];
 532 const RED: [f32; 4] = [0.85, 0.25, 0.25, 1.0];
 533 /// The least room the list keeps on a short window.
 534 const LIST_MIN_H: f32 = 120.0;
 535 
 536 pub fn view(
 537     state: &mut PackagesState,
 538     cx: f32,
 539     cy: f32,
 540     cw: f32,
 541     ch: f32,
 542     sec_focused: &[bool],
 543     layout: &mut PageFlow,
 544     ctx: &mut cce_ui::context::UiContext,
 545 ) -> PageContent {
 546     let mut final_pc = PageContent::new();
 547     let sec_w = 260.0f32;
 548     let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
 549 
 550     builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
 551         let mut form = sec.form();
 552         if !state.loaded {
 553             form.column().text("Loading package lists...", 12.0, TEXT_DIM);
 554             sec.place(form, ctx);
 555             return;
 556         }
 557         // The list fills the page like the services list: the well's floor lands at the
 558         // page's bottom.
 559         form.fill_height((cy + ch) - form.top() - sec.bottom_inset());
 560         let wrap_w = form.width();
 561 
 562         let active_bg = [0.20, 0.40, 0.65, 0.4];
 563         let inactive_bg = [0.10, 0.10, 0.16, 0.3];
 564         let hover_bg = [0.20, 0.20, 0.25, 0.15];
 565         let text_on = [0.90, 0.90, 0.95, 1.0];
 566         let busy = state.busy();
 567 
 568         let query = if ctx[state.search_box].editing {
 569             ctx[state.search_box].edit_buffer.to_lowercase()
 570         } else {
 571             ctx[state.search_box].text.to_lowercase()
 572         };
 573         // What the list shows, worked out before the widgets are lent to the form.
 574         let visible_installed = state.visible_installed(ctx);
 575         if state.installed_items.len() != visible_installed.len() {
 576             for h in state.installed_items.drain(..) {
 577                 ctx.remove(h);
 578             }
 579             for _ in 0..visible_installed.len() {
 580                 state.installed_items.push(ctx.insert(InteractiveListItem::new("")));
 581             }
 582         }
 583         let filtered_updates: Vec<&UpdateInfo> = state.updates.iter().filter(|p| p.name.to_lowercase().contains(&query)).collect();
 584         if state.updates_items.len() != filtered_updates.len() {
 585             for h in state.updates_items.drain(..) {
 586                 ctx.remove(h);
 587             }
 588             for _ in 0..filtered_updates.len() {
 589                 state.updates_items.push(ctx.insert(InteractiveListItem::new("")));
 590             }
 591         }
 592 
 593         let mut col = form.column();
 594 
 595         // ── Tabs (with counts) ──
 596         let label1 = format!("Installed ({})", state.installed.len());
 597         let label2 = format!("Updates ({})", state.updates.len());
 598         let tab = state.active_tab;
 599         col.row(|r| {
 600             form_button(r, label1, 0.0, (if tab == PackageTab::Installed { active_bg } else { inactive_bg }, hover_bg, text_on),
 601                 AppAction::Packages(PackagesMessage::SetTab(PackageTab::Installed)));
 602             form_button(r, label2, 0.0, (if tab == PackageTab::Updates { active_bg } else { inactive_bg }, hover_bg, text_on),
 603                 AppAction::Packages(PackagesMessage::SetTab(PackageTab::Updates)));
 604         });
 605 
 606         col.widget_h(ctx, state.search_box, cce_ui::layout::textbox_height());
 607 
 608         // ── Update System: a button as wide as its label, its status beside it ──
 609         let status_line = if state.updating {
 610             "Updating system...".to_string()
 611         } else if state.updates.is_empty() {
 612             "System is up to date".to_string()
 613         } else {
 614             format!("{} updates available", state.updates.len())
 615         };
 616         let status_color = if state.updating || !state.updates.is_empty() { ACCENT } else { TEXT_DIM };
 617         let (btn_lbl, bg, hover) = if state.updating {
 618             ("Updating...", [0.15, 0.15, 0.20, 1.0], [0.15, 0.15, 0.20, 1.0])
 619         } else {
 620             ("Update System", [0.13, 0.18, 0.14, 1.0], [0.25, 0.30, 0.26, 1.0])
 621         };
 622         col.row(|r| {
 623             form_button(r, btn_lbl, button_need(btn_lbl), (bg, hover, text_on), AppAction::Packages(PackagesMessage::StartUpdate));
 624             r.text_fill(status_line, 12.0, status_color);
 625         });
 626 
 627         if let Some(ref res) = state.last_update_res {
 628             match res {
 629                 Ok(_) => {
 630                     col.text("Last update succeeded", 12.0, [0.56, 0.83, 0.56, 1.0]);
 631                 }
 632                 Err(err) => {
 633                     col.block(|b| {
 634                         b.text("Last update failed:", 12.0, RED);
 635                         b.lines(wrap_to_width(err, wrap_w, 11.0), 11.0, RED);
 636                     });
 637                 }
 638             }
 639         }
 640 
 641         // ── Filter row: All / Explicit / Orphans, and the select-mode switch ──
 642         if state.active_tab == PackageTab::Installed {
 643             let n_explicit = state.installed.iter().filter(|p| p.explicit).count();
 644             let n_orphans = state.installed.iter().filter(|p| p.orphan).count();
 645             let filters = [
 646                 (InstalledFilter::All, format!("All ({})", state.installed.len())),
 647                 (InstalledFilter::Explicit, format!("Explicit ({n_explicit})")),
 648                 (InstalledFilter::Orphans, format!("Orphans ({n_orphans})")),
 649             ];
 650             let select_label = if state.select_mode { "Done Selecting" } else { "Select..." };
 651             let filter = state.filter;
 652             let select_mode = state.select_mode;
 653             col.row(|r| {
 654                 for (f, label) in filters {
 655                     let face = if filter == f { active_bg } else { inactive_bg };
 656                     form_button(r, label, 0.0, (face, hover_bg, text_on), AppAction::Packages(PackagesMessage::SetFilter(f)));
 657                 }
 658                 let face = if select_mode { active_bg } else { inactive_bg };
 659                 form_button(r, select_label, 0.0, (face, hover_bg, text_on), AppAction::Packages(PackagesMessage::ToggleSelectMode));
 660             });
 661 
 662             // ── Bulk actions over the checked rows ──
 663             if state.select_mode {
 664                 let n = state.checked.len();
 665                 let summary = if n == 0 { "Click rows to select".to_string() } else { format!("{} selected", count_noun(n)) };
 666                 let targets: Vec<String> = state.checked.iter().cloned().collect();
 667                 let can_act = n > 0 && !busy;
 668                 let act_text = if can_act { TEXT_FG } else { TEXT_DIM };
 669                 let mark_label = if state.marking { "Marking..." } else { "Mark Explicit" };
 670                 col.row(|r| {
 671                     r.text_fill(summary, 12.0, if n > 0 { ACCENT } else { TEXT_DIM });
 672                     form_button(r, "Select Visible", button_need("Select Visible"), (TOGGLE_OFF, BTN_HOVER, TEXT_FG),
 673                         AppAction::Packages(PackagesMessage::CheckAllVisible));
 674                     form_button(r, "Clear", button_need("Clear"), (TOGGLE_OFF, BTN_HOVER, TEXT_FG),
 675                         AppAction::Packages(PackagesMessage::ClearChecked));
 676                     form_button(r, mark_label, button_need(mark_label),
 677                         (TOGGLE_OFF, if can_act { BTN_HOVER } else { TOGGLE_OFF }, act_text),
 678                         AppAction::Packages(PackagesMessage::SetInstallReason(targets.clone(), true)));
 679                     form_button(r, "Remove...", button_need("Remove..."),
 680                         (
 681                             if can_act { [0.25, 0.14, 0.14, 1.0] } else { TOGGLE_OFF },
 682                             if can_act { [0.40, 0.20, 0.20, 1.0] } else { TOGGLE_OFF },
 683                             if can_act { [0.95, 0.55, 0.55, 1.0] } else { TEXT_DIM },
 684                         ),
 685                         AppAction::Packages(PackagesMessage::PreviewRemoval(targets)));
 686                 });
 687             }
 688         }
 689 
 690         // ── Removal confirmation: what pacman says it would take ──
 691         if state.previewing {
 692             col.text("Checking what would be removed...", 12.0, TEXT_DIM);
 693         } else if let Some(plan) = state.removal.clone() {
 694             form_divider(&mut col);
 695             match &plan.outcome {
 696                 Ok(pkgs) => {
 697                     let total: u64 = pkgs.iter().map(|(_, s)| *s).sum();
 698                     let extra = pkgs.len().saturating_sub(plan.targets.len());
 699                     let head = if extra > 0 {
 700                         format!("Remove {} ({}), including {} no longer needed:",
 701                             count_noun(pkgs.len()), human_size(total),
 702                             if extra == 1 { "1 dependency".to_string() } else { format!("{extra} dependencies") })
 703                     } else {
 704                         format!("Remove {} ({}):", count_noun(pkgs.len()), human_size(total))
 705                     };
 706                     let names = pkgs.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>().join(", ");
 707                     let lines = wrap_to_width(&names, wrap_w, 11.0);
 708                     const MAX_LINES: usize = 6;
 709                     col.block(|b| {
 710                         b.text(head, 12.0, [0.95, 0.75, 0.55, 1.0]);
 711                         b.lines(lines.iter().take(MAX_LINES).cloned().collect(), 11.0, TEXT_FG);
 712                         if lines.len() > MAX_LINES {
 713                             let shown: usize = lines.iter().take(MAX_LINES).map(|l| l.split_whitespace().count()).sum();
 714                             b.text(format!("... and {} more", pkgs.len().saturating_sub(shown)), 11.0, TEXT_DIM);
 715                         }
 716                     });
 717                     let confirm = if state.uninstalling { "Removing...".to_string() } else { format!("Remove {}", count_noun(pkgs.len())) };
 718                     let targets = plan.targets.clone();
 719                     col.row(|r| {
 720                         let need = button_need(&confirm);
 721                         form_button(r, confirm, need, ([0.30, 0.14, 0.14, 1.0], [0.45, 0.20, 0.20, 1.0], [0.98, 0.65, 0.65, 1.0]),
 722                             AppAction::Packages(PackagesMessage::StartUninstall(targets)));
 723                         form_button(r, "Cancel", button_need("Cancel"), (TOGGLE_OFF, BTN_HOVER, TEXT_FG),
 724                             AppAction::Packages(PackagesMessage::CancelRemoval));
 725                     });
 726                 }
 727                 Err(err) => {
 728                     let lines: Vec<String> = err.lines().flat_map(|l| wrap_to_width(l, wrap_w, 11.0)).take(8).collect();
 729                     col.block(|b| {
 730                         b.text(format!("Can't remove {}:", plan.targets.join(", ")), 12.0, RED);
 731                         b.lines(lines, 11.0, [0.95, 0.55, 0.55, 1.0]);
 732                     });
 733                     col.row(|r| {
 734                         form_button(r, "Dismiss", button_need("Dismiss"), (TOGGLE_OFF, BTN_HOVER, TEXT_FG),
 735                             AppAction::Packages(PackagesMessage::CancelRemoval));
 736                     });
 737                 }
 738             }
 739         }
 740 
 741         // ── Outcome of the last remove / mark ──
 742         if let Some(ref res) = state.last_action {
 743             match res {
 744                 Ok(msg) => {
 745                     col.text(msg.clone(), 12.0, [0.56, 0.83, 0.56, 1.0]);
 746                 }
 747                 Err(err) => {
 748                     let lines: Vec<String> = err.lines().flat_map(|l| wrap_to_width(l, wrap_w, 11.0)).take(8).collect();
 749                     col.lines(lines, 11.0, RED);
 750                 }
 751             }
 752         }
 753 
 754         // ── Selected package details ──
 755         // Set aside while a removal is being confirmed: the plan names the
 756         // package, and both together push the list off the page.
 757         let confirming = state.previewing || state.removal.is_some();
 758         if !confirming && (state.loading_info || state.selected_package.is_some()) {
 759             form_divider(&mut col);
 760         }
 761         if confirming {
 762             // Nothing: the confirmation above stands in for the details.
 763         } else if state.loading_info {
 764             col.text("Loading package details...", 12.0, TEXT_DIM);
 765         } else if let Some(pkg_name) = state.selected_package.clone() {
 766             if let Some(info_raw) = state.selected_package_info.clone() {
 767                 let mut parsed = parse_package_info(&info_raw);
 768                 if parsed.name.is_empty() {
 769                     parsed.name = pkg_name.clone();
 770                 }
 771 
 772                 // Heading row: the name, then the install-reason flip and Uninstall at the
 773                 // row's end. An explicit package can be handed back as a dependency (so it
 774                 // goes when nothing needs it) and a dependency kept for good.
 775                 let installed = state.installed.iter().find(|p| p.name == pkg_name).map(|p| p.explicit);
 776                 let on_installed = state.active_tab == PackageTab::Installed;
 777                 col.row(|r| {
 778                     r.text_fill(parsed.name.clone(), 14.0, [0.35, 0.65, 0.90, 1.0]);
 779                     if let (true, Some(explicit)) = (on_installed, installed) {
 780                         let (mark_lbl, to_explicit) = if explicit { ("Mark as Dependency", false) } else { ("Mark Explicit", true) };
 781                         form_button(r, mark_lbl, button_need(mark_lbl),
 782                             (TOGGLE_OFF, if busy { TOGGLE_OFF } else { BTN_HOVER }, if busy { TEXT_DIM } else { TEXT_FG }),
 783                             AppAction::Packages(PackagesMessage::SetInstallReason(vec![pkg_name.clone()], to_explicit)));
 784                         let colors = if busy {
 785                             (TOGGLE_OFF, TOGGLE_OFF, TEXT_DIM)
 786                         } else {
 787                             ([0.25, 0.14, 0.14, 1.0], [0.40, 0.20, 0.20, 1.0], [0.95, 0.55, 0.55, 1.0])
 788                         };
 789                         form_button(r, "Uninstall", button_need("Uninstall"), colors,
 790                             AppAction::Packages(PackagesMessage::PreviewRemoval(vec![pkg_name.clone()])));
 791                     }
 792                 });
 793 
 794                 let orphan = state.installed.iter().any(|p| p.name == pkg_name && p.orphan);
 795                 let reason = if orphan {
 796                     format!("{} (orphan: nothing requires it now)", parsed.install_reason)
 797                 } else {
 798                     parsed.install_reason.clone()
 799                 };
 800                 let pairs = [
 801                     ("Version", parsed.version.clone()),
 802                     ("Install Reason", reason),
 803                     ("Size", parsed.size.clone()),
 804                     ("Licenses", parsed.licenses.clone()),
 805                     ("Website", parsed.website.clone()),
 806                     ("Packager", parsed.packager.clone()),
 807                     ("Built", parsed.build_date.clone()),
 808                     ("Description", parsed.description.clone()),
 809                     ("Commands", parsed.commands.clone()),
 810                 ]
 811                 .into_iter()
 812                 .filter(|(_, v)| !v.is_empty())
 813                 .map(|(k, v)| (k.to_string(), TEXT_DIM, v, TEXT_FG))
 814                 .collect();
 815                 crate::app::form_pairs(&mut col, 11.0, pairs);
 816 
 817                 if !parsed.required_by.is_empty() && parsed.required_by != "None" {
 818                     let reqs: Vec<String> = parsed.required_by.split_whitespace().map(str::to_string).collect();
 819                     const COLUMNS: usize = 4;
 820                     // A core library is required by hundreds (glibc: ~300). Every
 821                     // row of buttons pushes the list down, and past the page's
 822                     // bottom the list loses its box; a few rows say enough.
 823                     const MAX_REQ_ROWS: usize = 4;
 824                     let shown = reqs.len().min(COLUMNS * MAX_REQ_ROWS);
 825                     col.text("Required By", 11.0, TEXT_DIM);
 826                     for chunk in reqs[..shown].chunks(COLUMNS) {
 827                         col.row(|r| {
 828                             for pkg in chunk {
 829                                 let action = AppAction::Packages(PackagesMessage::SelectAndScrollPackage(pkg.clone()));
 830                                 form_button(r, pkg.clone(), 0.0, (TOGGLE_OFF, BTN_HOVER, TEXT_FG), action);
 831                             }
 832                             // A short last row keeps the columns of the rows above it.
 833                             for _ in chunk.len()..COLUMNS {
 834                                 r.space(0.0, true);
 835                             }
 836                         });
 837                     }
 838                     if reqs.len() > shown {
 839                         col.text(format!("... and {} more", reqs.len() - shown), 11.0, TEXT_DIM);
 840                     }
 841                 }
 842             } else {
 843                 col.text("No details available.", 12.0, TEXT_DIM);
 844             }
 845         }
 846 
 847         form_divider(&mut col);
 848 
 849         // ── The list fills the rest of the page ──
 850         let tab = state.active_tab;
 851         let empty_msg = if state.filter == InstalledFilter::Orphans && query.is_empty() {
 852             "No orphaned packages"
 853         } else {
 854             "No packages match the query"
 855         };
 856         let select_mode = state.select_mode;
 857         let checked = &state.checked;
 858         let selected = state.selected_package.clone();
 859         let installed = &state.installed;
 860         let (installed_list, installed_items) = (&mut state.installed_list, &state.installed_items);
 861         let (updates_list, updates_items) = (&mut state.updates_list, &state.updates_items);
 862         col.fill(LIST_MIN_H, move |pc, rect, ctx| {
 863             let (list_box_x, list_box_y, list_box_w, list_box_h) = (rect.x, rect.y, rect.width, rect.height);
 864             let inset = cce_ui::layout::plate_padding();
 865             match tab {
 866                 PackageTab::Installed => {
 867                     let filtered: Vec<&PackageInfo> = visible_installed.iter().map(|&i| &installed[i]).collect();
 868                     // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
 869                     installed_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
 870                     installed_list.update_bounds(filtered.len(), list_box_y, list_box_h);
 871                     installed_list.push_prims(pc);
 872                     let item_h = installed_list.item_height;
 873 
 874                     pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
 875                     for (idx, pkg) in filtered.iter().enumerate() {
 876                         if let Some(draw_y) = installed_list.get_item_draw_y(idx, 4.0) {
 877                             // Rows dispatch as extra roots (the dissolved list is no parent).
 878                             let row = installed_items[idx];
 879                             let item = &mut ctx[row];
 880                             item.title = pkg.name.clone();
 881                             let reason = match (pkg.explicit, pkg.orphan) {
 882                                 (true, _) => "explicit",
 883                                 (false, true) => "orphan",
 884                                 (false, false) => "dependency",
 885                             };
 886                             item.subtitle = Some(format!("{}  ·  {}", pkg.version, reason));
 887                             item.selected = if select_mode {
 888                                 checked.contains(&pkg.name)
 889                             } else {
 890                                 Some(&pkg.name) == selected.as_ref()
 891                             };
 892                             let cell = lay_row(Rect { x: list_box_x, y: draw_y, width: list_box_w, height: item_h }, &[Cell::grow(item_h)])[0];
 893                             render_widget_h(pc, row, cell.x, cell.y, cell.width, cell.height, ctx);
 894                         }
 895                     }
 896                     pc.pop_clip_rect();
 897                     // The scrollbar's fore copy, over the rows at the raise's fade.
 898                     installed_list.push_scrollbar_fore(pc);
 899                     if filtered.is_empty() {
 900                         pc.text(empty_msg, list_box_x + inset, list_box_y + inset, 12.0, TEXT_DIM);
 901                     }
 902                 }
 903                 PackageTab::Updates => {
 904                     // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
 905                     updates_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
 906                     updates_list.update_bounds(filtered_updates.len(), list_box_y, list_box_h);
 907                     updates_list.push_prims(pc);
 908                     let item_h = updates_list.item_height;
 909 
 910                     pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
 911                     for (idx, pkg) in filtered_updates.iter().enumerate() {
 912                         if let Some(draw_y) = updates_list.get_item_draw_y(idx, 4.0) {
 913                             // Rows dispatch as extra roots (the dissolved list is no parent).
 914                             let row = updates_items[idx];
 915                             let item = &mut ctx[row];
 916                             item.title = pkg.name.clone();
 917                             item.subtitle = Some(format!("{}  ->  {}", pkg.old_version, pkg.new_version));
 918                             item.selected = Some(&pkg.name) == selected.as_ref();
 919                             let cell = lay_row(Rect { x: list_box_x, y: draw_y, width: list_box_w, height: item_h }, &[Cell::grow(item_h)])[0];
 920                             render_widget_h(pc, row, cell.x, cell.y, cell.width, cell.height, ctx);
 921                         }
 922                     }
 923                     pc.pop_clip_rect();
 924                     // The scrollbar's fore copy, over the rows at the raise's fade.
 925                     updates_list.push_scrollbar_fore(pc);
 926                     if filtered_updates.is_empty() {
 927                         pc.text("No updates match the query", list_box_x + inset, list_box_y + inset, 12.0, TEXT_DIM);
 928                     }
 929                 }
 930             }
 931         });
 932         sec.place(form, ctx);
 933     });
 934 
 935     final_pc
 936 }
 937 
 938 pub fn update(state: &mut PackagesState, msg: PackagesMessage, ctx: &mut UiContext) {
 939     match msg {
 940         PackagesMessage::Refreshed(new) => {
 941             state.loaded = new.loaded;
 942             state.installed = new.installed;
 943             state.updates = new.updates;
 944             // Selection, fetched info, and in-flight update/uninstall flags are
 945             // LOCAL state — the periodic background refresh must not clear them.
 946             // Only drop a selection whose package no longer exists anywhere.
 947             let installed = &state.installed;
 948             state.checked.retain(|n| installed.iter().any(|p| &p.name == n));
 949             if let Some(sel) = state.selected_package.clone() {
 950                 let still_exists = state.installed.iter().any(|p| p.name == sel)
 951                     || state.updates.iter().any(|u| u.name == sel);
 952                 if !still_exists {
 953                     state.selected_package = None;
 954                     state.selected_package_info = None;
 955                     state.loading_info = false;
 956                 }
 957             }
 958         }
 959         PackagesMessage::SetTab(tab) => {
 960             state.active_tab = tab;
 961             state.installed_list.set_scroll_y(0.0);
 962             state.updates_list.set_scroll_y(0.0);
 963             for h in state.installed_items.drain(..) {
 964                 ctx.remove(h);
 965             }
 966             for h in state.updates_items.drain(..) {
 967                 ctx.remove(h);
 968             }
 969             state.selected_package = None;
 970             state.selected_package_info = None;
 971             state.loading_info = false;
 972         }
 973         PackagesMessage::StartUpdate => {
 974             state.updating = true;
 975             state.last_update_res = None;
 976         }
 977         PackagesMessage::UpdateFinished(res) => {
 978             state.updating = false;
 979             state.last_update_res = Some(res);
 980         }
 981         PackagesMessage::SelectPackage(name) => {
 982             if name != state.selected_package {
 983                 state.selected_package = name;
 984                 state.selected_package_info = None;
 985                 state.loading_info = state.selected_package.is_some();
 986             }
 987         }
 988         PackagesMessage::InfoFetched(name, res) => {
 989             if state.selected_package.as_ref() == Some(&name) {
 990                 state.loading_info = false;
 991                 match res {
 992                     Ok(info) => {
 993                         state.selected_package_info = Some(info);
 994                     }
 995                     Err(err) => {
 996                         state.selected_package_info = Some(format!("Error loading package info: {}", err));
 997                     }
 998                 }
 999             }
1000         }
1001         PackagesMessage::SetFilter(filter) => {
1002             if state.filter != filter {
1003                 state.filter = filter;
1004                 state.installed_list.set_scroll_y(0.0);
1005                 for h in state.installed_items.drain(..) {
1006                     ctx.remove(h);
1007                 }
1008             }
1009         }
1010         PackagesMessage::ToggleSelectMode => {
1011             state.select_mode = !state.select_mode;
1012             if state.select_mode {
1013                 // The details pane belongs to single selection; free its space.
1014                 state.selected_package = None;
1015                 state.selected_package_info = None;
1016                 state.loading_info = false;
1017             } else {
1018                 state.checked.clear();
1019             }
1020         }
1021         PackagesMessage::ToggleChecked(name) => {
1022             if !state.checked.remove(&name) {
1023                 state.checked.insert(name);
1024             }
1025         }
1026         PackagesMessage::CheckAllVisible => {
1027             for i in state.visible_installed(ctx) {
1028                 state.checked.insert(state.installed[i].name.clone());
1029             }
1030         }
1031         PackagesMessage::ClearChecked => state.checked.clear(),
1032         PackagesMessage::PreviewRemoval(_) => {
1033             state.previewing = true;
1034             state.removal = None;
1035             state.last_action = None;
1036         }
1037         PackagesMessage::RemovalPreviewed(targets, outcome) => {
1038             state.previewing = false;
1039             state.removal = Some(RemovalPlan { targets, outcome });
1040         }
1041         PackagesMessage::CancelRemoval => {
1042             state.removal = None;
1043             state.previewing = false;
1044         }
1045         PackagesMessage::StartUninstall(_) => {
1046             state.uninstalling = true;
1047             state.last_action = None;
1048         }
1049         PackagesMessage::UninstallFinished(targets, res) => {
1050             state.uninstalling = false;
1051             let removed = state.removal.as_ref()
1052                 .filter(|p| p.targets == targets)
1053                 .and_then(|p| p.outcome.as_ref().ok())
1054                 .map(|pkgs| pkgs.len())
1055                 .unwrap_or(targets.len());
1056             state.removal = None;
1057             match res {
1058                 Ok(()) => {
1059                     for t in &targets {
1060                         state.checked.remove(t);
1061                     }
1062                     if state.selected_package.as_ref().is_some_and(|s| targets.contains(s)) {
1063                         state.selected_package = None;
1064                         state.selected_package_info = None;
1065                     }
1066                     state.last_action = Some(Ok(format!("Removed {}", count_noun(removed))));
1067                 }
1068                 Err(err) => {
1069                     state.last_action = Some(Err(format!("Removal failed: {err}")));
1070                 }
1071             }
1072         }
1073         PackagesMessage::SetInstallReason(_, _) => {
1074             state.marking = true;
1075             state.last_action = None;
1076         }
1077         PackagesMessage::InstallReasonSet(targets, explicit, res) => {
1078             state.marking = false;
1079             let what = if explicit { "explicitly installed" } else { "dependencies" };
1080             state.last_action = Some(match res {
1081                 Ok(()) => {
1082                     // Reflect it now; the refresh that follows confirms it (and
1083                     // recomputes orphans, which a reason change can create).
1084                     for p in state.installed.iter_mut().filter(|p| targets.contains(&p.name)) {
1085                         p.explicit = explicit;
1086                         if explicit {
1087                             p.orphan = false;
1088                         }
1089                     }
1090                     if targets.len() == 1 {
1091                         Ok(format!("Marked {} as {}", targets[0], if explicit { "explicitly installed" } else { "a dependency" }))
1092                     } else {
1093                         Ok(format!("Marked {} as {}", count_noun(targets.len()), what))
1094                     }
1095                 }
1096                 Err(err) => Err(format!("Marking failed: {err}")),
1097             });
1098         }
1099         PackagesMessage::SelectAndScrollPackage(name) => {
1100             state.select_and_scroll_to(&name, ctx);
1101         }
1102     }
1103 }
1104 
1105 impl PackagesState {
1106     /// A pacman transaction (or pkexec prompt) is in flight. pacman holds one
1107     /// database lock, so a second one started now would only fail.
1108     pub fn busy(&self) -> bool {
1109         self.updating || self.uninstalling || self.marking
1110     }
1111 
1112     /// The search query as typed so far (the box commits only on Enter).
1113     fn query(&self, ctx: &UiContext) -> String {
1114         if ctx[self.search_box].editing {
1115             ctx[self.search_box].edit_buffer.to_lowercase()
1116         } else {
1117             ctx[self.search_box].text.to_lowercase()
1118         }
1119     }
1120 
1121     /// Indices into `installed` of the rows the Installed tab shows, in order:
1122     /// the filter, then the search. The view paints exactly these and the click
1123     /// mapping resolves against them, so the two can never disagree.
1124     pub fn visible_installed(&self, ctx: &UiContext) -> Vec<usize> {
1125         let query = self.query(ctx);
1126         self.installed
1127             .iter()
1128             .enumerate()
1129             .filter(|(_, p)| self.filter.admits(p))
1130             .filter(|(_, p)| p.name.to_lowercase().contains(&query) || p.version.to_lowercase().contains(&query))
1131             .map(|(i, _)| i)
1132             .collect()
1133     }
1134 
1135     pub fn select_and_scroll_to(&mut self, pkg_name: &str, ctx: &mut UiContext) {
1136         self.active_tab = PackageTab::Installed;
1137         // The scroll target below is an index into the whole list, so the
1138         // list must be unfiltered for it to land on the package.
1139         self.filter = InstalledFilter::All;
1140         self.select_mode = false;
1141         self.checked.clear();
1142         for h in self.installed_items.drain(..) {
1143             ctx.remove(h);
1144         }
1145         ctx[self.search_box].text.clear();
1146         ctx[self.search_box].edit_buffer.clear();
1147         ctx[self.search_box].editing = false;
1148         self.selected_package = Some(pkg_name.to_string());
1149         self.selected_package_info = None;
1150         self.loading_info = true;
1151         if let Some(idx) = self.installed.iter().position(|p| p.name == pkg_name) {
1152             let item_height_full = self.installed_list.item_height + self.installed_list.item_gap;
1153             let target_y = idx as f32 * item_height_full - 164.0;
1154             self.installed_list.set_scroll_y(target_y);
1155             self.installed_list.notify_scrolled();
1156         }
1157     }
1158 }
1159 
1160 impl PackagesState {
1161     /// The active tab's dissolved list region (only one is laid out per frame).
1162     fn active_list(&mut self) -> &mut ScrollRegion {
1163         match self.active_tab {
1164             PackageTab::Installed => &mut self.installed_list,
1165             PackageTab::Updates => &mut self.updates_list,
1166         }
1167     }
1168 }
1169 
1170 impl crate::pages::AppPage for PackagesState {
1171     // Sections: [the one well]
1172     fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
1173         // Mirrors the view's `!loaded` early return: the search box is only painted
1174         // (and so only registered) once `pacman -Q` + `checkupdates` land, which is
1175         // the longest load window of any page. The group count stays 1 either way —
1176         // an empty outer Vec would kill the ctrl-nav entry point.
1177         if self.loaded {
1178             vec![vec![self.search_box.id()]]
1179         } else {
1180             vec![Vec::new()]
1181         }
1182     }
1183 
1184     fn view(
1185         &mut self,
1186         cx: f32,
1187         cy: f32,
1188         cw: f32,
1189         ch: f32,
1190         _root_focused: bool,
1191         sec_focused: &[bool],
1192         layout: &mut PageFlow,
1193         ctx: &mut cce_ui::context::UiContext,
1194     ) -> crate::app::PageContent {
1195         view(self, cx, cy, cw, ch, sec_focused, layout, ctx)
1196     }
1197 
1198     fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>, ctx: &mut UiContext) {
1199         let query = if ctx[self.search_box].editing {
1200             ctx[self.search_box].edit_buffer.to_lowercase()
1201         } else {
1202             ctx[self.search_box].text.to_lowercase()
1203         };
1204 
1205         match self.active_tab {
1206             PackageTab::Installed => {
1207                 let visible = self.visible_installed(ctx);
1208                 for (idx, &row) in self.installed_items.iter().enumerate() {
1209                     let item = &mut ctx[row];
1210                     if item.just_clicked {
1211                         item.just_clicked = false;
1212                         if let Some(&i) = visible.get(idx) {
1213                             let name = self.installed[i].name.clone();
1214                             actions.push(AppAction::Packages(if self.select_mode {
1215                                 PackagesMessage::ToggleChecked(name)
1216                             } else {
1217                                 PackagesMessage::SelectPackage(Some(name))
1218                             }));
1219                         }
1220                     }
1221                 }
1222             }
1223             PackageTab::Updates => {
1224                 let filtered: Vec<&UpdateInfo> = self.updates.iter()
1225                     .filter(|p| p.name.to_lowercase().contains(&query))
1226                     .collect();
1227                 for (idx, &row) in self.updates_items.iter().enumerate() {
1228                     let item = &mut ctx[row];
1229                     if item.just_clicked {
1230                         item.just_clicked = false;
1231                         if idx < filtered.len() {
1232                             let pkg = filtered[idx];
1233                             actions.push(AppAction::Packages(PackagesMessage::SelectPackage(Some(pkg.name.clone()))));
1234                         }
1235                     }
1236                 }
1237             }
1238         }
1239     }
1240 
1241     // Both root methods below filter by `get_item_draw_y`, the SAME predicate the view's
1242     // paint loop uses to virtualize rows. Only a row that was drawn had `render_widget`
1243     // refresh its rect; a scrolled-out row keeps the rect from the last frame it was
1244     // visible, and since `dispatch_page_event` takes the first root that hit-tests true
1245     // in index order, an unfiltered list let a stale low-index row swallow clicks meant
1246     // for the row actually on screen (click "fontforge", select "appstream").
1247     fn extra_dispatch_roots(&mut self) -> Vec<cce_ui::widget::WidgetId> {
1248         let (list, items) = match self.active_tab {
1249             PackageTab::Installed => (&self.installed_list, &self.installed_items),
1250             PackageTab::Updates => (&self.updates_list, &self.updates_items),
1251         };
1252         items
1253             .iter()
1254             .enumerate()
1255             .filter(|(idx, _)| list.get_item_draw_y(*idx, 4.0).is_some())
1256             .map(|(_, i)| i.id())
1257             .collect()
1258     }
1259 
1260 
1261     fn handle_pointer_move(
1262         &mut self,
1263         lx: f32,
1264         ly: f32,
1265         _actions: &mut Vec<crate::app::AppAction>,
1266         _ctx: &mut cce_ui::context::UiContext,
1267     ) -> bool {
1268         self.loaded && self.active_list().cursor_moved(lx, ly)
1269     }
1270 
1271     fn handle_pointer_down(&mut self, lx: f32, ly: f32, _ctx: &mut cce_ui::context::UiContext) -> bool {
1272         self.loaded && self.active_list().press(lx, ly)
1273     }
1274 
1275     fn handle_pointer_up(&mut self, _ctx: &mut cce_ui::context::UiContext) -> bool {
1276         self.active_list().release()
1277     }
1278 
1279     fn handle_mouse_wheel(&mut self, delta: &cce_ui::widget::MouseScrollDelta, lx: f32, ly: f32) -> bool {
1280         self.loaded && self.active_list().wheel(delta, lx, ly)
1281     }
1282 
1283     fn handle_key_input(&mut self, event: &cce_ui::widget::KeyEvent) -> bool {
1284         self.loaded && self.active_list().keyboard(event)
1285     }
1286 
1287     fn tick(&mut self, dt: f32) -> bool {
1288         self.installed_list.tick(dt) | self.updates_list.tick(dt)
1289     }
1290 
1291     /// Only the active tab's list is laid out and drawn.
1292     fn frameless_lists(&self) -> Vec<&ScrollRegion> {
1293         vec![match self.active_tab {
1294             PackageTab::Installed => &self.installed_list,
1295             PackageTab::Updates => &self.updates_list,
1296         }]
1297     }
1298 }
1299 
1300 #[cfg(test)]
1301 mod tests {
1302     use super::*;
1303 
1304     #[test]
1305     fn refresh_preserves_selection_and_flags() {
1306         let mut ui = cce_ui::context::UiContext::new();
1307         let mut st = PackagesState::new(&mut ui);
1308         st.loaded = true;
1309         st.installed = vec![PackageInfo { name: "foo".into(), version: "1".into(), ..Default::default() }];
1310         st.selected_package = Some("foo".into());
1311         st.selected_package_info = Some("info".into());
1312         st.updating = true;
1313 
1314         let fresh = PackagesState {
1315             loaded: true,
1316             installed: vec![PackageInfo { name: "foo".into(), version: "2".into(), ..Default::default() }],
1317             ..Default::default()
1318         };
1319         update(&mut st, PackagesMessage::Refreshed(fresh), &mut ui);
1320         assert_eq!(st.selected_package.as_deref(), Some("foo"), "refresh must keep the selection");
1321         assert!(st.selected_package_info.is_some(), "refresh must keep fetched info");
1322         assert!(st.updating, "refresh must not clear the in-flight update flag");
1323 
1324         // A package that vanished from both lists does clear the selection.
1325         let fresh2 = PackagesState { loaded: true, ..Default::default() };
1326         update(&mut st, PackagesMessage::Refreshed(fresh2), &mut ui);
1327         assert!(st.selected_package.is_none());
1328         assert!(st.selected_package_info.is_none());
1329     }
1330 
1331     fn pkg(name: &str, explicit: bool, orphan: bool) -> PackageInfo {
1332         PackageInfo { name: name.into(), version: "1".into(), explicit, orphan }
1333     }
1334 
1335     fn loaded_state(ui: &mut UiContext) -> PackagesState {
1336         let mut st = PackagesState::new(ui);
1337         st.loaded = true;
1338         st.installed = vec![
1339             pkg("alpha", true, false),
1340             pkg("beta", false, true),
1341             pkg("gamma", false, false),
1342             pkg("gamma-orphan", false, true),
1343         ];
1344         st
1345     }
1346 
1347     #[test]
1348     fn parse_installed_joins_reason_and_orphan_lists() {
1349         let list = parse_installed("zed 1.0\nRust 2.0\ncmake 3.0\n", "zed\n", "cmake\n");
1350         let names: Vec<_> = list.iter().map(|p| p.name.as_str()).collect();
1351         assert_eq!(names, ["cmake", "Rust", "zed"], "sorted case-insensitively");
1352         assert!(list[2].explicit && !list[2].orphan);
1353         assert!(!list[1].explicit && !list[1].orphan);
1354         assert!(!list[0].explicit && list[0].orphan);
1355     }
1356 
1357     #[test]
1358     fn filter_and_search_compose() {
1359         let mut ui = cce_ui::context::UiContext::new();
1360         let mut st = loaded_state(&mut ui);
1361         assert_eq!(st.visible_installed(&ui).len(), 4);
1362         update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans), &mut ui);
1363         assert_eq!(st.visible_installed(&ui), vec![1, 3]);
1364         update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Explicit), &mut ui);
1365         assert_eq!(st.visible_installed(&ui), vec![0]);
1366         update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans), &mut ui);
1367         ui[st.search_box].text = "gamma".into();
1368         assert_eq!(st.visible_installed(&ui), vec![3]);
1369     }
1370 
1371     /// A click on visible row N must name the package painted at row N, under
1372     /// a filter too — the view and the click mapping share `visible_installed`.
1373     #[test]
1374     fn row_click_resolves_through_the_filter() {
1375         let mut ui = cce_ui::context::UiContext::new();
1376         let mut st = loaded_state(&mut ui);
1377         update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans), &mut ui);
1378         let mut layout = cce_ui::layout::PageFlow::new();
1379         view(&mut st, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut ui);
1380         assert_eq!(st.installed_items.len(), 2);
1381         ui[st.installed_items[1]].just_clicked = true;
1382         let mut actions = Vec::new();
1383         crate::pages::AppPage::propagate_widget_changes(&mut st, &mut actions, &mut ui);
1384         match actions.as_slice() {
1385             [AppAction::Packages(PackagesMessage::SelectPackage(Some(n)))] => assert_eq!(n, "gamma-orphan"),
1386             other => panic!("unexpected actions: {other:?}"),
1387         }
1388 
1389         // In select mode the same click toggles the row instead.
1390         update(&mut st, PackagesMessage::ToggleSelectMode, &mut ui);
1391         ui[st.installed_items[1]].just_clicked = true;
1392         let mut actions = Vec::new();
1393         crate::pages::AppPage::propagate_widget_changes(&mut st, &mut actions, &mut ui);
1394         match actions.as_slice() {
1395             [AppAction::Packages(PackagesMessage::ToggleChecked(n))] => assert_eq!(n, "gamma-orphan"),
1396             other => panic!("unexpected actions: {other:?}"),
1397         }
1398     }
1399 
1400     #[test]
1401     fn select_mode_checks_and_clears() {
1402         let mut ui = cce_ui::context::UiContext::new();
1403         let mut st = loaded_state(&mut ui);
1404         st.selected_package = Some("alpha".into());
1405         update(&mut st, PackagesMessage::ToggleSelectMode, &mut ui);
1406         assert!(st.selected_package.is_none(), "select mode frees the details pane");
1407         update(&mut st, PackagesMessage::ToggleChecked("beta".into()), &mut ui);
1408         update(&mut st, PackagesMessage::ToggleChecked("beta".into()), &mut ui);
1409         assert!(st.checked.is_empty(), "a second click unchecks");
1410         update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans), &mut ui);
1411         update(&mut st, PackagesMessage::CheckAllVisible, &mut ui);
1412         assert_eq!(st.checked.iter().cloned().collect::<Vec<_>>(), ["beta", "gamma-orphan"]);
1413         // A refresh drops checked names that are gone.
1414         let fresh = PackagesState { loaded: true, installed: vec![pkg("beta", false, true)], ..Default::default() };
1415         update(&mut st, PackagesMessage::Refreshed(fresh), &mut ui);
1416         assert_eq!(st.checked.iter().cloned().collect::<Vec<_>>(), ["beta"]);
1417         update(&mut st, PackagesMessage::ToggleSelectMode, &mut ui);
1418         assert!(st.checked.is_empty(), "leaving select mode clears the selection");
1419     }
1420 
1421     /// The failure that prompted this page's rework: a removal pacman refuses
1422     /// (a dependency still requires it) used to land in the details text,
1423     /// where the info parser found no known key and showed nothing at all.
1424     #[test]
1425     fn refused_removal_stays_visible() {
1426         let mut ui = cce_ui::context::UiContext::new();
1427         let mut st = loaded_state(&mut ui);
1428         let err = "error: failed to prepare transaction (could not satisfy dependencies)\n\
1429                    :: removing beta breaks dependency 'beta' required by alpha".to_string();
1430         update(&mut st, PackagesMessage::PreviewRemoval(vec!["beta".into()]), &mut ui);
1431         assert!(st.previewing);
1432         update(&mut st, PackagesMessage::RemovalPreviewed(vec!["beta".into()], Err(err.clone())), &mut ui);
1433         assert!(!st.previewing);
1434         let mut layout = cce_ui::layout::PageFlow::new();
1435         let pc = view(&mut st, 10.0, 20.0, 800.0, 600.0, &[false], &mut layout, &mut ui);
1436         let texts: Vec<String> = pc.texts.iter().map(|t| t.0.clone()).collect();
1437         assert!(texts.iter().any(|t| t.contains("required by alpha")), "the reason is painted: {texts:?}");
1438 
1439         // And a failed transaction reports into last_action, not the details.
1440         update(&mut st, PackagesMessage::StartUninstall(vec!["beta".into()]), &mut ui);
1441         update(&mut st, PackagesMessage::UninstallFinished(vec!["beta".into()], Err(err)), &mut ui);
1442         assert!(!st.uninstalling);
1443         assert!(matches!(&st.last_action, Some(Err(e)) if e.contains("required by alpha")));
1444     }
1445 
1446     #[test]
1447     fn successful_removal_reports_the_whole_plan() {
1448         let mut ui = cce_ui::context::UiContext::new();
1449         let mut st = loaded_state(&mut ui);
1450         st.checked.insert("beta".into());
1451         update(&mut st, PackagesMessage::RemovalPreviewed(
1452             vec!["beta".into()],
1453             Ok(parse_removal_preview("beta 1024\nlibbeta 2048\n")),
1454         ), &mut ui);
1455         update(&mut st, PackagesMessage::StartUninstall(vec!["beta".into()]), &mut ui);
1456         update(&mut st, PackagesMessage::UninstallFinished(vec!["beta".into()], Ok(())), &mut ui);
1457         assert!(st.removal.is_none());
1458         assert!(st.checked.is_empty());
1459         assert!(matches!(&st.last_action, Some(Ok(m)) if m == "Removed 2 packages"));
1460     }
1461 
1462     #[test]
1463     fn marking_explicit_updates_rows_at_once() {
1464         let mut ui = cce_ui::context::UiContext::new();
1465         let mut st = loaded_state(&mut ui);
1466         update(&mut st, PackagesMessage::SetInstallReason(vec!["beta".into()], true), &mut ui);
1467         assert!(st.busy());
1468         update(&mut st, PackagesMessage::InstallReasonSet(vec!["beta".into()], true, Ok(())), &mut ui);
1469         assert!(!st.busy());
1470         assert!(st.installed[1].explicit && !st.installed[1].orphan);
1471         assert!(matches!(&st.last_action, Some(Ok(m)) if m == "Marked beta as explicitly installed"));
1472     }
1473 
1474     #[test]
1475     fn jump_to_package_clears_the_filter() {
1476         let mut ui = cce_ui::context::UiContext::new();
1477         let mut st = loaded_state(&mut ui);
1478         update(&mut st, PackagesMessage::SetFilter(InstalledFilter::Orphans), &mut ui);
1479         update(&mut st, PackagesMessage::SelectAndScrollPackage("alpha".into()), &mut ui);
1480         assert_eq!(st.filter, InstalledFilter::All);
1481         assert_eq!(st.selected_package.as_deref(), Some("alpha"));
1482     }
1483 
1484     /// Captured from `pacman -Rs --print -- acl` (stdout/stderr as pacman split them).
1485     #[test]
1486     fn dependency_refusal_is_summarized() {
1487         let stderr = "error: failed to prepare transaction (could not satisfy dependencies)\n";
1488         let stdout = "checking dependencies...\n\
1489                       :: removing acl breaks dependency 'acl' required by coreutils\n\
1490                       :: removing acl breaks dependency 'acl' required by cups\n\
1491                       :: removing acl breaks dependency 'libacl.so=1-64' required by cups\n\
1492                       :: removing attr breaks dependency 'attr' required by acl\n";
1493         assert_eq!(
1494             summarize_pacman_failure(stderr, stdout),
1495             "error: failed to prepare transaction (could not satisfy dependencies)\n\
1496              acl is still required by coreutils, cups\n\
1497              attr is still required by acl"
1498         );
1499         // Lines pacman wrote to stderr instead are not duplicated.
1500         let both = format!("{stderr}:: removing acl breaks dependency 'acl' required by cups\n");
1501         assert_eq!(
1502             summarize_pacman_failure(&both, ""),
1503             "error: failed to prepare transaction (could not satisfy dependencies)\nacl is still required by cups"
1504         );
1505     }
1506 
1507     #[test]
1508     fn preview_parse_and_sizes() {
1509         assert_eq!(parse_removal_preview("a 10\nb x\n\n"), vec![("a".to_string(), 10), ("b".to_string(), 0)]);
1510         assert_eq!(human_size(2048), "2 KiB");
1511         assert_eq!(human_size(5 * 1024 * 1024), "5.0 MiB");
1512     }
1513 
1514     #[test]
1515     fn test_view_layout_grid() {
1516         let mut ui = cce_ui::context::UiContext::new();
1517         let mut state = PackagesState::new(&mut ui);
1518         let mut layout = cce_ui::layout::PageFlow::new();
1519         let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false, false], &mut layout, &mut ui);
1520         assert!(!pc.rects.is_empty() || !pc.texts.is_empty() || !pc.buttons.is_empty());
1521     }
1522 }