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

commitf0b43e9072d6211278cfd667563280a1dfab4a47
parent96d3f299d5
authorLucas Galante <[email protected]>
date2026-08-12 15:41
fix: package selection survives background refresh; compact details zone

The periodic Refreshed message carried a Default-built state and the
update arm copied selection/info/updating/uninstalling from it — wiping
the user's selection (and in-flight flags) every refresh cycle. Refresh
now only replaces the package lists, clearing the selection solely when
its package vanished from both lists (regression-tested).

The details zone drops from two lines per field to one: heading row
with the Uninstall button inline at the right, same-line key/value rows
with values wrapping at the value column, tighter Required-By chips
(4-up, 20px).

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/pages/packages.rs | 153 ++++++++++++++++++++++++++++++++------------------
 1 file changed, 97 insertions(+), 56 deletions(-)

diff --git a/src/pages/packages.rs b/src/pages/packages.rs
index 8f55503..8886e3b 100644
--- a/src/pages/packages.rs
+++ b/src/pages/packages.rs
@@ -431,47 +431,74 @@ pub fn view(
                     parsed.name = pkg_name.clone();
                 }
 
-                stack.context.text(&parsed.name, 12.0, 0.0, 14.0, [0.35, 0.65, 0.90, 1.0]);
-
-                let render_detail = |sub: &mut SectionContext<'_, PageContent>, key: &str, val: &str| {
-                    sub.text(key, 12.0, 0.0, 11.0, TEXT_DIM);
-                    let val_start_x = 130.0f32;
-                    let usable_w = sub.cw - val_start_x - 12.0;
-                    let char_w = 6.0f32;
-                    let max_chars = (usable_w / char_w).max(15.0) as usize;
+                // Heading row: name left, Uninstall (compact, quiet-red) right.
+                {
+                    let sc = &mut *stack.context;
+                    let mut y = sc.content_y;
+                    if y > sc.content_start_y {
+                        y += sc.row_gap;
+                    }
+                    let lx = sc.ax(12.0);
+                    sc.pc.text(&parsed.name, lx, y, 14.0, [0.35, 0.65, 0.90, 1.0]);
+                    sc.content_y = y + 20.0;
+                    for h in &mut sc.grid.col_heights {
+                        *h = sc.content_y;
+                    }
+                    if state.active_tab == PackageTab::Installed {
+                        let item_w = sc.cw - 2.0 * (sc.padding() + 12.0);
+                        let bw = 100.0;
+                        let bx = lx + item_w - bw;
+                        let (btn_lbl, bg, hover, text_col) = if state.uninstalling {
+                            ("Uninstalling...", TOGGLE_OFF, TOGGLE_OFF, TEXT_DIM)
+                        } else {
+                            ("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])
+                        };
+                        sc.button(btn_lbl, bx, y - 2.0, bw, 22.0, bg, hover, text_col,
+                            AppAction::Packages(PackagesMessage::StartUninstall(pkg_name.clone())));
+                    }
+                }
 
+                // Same-line kv rows, values wrapping at the value column.
+                let kv_wrap = |sc: &mut SectionContext<'_, PageContent>, key: &str, val: &str| {
+                    if val.is_empty() {
+                        return;
+                    }
+                    let mut y = sc.content_y + 4.0;
+                    let lx = sc.ax(12.0);
+                    let vx = lx + 118.0;
+                    let usable_w = sc.cw - 118.0 - 2.0 * (sc.padding() + 12.0);
+                    let max_chars = ((usable_w / 6.0) as usize).max(15);
+                    sc.pc.text(key, lx, y, 11.0, TEXT_DIM);
                     let lines = wrap_text(val, max_chars);
                     for line in &lines {
-                        sub.text(line, val_start_x, 0.0, 11.0, TEXT_FG);
+                        sc.pc.text(line, vx, y, 11.0, TEXT_FG);
+                        y += 15.0;
+                    }
+                    if lines.is_empty() {
+                        y += 15.0;
+                    }
+                    sc.content_y = y + 1.0;
+                    for h in &mut sc.grid.col_heights {
+                        *h = sc.content_y;
                     }
                 };
 
