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

src/pages/packages.rs (33.7K)

  1 use crate::app::{AppAction, PageContent, SectionContextExt, section_divider};
  2 use cce_ui::widget::ScrollRegion;
  3 use cce_ui::layout::{render_widget, PageLayoutBuilder, LayoutStrategy, SectionContext, RenderTarget};
  4 use cce_ui::widget::{WidgetHost, TextBox, InteractiveListItem};
  5 
  6 #[derive(Debug, Clone)]
  7 pub struct PackageInfo {
  8     pub name: String,
  9     pub version: String,
 10 }
 11 
 12 #[derive(Debug, Clone)]
 13 pub struct UpdateInfo {
 14     pub name: String,
 15     pub old_version: String,
 16     pub new_version: String,
 17 }
 18 
 19 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 20 pub enum PackageTab {
 21     Installed,
 22     Updates,
 23 }
 24 
 25 impl Default for PackageTab {
 26     fn default() -> Self {
 27         PackageTab::Installed
 28     }
 29 }
 30 
 31 #[derive(Debug, Clone)]
 32 pub struct PackagesState {
 33     pub loaded: bool,
 34     pub installed: Vec<PackageInfo>,
 35     pub updates: Vec<UpdateInfo>,
 36     pub active_tab: PackageTab,
 37     pub search_box: cce_ui::widget::Adapted<TextBox>,
 38     pub installed_list: ScrollRegion,
 39     pub installed_items: Vec<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>,
 40     pub updates_list: ScrollRegion,
 41     pub updates_items: Vec<cce_ui::widget::Adapted<cce_ui::widget::InteractiveListItem>>,
 42     pub updating: bool,
 43     pub last_update_res: Option<Result<(), String>>,
 44     pub selected_package: Option<String>,
 45     pub selected_package_info: Option<String>,
 46     pub loading_info: bool,
 47     pub uninstalling: bool,
 48 }
 49 
 50 impl Default for PackagesState {
 51     fn default() -> Self {
 52         Self {
 53             loaded: false,
 54             installed: Vec::new(),
 55             updates: Vec::new(),
 56             active_tab: PackageTab::Installed,
 57             search_box: TextBox::new(String::new()).with_placeholder("Filter Packages..."),
 58             installed_list: ScrollRegion::new(32.0, 4.0).with_frame(false),
 59             installed_items: Vec::new(),
 60             updates_list: ScrollRegion::new(32.0, 4.0).with_frame(false),
 61             updates_items: Vec::new(),
 62             updating: false,
 63             last_update_res: None,
 64             selected_package: None,
 65             selected_package_info: None,
 66             loading_info: false,
 67             uninstalling: false,
 68         }
 69     }
 70 }
 71 
 72 #[derive(Debug, Clone)]
 73 pub enum PackagesMessage {
 74     Refreshed(PackagesState),
 75     SetTab(PackageTab),
 76     StartUpdate,
 77     UpdateFinished(Result<(), String>),
 78     SelectPackage(Option<String>),
 79     SelectAndScrollPackage(String),
 80     InfoFetched(String, Result<String, String>),
 81     StartUninstall(String),
 82     UninstallFinished(Result<(), String>),
 83 }
 84 
 85 pub async fn fetch_packages_state() -> PackagesState {
 86     let installed = fetch_installed_packages().await;
 87     let updates = fetch_available_updates().await;
 88     PackagesState {
 89         loaded: true,
 90         installed,
 91         updates,
 92         updating: false,
 93         last_update_res: None,
 94         ..Default::default()
 95     }
 96 }
 97 
 98 async fn fetch_installed_packages() -> Vec<PackageInfo> {
 99     let mut list = Vec::new();
100     if let Ok(output) = tokio::process::Command::new("pacman")
101         .arg("-Q")
102         .output()
103         .await
104     {
105         let stdout = String::from_utf8_lossy(&output.stdout);
106         for line in stdout.lines() {
107             let parts: Vec<&str> = line.split_whitespace().collect();
108             if parts.len() >= 2 {
109                 list.push(PackageInfo {
110                     name: parts[0].to_string(),
111                     version: parts[1].to_string(),
112                 });
113             }
114         }
115     }
116     list.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
117     list
118 }
119 
120 async fn fetch_available_updates() -> Vec<UpdateInfo> {
121     let mut list = Vec::new();
122     if let Ok(output) = tokio::process::Command::new("checkupdates")
123         .output()
124         .await
125     {
126         let stdout = String::from_utf8_lossy(&output.stdout);
127         for line in stdout.lines() {
128             let parts: Vec<&str> = line.split_whitespace().collect();
129             if parts.len() >= 4 && parts[2] == "->" {
130                 list.push(UpdateInfo {
131                     name: parts[0].to_string(),
132                     old_version: parts[1].to_string(),
133                     new_version: parts[3].to_string(),
134                 });
135             }
136         }
137     }
138     list.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
139     list
140 }
141 
142 pub async fn run_update() -> Result<(), String> {
143     let output = tokio::process::Command::new("pkexec")
144         .args(["pacman", "-Syu", "--noconfirm"])
145         .output()
146         .await
147         .map_err(|e| format!("Failed to run update: {}", e))?;
148         
149     if !output.status.success() {
150         let err = String::from_utf8_lossy(&output.stderr).to_string();
151         return Err(format!("Update process failed: {}", err));
152     }
153     
154     Ok(())
155 }
156 
157 pub async fn run_uninstall(name: String) -> Result<(), String> {
158     let output = tokio::process::Command::new("pkexec")
159         .args(["pacman", "-R", "--noconfirm", &name])
160         .output()
161         .await
162         .map_err(|e| format!("Failed to run uninstall: {}", e))?;
163         
164     if !output.status.success() {
165         let err = String::from_utf8_lossy(&output.stderr).to_string();
166         return Err(format!("Uninstall process failed: {}", err));
167     }
168     
169     Ok(())
170 }
171 
172 pub async fn fetch_package_info(name: String, installed: bool) -> Result<String, String> {
173     let arg = if installed { "-Qi" } else { "-Si" };
174     let output = tokio::process::Command::new("pacman")
175         .args([arg, &name])
176         .output()
177         .await
178         .map_err(|e| format!("Failed to run pacman: {}", e))?;
179         
180     if !output.status.success() {
181         let err = String::from_utf8_lossy(&output.stderr).to_string();
182         return Err(format!("Command failed: {}", err));
183     }
184     
185     let mut raw_info = String::from_utf8_lossy(&output.stdout).to_string();
186     if installed {
187         if let Ok(ql_out) = tokio::process::Command::new("pacman")
188             .args(["-Ql", &name])
189             .output()
190             .await
191         {
192             let stdout = String::from_utf8_lossy(&ql_out.stdout);
193             let mut binaries = Vec::new();
194             for line in stdout.lines() {
195                 let parts: Vec<&str> = line.split_whitespace().collect();
196                 if parts.len() >= 2 {
197                     let path = parts[1];
198                     if !path.ends_with('/') && (
199                         path.starts_with("/usr/bin/") || 
200                         path.starts_with("/bin/") || 
201                         path.starts_with("/usr/sbin/") || 
202                         path.starts_with("/sbin/")
203                     ) {
204                         if let Some(filename) = path.split('/').last() {
205                             binaries.push(filename.to_string());
206                         }
207                     }
208                 }
209             }
210             if !binaries.is_empty() {
211                 binaries.sort();
212                 binaries.dedup();
213                 raw_info.push_str(&format!("\nCommands        : {}\n", binaries.join("  ")));
214             }
215         }
216     }
217     Ok(raw_info)
218 }
219 
220 #[derive(Debug, Clone, Default)]
221 pub struct ParsedPackageInfo {
222     pub name: String,
223     pub version: String,
224     pub description: String,
225     pub website: String,
226     pub size: String,
227     pub licenses: String,
228     pub packager: String,
229     pub build_date: String,
230     pub required_by: String,
231     pub commands: String,
232 }
233 
234 pub fn parse_package_info(raw: &str) -> ParsedPackageInfo {
235     let mut current_key = String::new();
236     let mut map = std::collections::HashMap::new();
237 
238     for line in raw.lines() {
239         if line.is_empty() {
240             continue;
241         }
242         if !line.starts_with(' ') {
243             if let Some(pos) = line.find(':') {
244                 let key = line[..pos].trim().to_string();
245                 let val = line[pos + 1..].trim().to_string();
246                 current_key = key.clone();
247                 map.insert(key, val);
248             }
249         } else if !current_key.is_empty() {
250             if let Some(val) = map.get_mut(&current_key) {
251                 val.push(' ');
252                 val.push_str(line.trim());
253             }
254         }
255     }
256 
257     let get_val = |k: &str| map.get(k).cloned().unwrap_or_default();
258 
259     let name = get_val("Name");
260     let version = get_val("Version");
261     let description = get_val("Description");
262     let website = get_val("URL");
263     
264     let mut size = get_val("Installed Size");
265     if size.is_empty() {
266         size = get_val("Download Size");
267     }
268     
269     let licenses = get_val("Licenses");
270     let packager = get_val("Packager");
271     let build_date = get_val("Build Date");
272     let required_by = get_val("Required By");
273     let commands = get_val("Commands");
274 
275     ParsedPackageInfo {
276         name,
277         version,
278         description,
279         website,
280         size,
281         licenses,
282         packager,
283         build_date,
284         required_by,
285         commands,
286     }
287 }
288 
289 pub fn wrap_text(text: &str, max_chars: usize) -> Vec<String> {
290     let mut lines = Vec::new();
291     for paragraph in text.split('\n') {
292         let mut current_line = String::new();
293         for word in paragraph.split_whitespace() {
294             if current_line.is_empty() {
295                 current_line.push_str(word);
296             } else if current_line.len() + 1 + word.len() > max_chars {
297                 lines.push(current_line);
298                 current_line = word.to_string();
299             } else {
300                 current_line.push(' ');
301                 current_line.push_str(word);
302             }
303         }
304         if !current_line.is_empty() {
305             lines.push(current_line);
306         }
307     }
308     lines
309 }
310 
311 const TEXT_FG: [f32; 4] = [0.83, 0.83, 0.83, 1.0];
312 const TEXT_DIM: [f32; 4] = [0.53, 0.53, 0.60, 1.0];
313 const ACCENT: [f32; 4] = [0.36, 0.56, 0.38, 1.0];
314 const TOGGLE_OFF: [f32; 4] = [0.16, 0.16, 0.24, 1.0];
315 const BTN_HOVER: [f32; 4] = [0.25, 0.30, 0.26, 1.0];
316 const RED: [f32; 4] = [0.85, 0.25, 0.25, 1.0];
317 
318 pub fn view(
319     state: &mut PackagesState,
320     cx: f32,
321     cy: f32,
322     cw: f32,
323     ch: f32,
324     sec_focused: &[bool],
325     layout: &mut dyn LayoutStrategy,
326     ctx: &mut cce_ui::context::UiContext,
327 ) -> PageContent {
328     let m = crate::app::section_margin();
329     let mut final_pc = PageContent::new();
330     let sec_w = 260.0f32;
331     let mut builder = PageLayoutBuilder::new(layout, cx, cy, cw, ch, sec_w).with_section_count(1);
332 
333     builder.add_section_spanned(&mut final_pc, "", 1, sec_focused.first().copied().unwrap_or(false), |sec| {
334         let sec_w = sec.cw;
335         if !state.loaded {
336             sec.text("Loading package lists...", 12.0, 0.0, 12.0, TEXT_DIM);
337             return;
338         }
339 
340         // ── Tabs (with counts), filter, compact update row ──
341         let mut stack = sec.vstack(cce_ui::layout::plate_gap());
342         let tab_h = 28.0;
343         let active_bg = [0.20, 0.40, 0.65, 0.4];
344         let inactive_bg = [0.10, 0.10, 0.16, 0.3];
345         let hover_bg = [0.20, 0.20, 0.25, 0.15];
346 
347         let label1 = format!("Installed ({})", state.installed.len());
348         let label2 = format!("Updates ({})", state.updates.len());
349 
350         stack.add_row(2, cce_ui::layout::plate_gap(), tab_h, |ctx, i, x, w| {
351             if i == 0 {
352                 ctx.button(
353                     &label1,
354                     x,
355                     ctx.ay(),
356                     w,
357                     tab_h,
358                     if state.active_tab == PackageTab::Installed { active_bg } else { inactive_bg },
359                     hover_bg,
360                     [0.90, 0.90, 0.95, 1.0],
361                     AppAction::Packages(PackagesMessage::SetTab(PackageTab::Installed)),
362                 );
363             } else {
364                 ctx.button(
365                     &label2,
366                     x,
367                     ctx.ay(),
368                     w,
369                     tab_h,
370                     if state.active_tab == PackageTab::Updates { active_bg } else { inactive_bg },
371                     hover_bg,
372                     [0.90, 0.90, 0.95, 1.0],
373                     AppAction::Packages(PackagesMessage::SetTab(PackageTab::Updates)),
374                 );
375             }
376         });
377 
378         stack.context.spacing(4.0);
379 
380         let search_w = sec_w - 2.0 * m;
381         let search_h = 46.0;
382         state.search_box.set_row_rect(stack.context.left + m, search_w);
383         stack.add_widget(&mut state.search_box, search_w, search_h, ctx);
384         stack.context.spacing(4.0);
385 
386         // Update System: compact button + status text on one row.
387         let status_line = if state.updating {
388             "Updating system...".to_string()
389         } else if state.updates.is_empty() {
390             "System is up to date".to_string()
391         } else {
392             format!("{} updates available", state.updates.len())
393         };
394         let status_color = if state.updating || !state.updates.is_empty() { ACCENT } else { TEXT_DIM };
395         let (btn_lbl, bg, hover) = if state.updating {
396             ("Updating...", [0.15, 0.15, 0.20, 1.0], [0.15, 0.15, 0.20, 1.0])
397         } else {
398             ("Update System", [0.13, 0.18, 0.14, 1.0], [0.25, 0.30, 0.26, 1.0])
399         };
400         stack.add_row(3, cce_ui::layout::plate_gap(), 26.0, |c, i, x, w| {
401             if i == 0 {
402                 c.button(btn_lbl, x, c.ay(), w, 26.0, bg, hover, [0.90, 0.90, 0.95, 1.0],
403                     AppAction::Packages(PackagesMessage::StartUpdate));
404             } else if i == 1 {
405                 let y = c.ay();
406                 c.pc.text(&status_line, x, y + 6.0, 12.0, status_color);
407             }
408         });
409 
410         if let Some(ref res) = state.last_update_res {
411             match res {
412                 Ok(_) => stack.context.text("Last update succeeded", 12.0, 0.0, 12.0, [0.56, 0.83, 0.56, 1.0]),
413                 Err(err) => {
414                     stack.context.text("Last update failed:", 12.0, 0.0, 12.0, RED);
415                     stack.context.text(err, 12.0, 0.0, 11.0, RED);
416                 }
417             }
418         }
419 
420         // ── Selected package details ──
421         if state.loading_info || state.selected_package.is_some() {
422             section_divider(stack.context);
423         }
424         if state.loading_info {
425             stack.context.text("Loading package details...", 12.0, 0.0, 12.0, TEXT_DIM);
426         } else if let Some(pkg_name) = state.selected_package.clone() {
427             if let Some(info_raw) = state.selected_package_info.clone() {
428                 let mut parsed = parse_package_info(&info_raw);
429                 if parsed.name.is_empty() {
430                     parsed.name = pkg_name.clone();
431                 }
432 
433                 // Heading row: name left, Uninstall (compact, quiet-red) right.
434                 {
435                     let sc = &mut *stack.context;
436                     let mut y = sc.content_y;
437                     if y > sc.content_start_y {
438                         y += sc.row_gap;
439                     }
440                     let lx = sc.ax(m);
441                     sc.pc.text(&parsed.name, lx, y, 14.0, [0.35, 0.65, 0.90, 1.0]);
442                     sc.content_y = y + 20.0;
443                     for h in &mut sc.grid.col_heights {
444                         *h = sc.content_y;
445                     }
446                     if state.active_tab == PackageTab::Installed {
447                         let item_w = sc.cw - 2.0 * (sc.padding() + m);
448                         let bw = 100.0;
449                         let bx = lx + item_w - bw;
450                         let (btn_lbl, bg, hover, text_col) = if state.uninstalling {
451                             ("Uninstalling...", TOGGLE_OFF, TOGGLE_OFF, TEXT_DIM)
452                         } else {
453                             ("Uninstall", [0.25, 0.14, 0.14, 1.0], [0.40, 0.20, 0.20, 1.0], [0.95, 0.55, 0.55, 1.0])
454                         };
455                         sc.button(btn_lbl, bx, y - 2.0, bw, 22.0, bg, hover, text_col,
456                             AppAction::Packages(PackagesMessage::StartUninstall(pkg_name.clone())));
457                     }
458                 }
459 
460                 // Same-line kv rows, values wrapping at the value column.
461                 let kv_wrap = |sc: &mut SectionContext<'_, PageContent>, key: &str, val: &str| {
462                     if val.is_empty() {
463                         return;
464                     }
465                     let mut y = sc.content_y + 4.0;
466                     let lx = sc.ax(m);
467                     let vx = lx + 118.0;
468                     let usable_w = sc.cw - 118.0 - 2.0 * (sc.padding() + m);
469                     let max_chars = ((usable_w / 6.0) as usize).max(15);
470                     sc.pc.text(key, lx, y, 11.0, TEXT_DIM);
471                     let lines = wrap_text(val, max_chars);
472                     for line in &lines {
473                         sc.pc.text(line, vx, y, 11.0, TEXT_FG);
474                         y += 15.0;
475                     }
476                     if lines.is_empty() {
477                         y += 15.0;
478                     }
479                     sc.content_y = y + 1.0;
480                     for h in &mut sc.grid.col_heights {
481                         *h = sc.content_y;
482                     }
483                 };
484 
485                 kv_wrap(stack.context, "Version", &parsed.version);
486                 kv_wrap(stack.context, "Size", &parsed.size);
487                 kv_wrap(stack.context, "Licenses", &parsed.licenses);
488                 kv_wrap(stack.context, "Website", &parsed.website);
489                 kv_wrap(stack.context, "Packager", &parsed.packager);
490                 kv_wrap(stack.context, "Built", &parsed.build_date);
491                 kv_wrap(stack.context, "Description", &parsed.description);
492                 kv_wrap(stack.context, "Commands", &parsed.commands);
493 
494                 if !parsed.required_by.is_empty() && parsed.required_by != "None" {
495                     stack.context.text("Required By", 12.0, 0.0, 11.0, TEXT_DIM);
496 
497                     let reqs: Vec<&str> = parsed.required_by.split_whitespace().collect();
498                     let cols_count = 4;
499                     let gap = 4.0;
500                     let btn_h = 20.0;
501 
502                     for chunk in reqs.chunks(cols_count) {
503                         let btn_y = stack.context.ay();
504                         let cols = stack.context.row_layout(cols_count, gap);
505                         for (i, &pkg) in chunk.iter().enumerate() {
506                             if let Some(&(x, w)) = cols.get(i) {
507                                 let action = AppAction::Packages(PackagesMessage::SelectAndScrollPackage(pkg.to_string()));
508                                 stack.context.button(pkg, x, btn_y, w, btn_h, TOGGLE_OFF, BTN_HOVER, TEXT_FG, action);
509                             }
510                         }
511                     }
512                 }
513             } else {
514                 stack.context.text("No details available.", 12.0, 0.0, 12.0, TEXT_DIM);
515             }
516         }
517 
518         section_divider(stack.context);
519 
520         // ── List fills the rest of the page ──
521         let list_box_x = sec.left + m;
522         let list_box_y = sec.ay();
523         let list_box_w = sec_w - 2.0 * m;
524         let list_box_h = ((cy + ch) - m - list_box_y).max(120.0);
525 
526         let query = if state.search_box.editing {
527             state.search_box.edit_buffer.to_lowercase()
528         } else {
529             state.search_box.text.to_lowercase()
530         };
531 
532         match state.active_tab {
533             PackageTab::Installed => {
534                 let filtered: Vec<&PackageInfo> = state.installed.iter()
535                     .filter(|p| p.name.to_lowercase().contains(&query) || p.version.to_lowercase().contains(&query))
536                     .collect();
537 
538                 // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
539                 state.installed_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
540                 state.installed_list.update_bounds(filtered.len(), list_box_y, list_box_h);
541                 state.installed_list.push_prims(sec.pc);
542                 let item_h = state.installed_list.item_height;
543 
544                 if state.installed_items.len() != filtered.len() {
545                     state.installed_items.clear();
546                     for _ in 0..filtered.len() {
547                         state.installed_items.push(InteractiveListItem::new(""));
548                     }
549                 }
550 
551                 sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
552                 for (idx, pkg) in filtered.iter().enumerate() {
553                     if let Some(draw_y) = state.installed_list.get_item_draw_y(idx, 4.0) {
554                         // Rows dispatch as extra roots (the dissolved list is no parent).
555                         let item = &mut state.installed_items[idx];
556                         item.title = pkg.name.clone();
557                         item.subtitle = Some(format!("Version: {}", pkg.version));
558                         item.selected = Some(&pkg.name) == state.selected_package.as_ref();
559                         render_widget(sec.pc, item, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
560                     }
561                 }
562                 sec.pc.pop_clip_rect();
563 
564                 if filtered.is_empty() {
565                     sec.pc.text("No packages match the query", list_box_x + 16.0, list_box_y + 16.0, 12.0, TEXT_DIM);
566                 }
567             }
568             PackageTab::Updates => {
569                 let filtered: Vec<&UpdateInfo> = state.updates.iter()
570                     .filter(|p| p.name.to_lowercase().contains(&query))
571                     .collect();
572 
573                 // Dissolved List (Phase 6v): scroll state + frame prims are app-owned.
574                 state.updates_list.set_rect(list_box_x, list_box_y, list_box_w, list_box_h);
575                 state.updates_list.update_bounds(filtered.len(), list_box_y, list_box_h);
576                 state.updates_list.push_prims(sec.pc);
577                 let item_h = state.updates_list.item_height;
578 
579                 if state.updates_items.len() != filtered.len() {
580                     state.updates_items.clear();
581                     for _ in 0..filtered.len() {
582                         state.updates_items.push(InteractiveListItem::new(""));
583                     }
584                 }
585 
586                 sec.pc.push_clip_rect(list_box_x, list_box_y, list_box_w, list_box_h);
587                 for (idx, pkg) in filtered.iter().enumerate() {
588                     if let Some(draw_y) = state.updates_list.get_item_draw_y(idx, 4.0) {
589                         // Rows dispatch as extra roots (the dissolved list is no parent).
590                         let item = &mut state.updates_items[idx];
591                         item.title = pkg.name.clone();
592                         item.subtitle = Some(format!("{}  ->  {}", pkg.old_version, pkg.new_version));
593                         item.selected = Some(&pkg.name) == state.selected_package.as_ref();
594                         render_widget(sec.pc, item, list_box_x + 24.0, draw_y, list_box_w - 44.0, item_h, ctx);
595                     }
596                 }
597                 sec.pc.pop_clip_rect();
598 
599                 if filtered.is_empty() {
600                     sec.pc.text("No updates match the query", list_box_x + 16.0, list_box_y + 16.0, 12.0, TEXT_DIM);
601                 }
602             }
603         }
604 
605         // End the section so the well's bottom wall sits one margin below
606         // the list: finish() places the wall at content_y + padding + margin,
607         // so the list's own bottom margin and that one cancel.
608         sec.content_y = list_box_y + list_box_h - sec.padding();
609     });
610 
611     final_pc
612 }
613 
614 pub fn update(state: &mut PackagesState, msg: PackagesMessage) {
615     match msg {
616         PackagesMessage::Refreshed(new) => {
617             state.loaded = new.loaded;
618             state.installed = new.installed;
619             state.updates = new.updates;
620             // Selection, fetched info, and in-flight update/uninstall flags are
621             // LOCAL state — the periodic background refresh must not clear them.
622             // Only drop a selection whose package no longer exists anywhere.
623             if let Some(sel) = state.selected_package.clone() {
624                 let still_exists = state.installed.iter().any(|p| p.name == sel)
625                     || state.updates.iter().any(|u| u.name == sel);
626                 if !still_exists {
627                     state.selected_package = None;
628                     state.selected_package_info = None;
629                     state.loading_info = false;
630                 }
631             }
632         }
633         PackagesMessage::SetTab(tab) => {
634             state.active_tab = tab;
635             state.installed_list.set_scroll_y(0.0);
636             state.updates_list.set_scroll_y(0.0);
637             state.installed_items.clear();
638             state.updates_items.clear();
639             state.selected_package = None;
640             state.selected_package_info = None;
641             state.loading_info = false;
642         }
643         PackagesMessage::StartUpdate => {
644             state.updating = true;
645             state.last_update_res = None;
646         }
647         PackagesMessage::UpdateFinished(res) => {
648             state.updating = false;
649             state.last_update_res = Some(res);
650         }
651         PackagesMessage::SelectPackage(name) => {
652             if name != state.selected_package {
653                 state.selected_package = name;
654                 state.selected_package_info = None;
655                 state.loading_info = state.selected_package.is_some();
656             }
657         }
658         PackagesMessage::InfoFetched(name, res) => {
659             if state.selected_package.as_ref() == Some(&name) {
660                 state.loading_info = false;
661                 match res {
662                     Ok(info) => {
663                         state.selected_package_info = Some(info);
664                     }
665                     Err(err) => {
666                         state.selected_package_info = Some(format!("Error loading package info: {}", err));
667                     }
668                 }
669             }
670         }
671         PackagesMessage::StartUninstall(_name) => {
672             state.uninstalling = true;
673         }
674         PackagesMessage::UninstallFinished(res) => {
675             state.uninstalling = false;
676             match res {
677                 Ok(_) => {
678                     state.selected_package = None;
679                     state.selected_package_info = None;
680                 }
681                 Err(err) => {
682                     state.selected_package_info = Some(format!("Uninstall failed: {}", err));
683                 }
684             }
685         }
686         PackagesMessage::SelectAndScrollPackage(name) => {
687             state.select_and_scroll_to(&name);
688         }
689     }
690 }
691 
692 impl PackagesState {
693     pub fn select_and_scroll_to(&mut self, pkg_name: &str) {
694         self.active_tab = PackageTab::Installed;
695         self.search_box.text.clear();
696         self.search_box.edit_buffer.clear();
697         self.search_box.editing = false;
698         self.selected_package = Some(pkg_name.to_string());
699         self.selected_package_info = None;
700         self.loading_info = true;
701         if let Some(idx) = self.installed.iter().position(|p| p.name == pkg_name) {
702             let item_height_full = self.installed_list.item_height + self.installed_list.item_gap;
703             let target_y = idx as f32 * item_height_full - 164.0;
704             self.installed_list.set_scroll_y(target_y);
705         }
706     }
707 }
708 
709 impl PackagesState {
710     /// The active tab's dissolved list region (only one is laid out per frame).
711     fn active_list(&mut self) -> &mut ScrollRegion {
712         match self.active_tab {
713             PackageTab::Installed => &mut self.installed_list,
714             PackageTab::Updates => &mut self.updates_list,
715         }
716     }
717 }
718 
719 impl crate::pages::AppPage for PackagesState {
720     // Sections: [the one well]
721     fn section_widgets(&mut self) -> Vec<Vec<cce_ui::widget::WidgetId>> {
722         // Mirrors the view's `!loaded` early return: the search box is only painted
723         // (and so only registered) once `pacman -Q` + `checkupdates` land, which is
724         // the longest load window of any page. The group count stays 1 either way —
725         // an empty outer Vec would kill the ctrl-nav entry point.
726         if self.loaded {
727             vec![vec![self.search_box.id()]]
728         } else {
729             vec![Vec::new()]
730         }
731     }
732 
733     fn view(
734         &mut self,
735         cx: f32,
736         cy: f32,
737         cw: f32,
738         ch: f32,
739         _root_focused: bool,
740         sec_focused: &[bool],
741         layout: &mut dyn LayoutStrategy,
742         ctx: &mut cce_ui::context::UiContext,
743     ) -> crate::app::PageContent {
744         view(self, cx, cy, cw, ch, sec_focused, layout, ctx)
745     }
746 
747     fn propagate_widget_changes(&mut self, actions: &mut Vec<crate::app::AppAction>) {
748         let query = if self.search_box.editing {
749             self.search_box.edit_buffer.to_lowercase()
750         } else {
751             self.search_box.text.to_lowercase()
752         };
753 
754         match self.active_tab {
755             PackageTab::Installed => {
756                 let filtered: Vec<&PackageInfo> = self.installed.iter()
757                     .filter(|p| p.name.to_lowercase().contains(&query) || p.version.to_lowercase().contains(&query))
758                     .collect();
759                 for (idx, item) in self.installed_items.iter_mut().enumerate() {
760                     if item.just_clicked {
761                         item.just_clicked = false;
762                         if idx < filtered.len() {
763                             let pkg = filtered[idx];
764                             actions.push(AppAction::Packages(PackagesMessage::SelectPackage(Some(pkg.name.clone()))));
765                         }
766                     }
767                 }
768             }
769             PackageTab::Updates => {
770                 let filtered: Vec<&UpdateInfo> = self.updates.iter()
771                     .filter(|p| p.name.to_lowercase().contains(&query))
772                     .collect();
773                 for (idx, item) in self.updates_items.iter_mut().enumerate() {
774                     if item.just_clicked {
775                         item.just_clicked = false;
776                         if idx < filtered.len() {
777                             let pkg = filtered[idx];
778                             actions.push(AppAction::Packages(PackagesMessage::SelectPackage(Some(pkg.name.clone()))));
779                         }
780                     }
781                 }
782             }
783         }
784     }
785 
786     // Both root methods below filter by `get_item_draw_y`, the SAME predicate the view's
787     // paint loop uses to virtualize rows. Only a row that was drawn had `render_widget`
788     // refresh its rect; a scrolled-out row keeps the rect from the last frame it was
789     // visible, and since `dispatch_page_event` takes the first root that hit-tests true
790     // in index order, an unfiltered list let a stale low-index row swallow clicks meant
791     // for the row actually on screen (click "fontforge", select "appstream").
792     fn extra_dispatch_roots(&mut self) -> Vec<cce_ui::widget::WidgetId> {
793         let (list, items) = match self.active_tab {
794             PackageTab::Installed => (&self.installed_list, &self.installed_items),
795             PackageTab::Updates => (&self.updates_list, &self.updates_items),
796         };
797         items
798             .iter()
799             .enumerate()
800             .filter(|(idx, _)| list.get_item_draw_y(*idx, 4.0).is_some())
801             .map(|(_, i)| i.id())
802             .collect()
803     }
804 
805     fn register_extra_dispatch_roots(&mut self, ctx: &mut cce_ui::context::UiContext) {
806         let (list, items) = match self.active_tab {
807             PackageTab::Installed => (&self.installed_list, &mut self.installed_items),
808             PackageTab::Updates => (&self.updates_list, &mut self.updates_items),
809         };
810         for (idx, i) in items.iter_mut().enumerate() {
811             if list.get_item_draw_y(idx, 4.0).is_none() {
812                 continue;
813             }
814             let (id, ptr) = (i.id(), i.as_ptr_mut());
815             ctx.register_widget(id, ptr);
816         }
817     }
818 
819     fn handle_pointer_move(
820         &mut self,
821         lx: f32,
822         ly: f32,
823         _actions: &mut Vec<crate::app::AppAction>,
824         _ctx: &mut cce_ui::context::UiContext,
825     ) -> bool {
826         self.loaded && self.active_list().cursor_moved(lx, ly)
827     }
828 
829     fn handle_pointer_down(&mut self, lx: f32, ly: f32, _ctx: &mut cce_ui::context::UiContext) -> bool {
830         self.loaded && self.active_list().press(lx, ly)
831     }
832 
833     fn handle_pointer_up(&mut self, _ctx: &mut cce_ui::context::UiContext) -> bool {
834         self.active_list().release()
835     }
836 
837     fn handle_mouse_wheel(&mut self, delta: &cce_ui::widget::MouseScrollDelta, lx: f32, ly: f32) -> bool {
838         self.loaded && self.active_list().wheel(delta, lx, ly)
839     }
840 
841     fn handle_key_input(&mut self, event: &cce_ui::widget::KeyEvent) -> bool {
842         self.loaded && self.active_list().keyboard(event)
843     }
844 
845     fn tick(&mut self, dt: f32) -> bool {
846         self.installed_list.tick(dt) | self.updates_list.tick(dt)
847     }
848 }
849 
850 #[cfg(test)]
851 mod tests {
852     use super::*;
853 
854     #[test]
855     fn refresh_preserves_selection_and_flags() {
856         let mut st = PackagesState::default();
857         st.loaded = true;
858         st.installed = vec![PackageInfo { name: "foo".into(), version: "1".into() }];
859         st.selected_package = Some("foo".into());
860         st.selected_package_info = Some("info".into());
861         st.updating = true;
862 
863         let fresh = PackagesState {
864             loaded: true,
865             installed: vec![PackageInfo { name: "foo".into(), version: "2".into() }],
866             ..Default::default()
867         };
868         update(&mut st, PackagesMessage::Refreshed(fresh));
869         assert_eq!(st.selected_package.as_deref(), Some("foo"), "refresh must keep the selection");
870         assert!(st.selected_package_info.is_some(), "refresh must keep fetched info");
871         assert!(st.updating, "refresh must not clear the in-flight update flag");
872 
873         // A package that vanished from both lists does clear the selection.
874         let fresh2 = PackagesState { loaded: true, ..Default::default() };
875         update(&mut st, PackagesMessage::Refreshed(fresh2));
876         assert!(st.selected_package.is_none());
877         assert!(st.selected_package_info.is_none());
878     }
879 
880     #[test]
881     fn test_view_layout_grid() {
882         let mut state = PackagesState::default();
883         let mut layout = cce_ui::layout::ColumnLayout::new(20.0);
884         let pc = view(&mut state, 10.0, 20.0, 800.0, 600.0, &[false, false], &mut layout, &mut cce_ui::context::UiContext::new());
885         assert!(!pc.rects.is_empty() || !pc.texts.is_empty() || !pc.buttons.is_empty());
886     }
887 }