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(¤t_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 }