-                render_detail(stack.context, "Version:", &parsed.version);
-                if !parsed.size.is_empty() {
-                    render_detail(stack.context, "Size:", &parsed.size);
-                }
-                if !parsed.licenses.is_empty() {
-                    render_detail(stack.context, "Licenses:", &parsed.licenses);
-                }
-                if !parsed.website.is_empty() {
-                    render_detail(stack.context, "Website:", &parsed.website);
-                }
-                if !parsed.packager.is_empty() {
-                    render_detail(stack.context, "Packager:", &parsed.packager);
-                }
-                if !parsed.build_date.is_empty() {
-                    render_detail(stack.context, "Build Date:", &parsed.build_date);
-                }
-                if !parsed.description.is_empty() {
-                    render_detail(stack.context, "Description:", &parsed.description);
-                }
+                kv_wrap(stack.context, "Version", &parsed.version);
+                kv_wrap(stack.context, "Size", &parsed.size);
+                kv_wrap(stack.context, "Licenses", &parsed.licenses);
+                kv_wrap(stack.context, "Website", &parsed.website);
+                kv_wrap(stack.context, "Packager", &parsed.packager);
+                kv_wrap(stack.context, "Built", &parsed.build_date);
+                kv_wrap(stack.context, "Description", &parsed.description);
+                kv_wrap(stack.context, "Commands", &parsed.commands);
+
                 if !parsed.required_by.is_empty() && parsed.required_by != "None" {
-                    stack.context.text("Required By:", 12.0, 0.0, 11.0, TEXT_DIM);
+                    stack.context.text("Required By", 12.0, 0.0, 11.0, TEXT_DIM);
 
                     let reqs: Vec<&str> = parsed.required_by.split_whitespace().collect();
-                    let cols_count = 3;
-                    let gap = 6.0;
-                    let btn_h = 24.0;
+                    let cols_count = 4;
+                    let gap = 4.0;
+                    let btn_h = 20.0;
 
                     for chunk in reqs.chunks(cols_count) {
                         let btn_y = stack.context.ay();
@@ -484,24 +511,6 @@ pub fn view(
                         }
                     }
                 }
-                if !parsed.commands.is_empty() {
-                    render_detail(stack.context, "Commands:", &parsed.commands);
-                }
-
-                if state.active_tab == PackageTab::Installed {
-                    stack.context.spacing(4.0);
-                    let (btn_lbl, bg, hover, text_col) = if state.uninstalling {
-                        ("Uninstalling...", TOGGLE_OFF, TOGGLE_OFF, TEXT_DIM)
-                    } else {
-                        ("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])
-                    };
-                    stack.add_row(3, 8.0, 26.0, |c, i, x, w| {
-                        if i == 0 {
-                            c.button(btn_lbl, x, c.ay(), w, 26.0, bg, hover, text_col,
-                                AppAction::Packages(PackagesMessage::StartUninstall(pkg_name.clone())));
-                        }
-                    });
-                }
             } else {
                 stack.context.text("No details available.", 12.0, 0.0, 12.0, TEXT_DIM);
             }
@@ -607,12 +616,18 @@ pub fn update(state: &mut PackagesState, msg: PackagesMessage) {
             state.loaded = new.loaded;
             state.installed = new.installed;
             state.updates = new.updates;
-            state.updating = new.updating;
-            state.last_update_res = new.last_update_res;
-            state.selected_package = new.selected_package;
-            state.selected_package_info = new.selected_package_info;
-            state.loading_info = new.loading_info;
-            state.uninstalling = new.uninstalling;
+            // Selection, fetched info, and in-flight update/uninstall flags are
+            // LOCAL state — the periodic background refresh must not clear them.
+            // Only drop a selection whose package no longer exists anywhere.
+            if let Some(sel) = state.selected_package.clone() {
+                let still_exists = state.installed.iter().any(|p| p.name == sel)
+                    || state.updates.iter().any(|u| u.name == sel);
+                if !still_exists {
+                    state.selected_package = None;
+                    state.selected_package_info = None;
+                    state.loading_info = false;
+                }
+            }
         }
         PackagesMessage::SetTab(tab) => {
             state.active_tab = tab;
@@ -808,6 +823,32 @@ impl crate::pages::AppPage for PackagesState {
 mod tests {
     use super::*;
 
+    #[test]
+    fn refresh_preserves_selection_and_flags() {
+        let mut st = PackagesState::default();
+        st.loaded = true;
+        st.installed = vec![PackageInfo { name: "foo".into(), version: "1".into() }];
+        st.selected_package = Some("foo".into());
+        st.selected_package_info = Some("info".into());
+        st.updating = true;
+
+        let fresh = PackagesState {
+            loaded: true,
+            installed: vec![PackageInfo { name: "foo".into(), version: "2".into() }],
+            ..Default::default()
+        };
+        update(&mut st, PackagesMessage::Refreshed(fresh));
+        assert_eq!(st.selected_package.as_deref(), Some("foo"), "refresh must keep the selection");
+        assert!(st.selected_package_info.is_some(), "refresh must keep fetched info");
+        assert!(st.updating, "refresh must not clear the in-flight update flag");
+
+        // A package that vanished from both lists does clear the selection.
+        let fresh2 = PackagesState { loaded: true, ..Default::default() };
+        update(&mut st, PackagesMessage::Refreshed(fresh2));
+        assert!(st.selected_package.is_none());
+        assert!(st.selected_package_info.is_none());
+    }
+
     #[test]
     fn test_view_layout_grid() {
         let mut state = PackagesState::default();