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