git.lucas.co / cce-secrets
secrets manager
git clone https://git.lucas.co/cce-secrets.git

src/main.rs (53.1K)

   1 use secret_service::{EncryptionType, SecretService};
   2 use wayland_client::QueueHandle;
   3 
   4 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
   5 use cce_ui::widget::{
   6     Bounds, Button, ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey,
   7     ScrollMotion, TextBox, WidgetHost, LINE_PX,
   8 };
   9 
  10 const LIST_W: f32 = 280.0;
  11 const ROW_H: f32 = 44.0;
  12 const SEARCH_H: f32 = 30.0;
  13 const STATUS_H: f32 = 30.0;
  14 const BTN_W: f32 = 90.0;
  15 const BTN_H: f32 = 28.0;
  16 const CLIPBOARD_CLEAR_SECS: u64 = 30;
  17 
  18 /// Entry fields written back as Secret Service attributes (cce-keyring-sync
  19 /// mirrors them to 1Password's username / url / notes; Title is the label).
  20 const EDIT_ATTRS: [&str; 3] = ["UserName", "URL", "Notes"];
  21 
  22 /// One Secret Service item, sans secret: the secret itself is fetched on
  23 /// demand by object path (reveal/copy) and never held in the list.
  24 #[derive(Clone, Debug)]
  25 struct EntryData {
  26     path: String,
  27     label: String,
  28     collection: String,
  29     attrs: Vec<(String, String)>,
  30 }
  31 
  32 impl EntryData {
  33     /// The dim second line of a list row: a username-ish attribute if present.
  34     fn hint(&self) -> Option<&str> {
  35         self.attrs
  36             .iter()
  37             .find(|(k, _)| k.eq_ignore_ascii_case("username") || k.eq_ignore_ascii_case("user"))
  38             .or_else(|| self.attrs.first())
  39             .map(|(_, v)| v.as_str())
  40             .filter(|v| !v.is_empty())
  41     }
  42 
  43     fn attr(&self, key: &str) -> &str {
  44         self.attrs
  45             .iter()
  46             .find(|(k, _)| k == key)
  47             .map(|(_, v)| v.as_str())
  48             .unwrap_or("")
  49     }
  50 }
  51 
  52 #[derive(Clone, Copy, Debug, PartialEq)]
  53 enum Purpose {
  54     Copy,
  55     Reveal,
  56 }
  57 
  58 #[derive(Clone)]
  59 enum Cmd {
  60     Reload,
  61     /// Ask cce-keyring-sync for a pass and reload — the resident daemon
  62     /// when it runs, the one-shot binary otherwise (see `run_sync`).
  63     Sync,
  64     GetSecret { path: String, purpose: Purpose },
  65     CreateItem { label: String, attrs: Vec<(String, String)>, secret: String },
  66     UpdateItem { path: String, label: String, attrs: Vec<(String, String)>, secret: Option<String> },
  67     DeleteItem { path: String },
  68 }
  69 
  70 #[derive(Clone, Debug)]
  71 enum AppMessage {
  72     Loaded(Vec<EntryData>),
  73     Status(String, bool),
  74     /// A transient progress line ("Loading entries…"): shown unless a sync
  75     /// is in flight, and never the end of one.
  76     Progress(String),
  77     Revealed { path: String, secret: String },
  78     SelectPath(String),
  79     RefreshClicked,
  80     SyncClicked,
  81     RevealClicked,
  82     CopyClicked,
  83     NewClicked,
  84     EditClicked,
  85     DeleteClicked,
  86     SaveClicked,
  87     CancelClicked,
  88 }
  89 
  90 /// What the detail pane shows: the read-only entry view, or the entry form
  91 /// (`path: None` = creating a new entry).
  92 enum Mode {
  93     Browse,
  94     Edit { path: Option<String> },
  95 }
  96 
  97 /// cce-keyring-sync's state file: `last_run` (unix seconds) and the last
  98 /// run's one-line outcome. The daemon's only channel back to this UI.
  99 fn sync_state_path() -> std::path::PathBuf {
 100     std::env::var("XDG_STATE_HOME")
 101         .ok()
 102         .filter(|s| !s.is_empty())
 103         .map(std::path::PathBuf::from)
 104         .unwrap_or_else(|| {
 105             std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".local/state")
 106         })
 107         .join("cce/keyring-sync/state.json")
 108 }
 109 
 110 fn read_sync_state() -> Option<(i64, String)> {
 111     let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(sync_state_path()).ok()?).ok()?;
 112     let last_run = v.get("last_run")?.as_i64()?;
 113     let last_result = v.get("last_result").and_then(|r| r.as_str()).unwrap_or("").to_string();
 114     Some((last_run, last_result))
 115 }
 116 
 117 /// Ask the resident daemon for a pass now. Its `op` authorization is the
 118 /// live one (KEYRING-SYNC.md, phase 0), so this raises no dialog; a
 119 /// one-shot `cce-keyring-sync sync` from here would. Fire-and-forget: a
 120 /// save does not wait for the mirror. False when no daemon is running.
 121 async fn poke_sync_daemon() -> bool {
 122     let active = tokio::process::Command::new("systemctl")
 123         .args(["--user", "is-active", "--quiet", "cce-keyring-sync.service"])
 124         .status()
 125         .await
 126         .map(|s| s.success())
 127         .unwrap_or(false);
 128     if !active {
 129         return false;
 130     }
 131     tokio::process::Command::new("systemctl")
 132         .args(["--user", "kill", "-s", "SIGUSR1", "cce-keyring-sync.service"])
 133         .status()
 134         .await
 135         .map(|s| s.success())
 136         .unwrap_or(false)
 137 }
 138 
 139 /// The Sync button. With the daemon up: poke it and wait for its state
 140 /// file to record a new run, then show that run's outcome. Without it: the
 141 /// one-shot binary, whose summary line ("synced: …", "in sync") or
 142 /// refusal text is the status.
 143 /// Returns the status line (text, is_error). The caller shows it *after*
 144 /// the reload that follows a sync, or the reload's "N entries" would wipe
 145 /// it a frame later.
 146 async fn run_sync() -> (String, bool) {
 147     let before = read_sync_state().map(|(t, _)| t).unwrap_or(0);
 148     if poke_sync_daemon().await {
 149         // A pass is one `op item list` plus writes; a dialog nobody
 150         // answers holds it 60 s. Wait a little past that.
 151         for _ in 0..180 {
 152             tokio::time::sleep(std::time::Duration::from_millis(500)).await;
 153             if let Some((t, result)) = read_sync_state() {
 154                 if t > before {
 155                     let is_error = result.starts_with("failed");
 156                     let msg = if result.is_empty() { "synced".to_string() } else { result };
 157                     return (msg, is_error);
 158                 }
 159             }
 160         }
 161         return ("sync daemon did not report within 90s".to_string(), true);
 162     }
 163     let out = tokio::process::Command::new("cce-keyring-sync")
 164         .arg("sync")
 165         .output()
 166         .await;
 167     match out {
 168         Ok(out) => {
 169             let pick = |bytes: &[u8]| {
 170                 String::from_utf8_lossy(bytes)
 171                     .lines()
 172                     .rev()
 173                     .find(|l| !l.trim().is_empty())
 174                     .unwrap_or("")
 175                     .to_string()
 176             };
 177             if out.status.success() {
 178                 let line = pick(&out.stdout);
 179                 let msg = if line.is_empty() { "synced".to_string() } else { line };
 180                 return (msg, false);
 181             } else {
 182                 let line = pick(&out.stderr);
 183                 let msg = if line.is_empty() { "sync failed".to_string() } else { line };
 184                 return (msg, true);
 185             }
 186         }
 187         Err(e) => {
 188             return (format!("cce-keyring-sync not runnable: {e}"), true);
 189         }
 190     }
 191 }
 192 
 193 // ── Secret Service worker ─────────────────────────────────────────────────
 194 //
 195 // The D-Bus session lives on its own thread (single-thread tokio runtime,
 196 // notifier pattern): the UI sends commands over an mpsc, results come back
 197 // through the calloop channel into update(). Copied secrets go straight to
 198 // the clipboard from here — only revealed ones cross to the UI at all.
 199 
 200 fn spawn_worker(rx: std::sync::mpsc::Receiver<Cmd>, tx: calloop::channel::Sender<AppMessage>) {
 201     std::thread::spawn(move || {
 202         let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
 203             Ok(rt) => rt,
 204             Err(e) => {
 205                 let _ = tx.send(AppMessage::Status(format!("tokio runtime failed: {e}"), true));
 206                 return;
 207             }
 208         };
 209         rt.block_on(async move {
 210             let mut ss = match SecretService::connect(EncryptionType::Dh).await {
 211                 Ok(ss) => ss,
 212                 Err(e) => {
 213                     let _ = tx.send(AppMessage::Status(
 214                         format!("Secret Service unavailable: {e} — is gnome-keyring running?"),
 215                         true,
 216                     ));
 217                     return;
 218                 }
 219             };
 220             load_entries(&ss, &tx).await;
 221             while let Ok(cmd) = rx.recv() {
 222                 match cmd {
 223                     Cmd::Reload => load_entries(&ss, &tx).await,
 224                     Cmd::Sync => {
 225                         let (msg, is_error) = run_sync().await;
 226                         load_entries(&ss, &tx).await;
 227                         let _ = tx.send(AppMessage::Status(msg, is_error));
 228                     }
 229                     op => {
 230                         let edits = matches!(op, Cmd::CreateItem { .. } | Cmd::UpdateItem { .. } | Cmd::DeleteItem { .. });
 231                         let Err(first) = run_secret_op(&ss, &tx, op.clone()).await else {
 232                             if edits {
 233                                 // A saved entry reaches 1Password on the
 234                                 // daemon's next pass; ask for it now.
 235                                 poke_sync_daemon().await;
 236                             }
 237                             continue;
 238                         };
 239                         // The daemon may have restarted underneath us
 240                         // (gnome-keyring aborted on a GLib assertion and was
 241                         // relaunched, 2026-09-06). Listing survives that, but
 242                         // the session negotiated at connect died with the old
 243                         // process, and every secret transfer names it — so the
 244                         // list looks fine while Copy/Reveal/Save fail. Take a
 245                         // fresh connection (new session) and try exactly once
 246                         // more; a failure on the retry is a real one.
 247                         log::info!("secret op failed ({first}); reconnecting to the Secret Service and retrying once");
 248                         match SecretService::connect(EncryptionType::Dh).await {
 249                             Ok(fresh) => {
 250                                 ss = fresh;
 251                                 if let Err(second) = run_secret_op(&ss, &tx, op).await {
 252                                     let _ = tx.send(AppMessage::Status(second, true));
 253                                 }
 254                             }
 255                             Err(e) => {
 256                                 let _ = tx.send(AppMessage::Status(
 257                                     format!("{first} (reconnect to Secret Service failed: {e})"),
 258                                     true,
 259                                 ));
 260                             }
 261                         }
 262                     }
 263                 }
 264             }
 265         });
 266     });
 267 }
 268 
 269 /// The session-bound commands: anything that transfers a secret (or edits
 270 /// an item) through the session opened at connect. `Err` is the status line
 271 /// to show; the caller decides whether to retry on a fresh connection first.
 272 async fn run_secret_op(
 273     ss: &SecretService<'_>,
 274     tx: &calloop::channel::Sender<AppMessage>,
 275     cmd: Cmd,
 276 ) -> Result<(), String> {
 277     match cmd {
 278         Cmd::Reload | Cmd::Sync => Ok(()),
 279         Cmd::GetSecret { path, purpose } => fetch_secret(ss, tx, path, purpose).await,
 280         Cmd::CreateItem { label, attrs, secret } => create_item(ss, tx, label, attrs, secret).await,
 281         Cmd::UpdateItem { path, label, attrs, secret } => {
 282             update_item(ss, tx, path, label, attrs, secret).await
 283         }
 284         Cmd::DeleteItem { path } => delete_item(ss, tx, path).await,
 285     }
 286 }
 287 
 288 async fn load_entries(ss: &SecretService<'_>, tx: &calloop::channel::Sender<AppMessage>) {
 289     let _ = tx.send(AppMessage::Progress("Loading entries…".to_string()));
 290     let collections = match ss.get_all_collections().await {
 291         Ok(c) => c,
 292         Err(e) => {
 293             let _ = tx.send(AppMessage::Status(format!("Listing collections failed: {e}"), true));
 294             return;
 295         }
 296     };
 297     let mut entries = Vec::new();
 298     for col in &collections {
 299         let label = col.get_label().await.unwrap_or_else(|_| "collection".to_string());
 300         // Locked collection: unlocking prompts through the Secret Service
 301         // provider (gnome-keyring raises its own dialog and this await blocks
 302         // until it's answered); a refused prompt just skips the collection.
 303         if col.is_locked().await.unwrap_or(false) {
 304             let _ = tx.send(AppMessage::Status(
 305                 format!("Unlock \"{label}\" to load its entries…"),
 306                 false,
 307             ));
 308             if col.unlock().await.is_err() || col.is_locked().await.unwrap_or(true) {
 309                 let _ = tx.send(AppMessage::Status(
 310                     format!("Collection \"{label}\" stayed locked — Refresh to retry"),
 311                     true,
 312                 ));
 313                 continue;
 314             }
 315         }
 316         let items = match col.get_all_items().await {
 317             Ok(i) => i,
 318             Err(e) => {
 319                 let _ = tx.send(AppMessage::Status(format!("Listing \"{label}\" failed: {e}"), true));
 320                 continue;
 321             }
 322         };
 323         for item in items {
 324             let mut attrs: Vec<(String, String)> = item
 325                 .get_attributes()
 326                 .await
 327                 .unwrap_or_default()
 328                 .into_iter()
 329                 .filter(|(k, _)| k != "xdg:schema")
 330                 .collect();
 331             attrs.sort();
 332             entries.push(EntryData {
 333                 path: item.item_path.to_string(),
 334                 label: item.get_label().await.unwrap_or_default(),
 335                 collection: label.clone(),
 336                 attrs,
 337             });
 338         }
 339     }
 340     entries.sort_by(|a, b| a.label.to_lowercase().cmp(&b.label.to_lowercase()));
 341     let _ = tx.send(AppMessage::Loaded(entries));
 342 }
 343 
 344 async fn fetch_secret(
 345     ss: &SecretService<'_>,
 346     tx: &calloop::channel::Sender<AppMessage>,
 347     path: String,
 348     purpose: Purpose,
 349 ) -> Result<(), String> {
 350     let item = resolve_item(ss, &path).await?;
 351     let _ = item.ensure_unlocked().await;
 352     let bytes = item
 353         .get_secret()
 354         .await
 355         .map_err(|e| format!("Secret fetch failed: {e}"))?;
 356     let secret = String::from_utf8_lossy(&bytes).to_string();
 357     match purpose {
 358         Purpose::Copy => {
 359             cce_ui::widget::clipboard::copy_to_clipboard(&secret);
 360             let _ = tx.send(AppMessage::Status(
 361                 format!("Secret copied — clipboard clears in {CLIPBOARD_CLEAR_SECS} s"),
 362                 false,
 363             ));
 364             std::thread::spawn(move || {
 365                 std::thread::sleep(std::time::Duration::from_secs(CLIPBOARD_CLEAR_SECS));
 366                 // Only clear if the clipboard still holds our secret.
 367                 if cce_ui::widget::clipboard::read_from_clipboard().as_deref() == Some(secret.as_str()) {
 368                     cce_ui::widget::clipboard::copy_to_clipboard("");
 369                 }
 370             });
 371         }
 372         Purpose::Reveal => {
 373             let _ = tx.send(AppMessage::Revealed { path, secret });
 374         }
 375     }
 376     Ok(())
 377 }
 378 
 379 async fn resolve_item<'a>(
 380     ss: &'a SecretService<'a>,
 381     path: &str,
 382 ) -> Result<secret_service::Item<'a>, String> {
 383     let opath = zbus::zvariant::OwnedObjectPath::try_from(path.to_string())
 384         .map_err(|e| format!("Bad item path: {e}"))?;
 385     ss.get_item_by_path(opath)
 386         .await
 387         .map_err(|e| format!("Item lookup failed: {e}"))
 388 }
 389 
 390 async fn create_item(
 391     ss: &SecretService<'_>,
 392     tx: &calloop::channel::Sender<AppMessage>,
 393     label: String,
 394     attrs: Vec<(String, String)>,
 395     secret: String,
 396 ) -> Result<(), String> {
 397     let collection = ss
 398         .get_default_collection()
 399         .await
 400         .map_err(|e| format!("No default collection: {e}"))?;
 401     let _ = collection.ensure_unlocked().await;
 402     let attr_map = attrs.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
 403     let item = collection
 404         .create_item(&label, attr_map, secret.as_bytes(), false, "text/plain")
 405         .await
 406         .map_err(|e| format!("Create failed: {e}"))?;
 407     let new_path = item.item_path.to_string();
 408     let _ = tx.send(AppMessage::Status(format!("Created \"{label}\""), false));
 409     load_entries(ss, tx).await;
 410     let _ = tx.send(AppMessage::SelectPath(new_path));
 411     Ok(())
 412 }
 413 
 414 async fn update_item(
 415     ss: &SecretService<'_>,
 416     tx: &calloop::channel::Sender<AppMessage>,
 417     path: String,
 418     label: String,
 419     attrs: Vec<(String, String)>,
 420     secret: Option<String>,
 421 ) -> Result<(), String> {
 422     let item = resolve_item(ss, &path).await?;
 423     let _ = item.ensure_unlocked().await;
 424     item.set_label(&label)
 425         .await
 426         .map_err(|e| format!("Saving label failed: {e}"))?;
 427     let attr_map = attrs.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
 428     item.set_attributes(attr_map)
 429         .await
 430         .map_err(|e| format!("Saving attributes failed: {e}"))?;
 431     if let Some(secret) = secret {
 432         item.set_secret(secret.as_bytes(), "text/plain")
 433             .await
 434             .map_err(|e| format!("Saving secret failed: {e}"))?;
 435     }
 436     let _ = tx.send(AppMessage::Status(format!("Saved \"{label}\""), false));
 437     load_entries(ss, tx).await;
 438     let _ = tx.send(AppMessage::SelectPath(path));
 439     Ok(())
 440 }
 441 
 442 async fn delete_item(
 443     ss: &SecretService<'_>,
 444     tx: &calloop::channel::Sender<AppMessage>,
 445     path: String,
 446 ) -> Result<(), String> {
 447     let item = resolve_item(ss, &path).await?;
 448     item.delete().await.map_err(|e| format!("Delete failed: {e}"))?;
 449     let _ = tx.send(AppMessage::Status("Entry deleted".to_string(), false));
 450     load_entries(ss, tx).await;
 451     Ok(())
 452 }
 453 
 454 // ── Application ───────────────────────────────────────────────────────────
 455 
 456 struct SecretsApp {
 457     search_box: cce_ui::widget::Adapted<TextBox>,
 458     refresh_btn: cce_ui::widget::Adapted<Button>,
 459     sync_btn: cce_ui::widget::Adapted<Button>,
 460     new_btn: cce_ui::widget::Adapted<Button>,
 461     reveal_btn: cce_ui::widget::Adapted<Button>,
 462     copy_btn: cce_ui::widget::Adapted<Button>,
 463     edit_btn: cce_ui::widget::Adapted<Button>,
 464     delete_btn: cce_ui::widget::Adapted<Button>,
 465     save_btn: cce_ui::widget::Adapted<Button>,
 466     cancel_btn: cce_ui::widget::Adapted<Button>,
 467     // The entry form, top to bottom (Tab order).
 468     title_box: cce_ui::widget::Adapted<TextBox>,
 469     user_box: cce_ui::widget::Adapted<TextBox>,
 470     url_box: cce_ui::widget::Adapted<TextBox>,
 471     notes_box: cce_ui::widget::Adapted<TextBox>,
 472     pass_box: cce_ui::widget::Adapted<TextBox>,
 473 
 474     mode: Mode,
 475     entries: Vec<EntryData>,
 476     /// Selected entry's object path (stable across reloads and filtering).
 477     selected: Option<String>,
 478     /// Revealed (path, secret); cleared on selection change and reload.
 479     revealed: Option<(String, String)>,
 480     /// Path armed for deletion by the first Delete click.
 481     pending_delete: Option<String>,
 482 
 483     /// The DRAWN list offset — `scroll_motion` glides it (wheel) or coasts
 484     /// it (trackpad flick); direct writes (Escape reset, clamp) are adopted
 485     /// by the motion on its next step.
 486     scroll_y: f32,
 487     scroll_motion: ScrollMotion,
 488     /// List viewport (x, y, w, h), refreshed each paint for hit-testing.
 489     list_rect: (f32, f32, f32, f32),
 490     pointer: (f32, f32),
 491     hover_row: Option<usize>,
 492 
 493     status_msg: String,
 494     status_is_error: bool,
 495     /// Right side of the status line: "synced 4m ago", off the sync tool's
 496     /// state file. Cached — the file is only re-read every few seconds.
 497     sync_hint: String,
 498     sync_hint_at: Option<std::time::Instant>,
 499     /// A Sync pass is in flight: the reload it ends with must not replace
 500     /// "Syncing…" with "N entries" before the result line arrives.
 501     syncing: bool,
 502 
 503     cmd_tx: std::sync::mpsc::Sender<Cmd>,
 504     cmd_rx: Option<std::sync::mpsc::Receiver<Cmd>>,
 505     sender: calloop::channel::Sender<AppMessage>,
 506     ui_context: cce_ui::context::UiContext,
 507 }
 508 
 509 /// A TextBox's live content: `edit_buffer` while editing (`text` only syncs
 510 /// on commit — TextBox landmine).
 511 fn live_text(tb: &cce_ui::widget::Adapted<TextBox>) -> &str {
 512     if tb.editing {
 513         &tb.edit_buffer
 514     } else {
 515         &tb.text
 516     }
 517 }
 518 
 519 impl SecretsApp {
 520     /// Indices into `entries` matching the search box, in display order.
 521     fn filtered(&self) -> Vec<usize> {
 522         let query = live_text(&self.search_box).to_lowercase();
 523         (0..self.entries.len())
 524             .filter(|&i| {
 525                 if query.is_empty() {
 526                     return true;
 527                 }
 528                 let e = &self.entries[i];
 529                 e.label.to_lowercase().contains(&query)
 530                     || e.collection.to_lowercase().contains(&query)
 531                     || e.attrs.iter().any(|(_, v)| v.to_lowercase().contains(&query))
 532             })
 533             .collect()
 534     }
 535 
 536     fn selected_entry(&self) -> Option<&EntryData> {
 537         let sel = self.selected.as_deref()?;
 538         self.entries.iter().find(|e| e.path == sel)
 539     }
 540 
 541     fn revealed_secret(&self) -> Option<&str> {
 542         let (path, secret) = self.revealed.as_ref()?;
 543         (self.selected.as_deref() == Some(path.as_str())).then_some(secret.as_str())
 544     }
 545 
 546     fn max_scroll(&self) -> f32 {
 547         (self.filtered().len() as f32 * ROW_H - self.list_rect.3).max(0.0)
 548     }
 549 
 550     /// Advance the wheel glide / flick coast; true while the offset is moving
 551     /// (the frame loop keeps drawing). Hover follows the rows under the pointer.
 552     fn tick_scroll(&mut self, dt: f32) -> bool {
 553         self.scroll_motion.reconcile(0.0, self.scroll_y);
 554         if !self.scroll_motion.is_animating() {
 555             return false;
 556         }
 557         let moved = self.scroll_motion.tick(dt, Bounds::max(0.0), Bounds::max(self.max_scroll()));
 558         self.scroll_y = self.scroll_motion.y.pos();
 559         if moved {
 560             self.hover_row = self.row_at(self.pointer.0, self.pointer.1);
 561         }
 562         moved || self.scroll_motion.is_animating()
 563     }
 564 
 565     fn row_at(&self, px: f32, py: f32) -> Option<usize> {
 566         let (lx, ly, lw, lh) = self.list_rect;
 567         if px < lx || px > lx + lw || py < ly || py > ly + lh {
 568             return None;
 569         }
 570         let row = ((py - ly + self.scroll_y) / ROW_H).floor();
 571         (row >= 0.0 && (row as usize) < self.filtered().len()).then_some(row as usize)
 572     }
 573 
 574     fn editing(&self) -> bool {
 575         matches!(self.mode, Mode::Edit { .. })
 576     }
 577 
 578     fn form_boxes_mut(&mut self) -> [&mut cce_ui::widget::Adapted<TextBox>; 5] {
 579         [
 580             &mut self.title_box,
 581             &mut self.user_box,
 582             &mut self.url_box,
 583             &mut self.notes_box,
 584             &mut self.pass_box,
 585         ]
 586     }
 587 
 588     /// Open the form prefilled from `entry` (or blank for a new one).
 589     fn open_form(&mut self, entry: Option<&EntryData>) {
 590         let (title, user, url, notes) = match entry {
 591             Some(e) => (e.label.clone(), e.attr("UserName").to_string(), e.attr("URL").to_string(), e.attr("Notes").to_string()),
 592             None => Default::default(),
 593         };
 594         self.title_box.set_value(&title);
 595         self.user_box.set_value(&user);
 596         self.url_box.set_value(&url);
 597         self.notes_box.set_value(&notes);
 598         self.pass_box.set_value("");
 599         self.pass_box.placeholder = Some(
 600             if entry.is_some() { "leave blank to keep current" } else { "password" }.to_string(),
 601         );
 602         self.mode = Mode::Edit { path: entry.map(|e| e.path.clone()) };
 603         self.pending_delete = None;
 604         self.revealed = None;
 605         for b in self.form_boxes_mut() {
 606             b.unfocus();
 607         }
 608         self.title_box.focus();
 609     }
 610 
 611     fn close_form(&mut self) {
 612         for b in self.form_boxes_mut() {
 613             b.unfocus();
 614         }
 615         self.mode = Mode::Browse;
 616     }
 617 
 618     /// Gather the form into a save command; errors go straight to the status line.
 619     fn save_form(&mut self) -> Option<Cmd> {
 620         let title = live_text(&self.title_box).trim().to_string();
 621         if title.is_empty() {
 622             self.status_msg = "Title is required".to_string();
 623             self.status_is_error = true;
 624             return None;
 625         }
 626         let values = [&self.user_box, &self.url_box, &self.notes_box]
 627             .map(|b| live_text(b).trim().to_string());
 628         let attrs: Vec<(String, String)> = EDIT_ATTRS
 629             .iter()
 630             .zip(values)
 631             .filter(|(_, v)| !v.is_empty())
 632             .map(|(k, v)| (k.to_string(), v))
 633             .collect();
 634         let password = live_text(&self.pass_box).to_string();
 635         let Mode::Edit { path } = &self.mode else { return None };
 636         Some(match path {
 637             Some(path) => Cmd::UpdateItem {
 638                 path: path.clone(),
 639                 label: title,
 640                 attrs,
 641                 secret: (!password.is_empty()).then_some(password),
 642             },
 643             None => Cmd::CreateItem { label: title, attrs, secret: password },
 644         })
 645     }
 646 
 647     /// "synced 4m ago" from cce-keyring-sync's state file, refreshed at most
 648     /// every 5s — the daemon ticks every 5 minutes, so staleness is invisible.
 649     fn refresh_sync_hint(&mut self) {
 650         if self.sync_hint_at.is_some_and(|t| t.elapsed().as_secs() < 5) {
 651             return;
 652         }
 653         self.sync_hint_at = Some(std::time::Instant::now());
 654         self.sync_hint = read_sync_state()
 655             .map(|(t, _)| t)
 656             .filter(|&t| t > 0)
 657             .map(|t| {
 658                 let ago = (std::time::SystemTime::now()
 659                     .duration_since(std::time::UNIX_EPOCH)
 660                     .map(|d| d.as_secs() as i64)
 661                     .unwrap_or(0)
 662                     - t)
 663                     .max(0);
 664                 match ago {
 665                     0..=90 => "synced just now".to_string(),
 666                     91..=5400 => format!("synced {}m ago", ago / 60),
 667                     _ => format!("synced {}h ago", ago / 3600),
 668                 }
 669             })
 670             .unwrap_or_default();
 671     }
 672 
 673     fn buttons_mut(&mut self) -> [&mut cce_ui::widget::Adapted<Button>; 9] {
 674         [
 675             &mut self.refresh_btn,
 676             &mut self.sync_btn,
 677             &mut self.new_btn,
 678             &mut self.reveal_btn,
 679             &mut self.copy_btn,
 680             &mut self.edit_btn,
 681             &mut self.delete_btn,
 682             &mut self.save_btn,
 683             &mut self.cancel_btn,
 684         ]
 685     }
 686 
 687     fn widgets_iter(&self) -> Vec<&dyn WidgetHost> {
 688         vec![
 689             &self.search_box,
 690             &self.refresh_btn,
 691             &self.sync_btn,
 692             &self.new_btn,
 693             &self.reveal_btn,
 694             &self.copy_btn,
 695             &self.edit_btn,
 696             &self.delete_btn,
 697             &self.save_btn,
 698             &self.cancel_btn,
 699             &self.title_box,
 700             &self.user_box,
 701             &self.url_box,
 702             &self.notes_box,
 703             &self.pass_box,
 704         ]
 705     }
 706 }
 707 
 708 fn park(btn: &mut cce_ui::widget::Adapted<Button>) {
 709     btn.set_rect(-1000.0, -1000.0, BTN_W, BTN_H);
 710 }
 711 
 712 fn srgb_u8(linear: [f32; 4]) -> [u8; 3] {
 713     let srgb = cce_ui::colors::to_srgb(linear);
 714     [
 715         (srgb[0] * 255.0) as u8,
 716         (srgb[1] * 255.0) as u8,
 717         (srgb[2] * 255.0) as u8,
 718     ]
 719 }
 720 
 721 impl Application for SecretsApp {
 722     type Message = AppMessage;
 723 
 724     fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
 725         Some(&self.ui_context)
 726     }
 727 
 728     fn new(_qh: &QueueHandle<EngineState<Self>>, sender: calloop::channel::Sender<Self::Message>) -> Self {
 729         let (cmd_tx, cmd_rx) = std::sync::mpsc::channel();
 730         Self {
 731             search_box: TextBox::new(String::new()).with_placeholder("Search"),
 732             refresh_btn: Button::new(0.0, 0.0, BTN_W, BTN_H).with_label("Refresh"),
 733             sync_btn: Button::new(0.0, 0.0, BTN_W, BTN_H).with_label("Sync"),
 734             new_btn: Button::new(0.0, 0.0, BTN_W, BTN_H).with_label("New"),
 735             reveal_btn: Button::new(0.0, 0.0, BTN_W, BTN_H).with_label("Reveal"),
 736             copy_btn: Button::new(0.0, 0.0, BTN_W, BTN_H).with_label("Copy"),
 737             edit_btn: Button::new(0.0, 0.0, BTN_W, BTN_H).with_label("Edit"),
 738             delete_btn: Button::new(0.0, 0.0, BTN_W, BTN_H).with_label("Delete"),
 739             save_btn: Button::new(0.0, 0.0, BTN_W, BTN_H).with_label("Save"),
 740             cancel_btn: Button::new(0.0, 0.0, BTN_W, BTN_H).with_label("Cancel"),
 741             title_box: TextBox::new(String::new()).with_placeholder("title"),
 742             user_box: TextBox::new(String::new()).with_placeholder("username"),
 743             url_box: TextBox::new(String::new()).with_placeholder("url"),
 744             notes_box: TextBox::new(String::new()).with_placeholder("notes"),
 745             pass_box: TextBox::new(String::new()).with_password(true).with_placeholder("password"),
 746             mode: Mode::Browse,
 747             entries: Vec::new(),
 748             selected: None,
 749             revealed: None,
 750             pending_delete: None,
 751             scroll_y: 0.0,
 752             scroll_motion: ScrollMotion::new(),
 753             // Placed by the first frame; empty until then so nothing hit-tests.
 754             list_rect: (0.0, 0.0, 0.0, 0.0),
 755             pointer: (0.0, 0.0),
 756             hover_row: None,
 757             status_msg: "Connecting to Secret Service…".to_string(),
 758             status_is_error: false,
 759             sync_hint: String::new(),
 760             sync_hint_at: None,
 761             syncing: false,
 762             cmd_tx,
 763             cmd_rx: Some(cmd_rx),
 764             sender,
 765             ui_context: cce_ui::context::UiContext::new(),
 766         }
 767     }
 768 
 769     fn settings(&self) -> WindowSettings {
 770         WindowSettings {
 771             title: "CCE Secrets".to_string(),
 772             app_id: "cce-secrets".to_string(),
 773             width: 760,
 774             height: 520,
 775             fullscreen: false,
 776             min_size: Some((560, 380)),
 777         }
 778     }
 779 
 780     fn register_sources(&mut self, _handle: &calloop::LoopHandle<'_, EngineState<Self>>) {
 781         if let Some(rx) = self.cmd_rx.take() {
 782             spawn_worker(rx, self.sender.clone());
 783         }
 784     }
 785 
 786     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, _exit: &mut bool) {
 787         *needs_rebuild = true;
 788         match msg {
 789             AppMessage::Loaded(entries) => {
 790                 self.entries = entries;
 791                 self.revealed = None;
 792                 self.pending_delete = None;
 793                 if self.selected_entry().is_none() {
 794                     self.selected = None;
 795                 }
 796                 self.scroll_y = self.scroll_y.clamp(0.0, self.max_scroll());
 797                 if self.syncing {
 798                     return;
 799                 }
 800                 self.status_msg = if self.entries.is_empty() {
 801                     "No entries — run `cce-keyring-sync adopt --vault <name>` to seed the keyring from 1Password".to_string()
 802                 } else {
 803                     format!("{} entries", self.entries.len())
 804                 };
 805                 self.status_is_error = false;
 806             }
 807             AppMessage::Status(msg, is_error) => {
 808                 self.syncing = false;
 809                 self.status_msg = msg;
 810                 self.status_is_error = is_error;
 811             }
 812             AppMessage::Progress(msg) => {
 813                 if !self.syncing {
 814                     self.status_msg = msg;
 815                     self.status_is_error = false;
 816                 }
 817             }
 818             AppMessage::Revealed { path, secret } => {
 819                 if self.selected.as_deref() == Some(path.as_str()) {
 820                     self.revealed = Some((path, secret));
 821                 }
 822             }
 823             AppMessage::SelectPath(path) => {
 824                 self.selected = Some(path);
 825                 self.revealed = None;
 826                 self.pending_delete = None;
 827             }
 828             AppMessage::RefreshClicked => {
 829                 let _ = self.cmd_tx.send(Cmd::Reload);
 830             }
 831             AppMessage::SyncClicked => {
 832                 self.syncing = true;
 833                 self.status_msg = "Syncing…".to_string();
 834                 self.status_is_error = false;
 835                 let _ = self.cmd_tx.send(Cmd::Sync);
 836             }
 837             AppMessage::RevealClicked => {
 838                 if let Some(sel) = self.selected.clone() {
 839                     if self.revealed_secret().is_some() {
 840                         self.revealed = None;
 841                     } else {
 842                         let _ = self.cmd_tx.send(Cmd::GetSecret { path: sel, purpose: Purpose::Reveal });
 843                     }
 844                 }
 845             }
 846             AppMessage::CopyClicked => {
 847                 if let Some(sel) = self.selected.clone() {
 848                     let _ = self.cmd_tx.send(Cmd::GetSecret { path: sel, purpose: Purpose::Copy });
 849                 }
 850             }
 851             AppMessage::NewClicked => self.open_form(None),
 852             AppMessage::EditClicked => {
 853                 if let Some(entry) = self.selected_entry().cloned() {
 854                     self.open_form(Some(&entry));
 855                 }
 856             }
 857             AppMessage::DeleteClicked => {
 858                 if let Some(sel) = self.selected.clone() {
 859                     if self.pending_delete.as_deref() == Some(sel.as_str()) {
 860                         self.pending_delete = None;
 861                         self.status_msg = "Deleting…".to_string();
 862                         self.status_is_error = false;
 863                         let _ = self.cmd_tx.send(Cmd::DeleteItem { path: sel });
 864                     } else {
 865                         self.pending_delete = Some(sel);
 866                         self.status_msg = "Click Confirm to delete this entry".to_string();
 867                         self.status_is_error = false;
 868                     }
 869                 }
 870             }
 871             AppMessage::SaveClicked => {
 872                 if let Some(cmd) = self.save_form() {
 873                     self.status_msg = "Saving…".to_string();
 874                     self.status_is_error = false;
 875                     let _ = self.cmd_tx.send(cmd);
 876                     self.close_form();
 877                 }
 878             }
 879             AppMessage::CancelClicked => self.close_form(),
 880         }
 881     }
 882 
 883     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
 884         if self.tick_scroll(dt) {
 885             *needs_rebuild = true;
 886         }
 887     }
 888 
 889     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
 890         use cce_ui::scene::layout::Rect;
 891         cce_ui::scale::set_scale_factor(scale as f32);
 892         let sw = size.width as f32;
 893         let sh = size.height as f32;
 894 
 895         // Widget roots re-registered every frame (idempotent; the frame is
 896         // assembled by hand, so nothing else registers them).
 897         {
 898             let (id, ptr) = (self.search_box.id(), self.search_box.as_ptr_mut());
 899             self.ui_context.register_widget(id, ptr);
 900             {
 901                 let (id, ptr) = (self.sync_btn.id(), self.sync_btn.as_ptr_mut());
 902                 self.ui_context.register_widget(id, ptr);
 903             }
 904             let (id, ptr) = (self.refresh_btn.id(), self.refresh_btn.as_ptr_mut());
 905             self.ui_context.register_widget(id, ptr);
 906             let (id, ptr) = (self.new_btn.id(), self.new_btn.as_ptr_mut());
 907             self.ui_context.register_widget(id, ptr);
 908             let (id, ptr) = (self.reveal_btn.id(), self.reveal_btn.as_ptr_mut());
 909             self.ui_context.register_widget(id, ptr);
 910             let (id, ptr) = (self.copy_btn.id(), self.copy_btn.as_ptr_mut());
 911             self.ui_context.register_widget(id, ptr);
 912             let (id, ptr) = (self.edit_btn.id(), self.edit_btn.as_ptr_mut());
 913             self.ui_context.register_widget(id, ptr);
 914             let (id, ptr) = (self.delete_btn.id(), self.delete_btn.as_ptr_mut());
 915             self.ui_context.register_widget(id, ptr);
 916             let (id, ptr) = (self.save_btn.id(), self.save_btn.as_ptr_mut());
 917             self.ui_context.register_widget(id, ptr);
 918             let (id, ptr) = (self.cancel_btn.id(), self.cancel_btn.as_ptr_mut());
 919             self.ui_context.register_widget(id, ptr);
 920             let (id, ptr) = (self.title_box.id(), self.title_box.as_ptr_mut());
 921             self.ui_context.register_widget(id, ptr);
 922             let (id, ptr) = (self.user_box.id(), self.user_box.as_ptr_mut());
 923             self.ui_context.register_widget(id, ptr);
 924             let (id, ptr) = (self.url_box.id(), self.url_box.as_ptr_mut());
 925             self.ui_context.register_widget(id, ptr);
 926             let (id, ptr) = (self.notes_box.id(), self.notes_box.as_ptr_mut());
 927             self.ui_context.register_widget(id, ptr);
 928             let (id, ptr) = (self.pass_box.id(), self.pass_box.as_ptr_mut());
 929             self.ui_context.register_widget(id, ptr);
 930         }
 931 
 932         let mut pc = cce_ui::scene::paint::PaintCtx::new();
 933         let quad = |pc: &mut cce_ui::scene::paint::PaintCtx, x: f32, y: f32, w: f32, h: f32, c: [f32; 4]| {
 934             pc.quad(Rect { x, y, width: w, height: h }, c);
 935         };
 936 
 937         // The standard root plate (cce-ui PlateSpec::window): the DE root
 938         // material at its opacity, the shared silhouette arc, the rolled rim.
 939         pc.root_plate(sw, sh);
 940 
 941         // Spacing is the ladder (cce-ui/CLAUDE.md): the window edge is
 942         // `inset`, siblings on the root plate stand `gap` apart, and inside
 943         // the two panes content sits `pad` off the rim with `pgap` between
 944         // blocks. The literals that remain are sizes and text line advances.
 945         let inset = cce_ui::layout::root_plate_inset();
 946         let gap = cce_ui::layout::root_plate_gap();
 947         let pad = cce_ui::layout::plate_padding();
 948         let pgap = cce_ui::layout::plate_gap();
 949 
 950         // ── Left panel: search + entry list ──
 951         self.search_box.set_rect(inset, inset, LIST_W, SEARCH_H);
 952         self.refresh_btn.set_rect(sw - inset - BTN_W, inset, BTN_W, BTN_H);
 953         self.new_btn.set_rect(sw - inset - BTN_W * 2.0 - gap, inset, BTN_W, BTN_H);
 954         self.sync_btn.set_rect(sw - inset - BTN_W * 3.0 - 2.0 * gap, inset, BTN_W, BTN_H);
 955 
 956         let list_y = inset + SEARCH_H + gap;
 957         let list_h = (sh - list_y - STATUS_H - gap).max(0.0);
 958         self.list_rect = (inset, list_y, LIST_W, list_h);
 959         quad(&mut pc, inset, list_y, LIST_W, list_h, cce_ui::color::list_bg_color());
 960 
 961         let filtered = self.filtered();
 962         let list_bounds = Some([inset, list_y, inset + LIST_W, list_y + list_h]);
 963         for (row, &ei) in filtered.iter().enumerate() {
 964             let ry = list_y + row as f32 * ROW_H - self.scroll_y;
 965             if ry + ROW_H < list_y || ry > list_y + list_h {
 966                 continue;
 967             }
 968             let entry = &self.entries[ei];
 969             let is_selected = self.selected.as_deref() == Some(entry.path.as_str());
 970             if is_selected {
 971                 quad(&mut pc, inset, ry, LIST_W, ROW_H, [0.10, 0.28, 0.17, 1.0]);
 972             } else if self.hover_row == Some(row) {
 973                 quad(&mut pc, inset, ry, LIST_W, ROW_H, [1.0, 1.0, 1.0, 0.04]);
 974             }
 975             // TODO(style): the two text lines sit at fixed offsets inside the
 976             // ROW_H row — a line rhythm, not a rung.
 977             pc.text_with(
 978                 entry.label.clone(),
 979                 inset + pad,
 980                 ry + 8.0,
 981                 12.0,
 982                 srgb_u8(cce_ui::colors::TEXT_HEADER),
 983                 None,
 984                 list_bounds,
 985             );
 986             if let Some(hint) = entry.hint() {
 987                 pc.text_with(
 988                     hint.to_string(),
 989                     inset + pad,
 990                     ry + 25.0,
 991                     10.0,
 992                     srgb_u8(cce_ui::colors::TEXT_DIM),
 993                     None,
 994                     list_bounds,
 995                 );
 996             }
 997         }
 998 
 999         // Scrollbar (wheel-driven; thumb is display-only).
1000         let content_h = filtered.len() as f32 * ROW_H;
1001         if content_h > list_h {
1002             let sb_w = 4.0;
1003             // style: deliberate — the 4px thumb hugs the list's rim; a rung-wide
1004             // gutter would read as a column of its own.
1005             let sb_x = inset + LIST_W - sb_w - 3.0;
1006             let visible_ratio = list_h / content_h;
1007             let thumb_h = (list_h * visible_ratio).clamp(20.0, list_h);
1008             let scroll_ratio = if self.max_scroll() > 0.0 { self.scroll_y / self.max_scroll() } else { 0.0 };
1009             let thumb_y = list_y + scroll_ratio * (list_h - thumb_h);
1010             quad(&mut pc, sb_x, list_y, sb_w, list_h, cce_ui::color::scrollbar_track_color());
1011             quad(&mut pc, sb_x, thumb_y, sb_w, thumb_h, cce_ui::color::scrollbar_thumb_color());
1012         }
1013 
1014         // ── Right panel: detail view or the entry form ──
1015         let dx = inset + LIST_W + gap;
1016         let dw = (sw - dx - inset).max(0.0);
1017         let detail_bounds = Some([dx, list_y, dx + dw, list_y + list_h]);
1018         // The pane's content starts one plate padding below its top; the
1019         // 22.0 under the 15px header is that line's advance, not a rung.
1020         let hy = list_y + pad;
1021         match &self.mode {
1022             Mode::Edit { path } => {
1023                 let header = if path.is_some() { "Edit entry" } else { "New entry" };
1024                 pc.text_with(header.to_string(), dx, hy, 15.0, srgb_u8(cce_ui::colors::TEXT_HEADER), None, detail_bounds);
1025                 let labels = ["Title", "UserName", "URL", "Notes", "Password"];
1026                 let mut fy = hy + 22.0 + pgap;
1027                 let box_w = (dw - 4.0).min(320.0); // TODO(style): 4px slack on the field width, not a rung
1028                 // Each field is a 10px label strip (14.0) over a 28px box. The
1029                 // fields are `pgap` apart rather than `control_gap()`: five
1030                 // control-height gaps overrun the default window height.
1031                 for (label, tb) in labels.iter().zip(self.form_boxes_mut()) {
1032                     tb.set_rect(dx, fy + 14.0, box_w, 28.0);
1033                     pc.text_with(label.to_string(), dx, fy, 10.0, srgb_u8(cce_ui::colors::TEXT_DIM), None, None);
1034                     fy += 14.0 + 28.0 + pgap;
1035                 }
1036                 self.save_btn.set_rect(dx, fy, BTN_W, BTN_H);
1037                 self.cancel_btn.set_rect(dx + BTN_W + pgap, fy, BTN_W, BTN_H);
1038                 for b in [&mut self.reveal_btn, &mut self.copy_btn, &mut self.edit_btn, &mut self.delete_btn, &mut self.new_btn] {
1039                     park(b);
1040                 }
1041             }
1042             Mode::Browse => {
1043                 park(&mut self.save_btn);
1044                 park(&mut self.cancel_btn);
1045                 for tb in self.form_boxes_mut() {
1046                     tb.set_rect(-1000.0, -1000.0, 10.0, 10.0);
1047                 }
1048                 if let Some(entry) = self.selected_entry().cloned() {
1049                     pc.text_with(entry.label.clone(), dx, hy, 15.0, srgb_u8(cce_ui::colors::TEXT_HEADER), None, detail_bounds);
1050                     pc.text_with(entry.collection.clone(), dx, hy + 22.0, 10.0, srgb_u8(cce_ui::colors::TEXT_DIM), None, detail_bounds);
1051 
1052                     // The header block (a 15px line, a 10px line), a pane gap,
1053                     // then the attribute rows at their 22.0 line advance.
1054                     let mut ay = hy + 22.0 + 14.0 + pgap;
1055                     for (key, value) in &entry.attrs {
1056                         pc.text_with(key.clone(), dx, ay, 10.0, srgb_u8(cce_ui::colors::TEXT_DIM), None, detail_bounds);
1057                         pc.text_with(value.clone(), dx + 120.0, ay, 11.0, srgb_u8(cce_ui::colors::TEXT_FG), None, detail_bounds);
1058                         ay += 22.0;
1059                     }
1060 
1061                     ay += pgap;
1062                     pc.text_with("secret".to_string(), dx, ay, 10.0, srgb_u8(cce_ui::colors::TEXT_DIM), None, detail_bounds);
1063                     let (secret_text, revealed) = match self.revealed_secret() {
1064                         Some(s) => (s.to_string(), true),
1065                         None => ("••••••••••••".to_string(), false),
1066                     };
1067                     pc.text_with(secret_text, dx + 120.0, ay, 11.0, srgb_u8(cce_ui::colors::TEXT_FG), None, detail_bounds);
1068 
1069                     self.reveal_btn.set_label(if revealed { "Hide" } else { "Reveal" });
1070                     self.delete_btn.set_label(
1071                         if self.pending_delete.as_deref() == Some(entry.path.as_str()) { "Confirm" } else { "Delete" },
1072                     );
1073                     // Two button rows under the secret line (14.0, its advance).
1074                     let by = ay + 14.0 + pgap;
1075                     self.reveal_btn.set_rect(dx, by, BTN_W, BTN_H);
1076                     self.copy_btn.set_rect(dx + BTN_W + pgap, by, BTN_W, BTN_H);
1077                     self.edit_btn.set_rect(dx, by + BTN_H + pgap, BTN_W, BTN_H);
1078                     self.delete_btn.set_rect(dx + BTN_W + pgap, by + BTN_H + pgap, BTN_W, BTN_H);
1079                 } else {
1080                     let hint = if self.entries.is_empty() { "" } else { "Select an entry" };
1081                     pc.text_with(hint.to_string(), dx, hy, 11.0, srgb_u8(cce_ui::colors::TEXT_DIM), None, detail_bounds);
1082                     for b in [&mut self.reveal_btn, &mut self.copy_btn, &mut self.edit_btn, &mut self.delete_btn] {
1083                         park(b);
1084                     }
1085                 }
1086             }
1087         }
1088 
1089         // ── Widgets + status line ──
1090         for w in &self.widgets_iter() {
1091             let (wx, wy, ww, wh) = w.rect();
1092             quad(&mut pc, wx, wy, ww, wh, w.color());
1093             for (qx, qy, qw, qh, qc) in w.extra_quads() {
1094                 quad(&mut pc, qx, qy, qw, qh, qc);
1095             }
1096         }
1097         for w in self.widgets_iter() {
1098             cce_ui::scene::painter::append_widget_text(&self.ui_context, w, &mut pc);
1099         }
1100 
1101         let status_color = if self.status_is_error { [0xee, 0x5c, 0x5c] } else { srgb_u8(cce_ui::colors::TEXT_DIM) };
1102         pc.text_with(
1103             self.status_msg.clone(),
1104             inset,
1105             sh - STATUS_H + 6.0, // TODO(style): seats the 10px line in the STATUS_H band, not a rung
1106             10.0,
1107             status_color,
1108             None,
1109             Some([inset, sh - STATUS_H, sw - inset, sh]),
1110         );
1111         self.refresh_sync_hint();
1112         if !self.sync_hint.is_empty() {
1113             let w = cce_ui::widget::display::measure_text_width(&self.sync_hint, &cce_ui::layout::read_preferred_fonts().0, 10.0);
1114             pc.text_with(
1115                 self.sync_hint.clone(),
1116                 sw - inset - w,
1117                 sh - STATUS_H + 6.0,
1118                 10.0,
1119                 srgb_u8(cce_ui::colors::TEXT_DIM),
1120                 None,
1121                 Some([inset, sh - STATUS_H, sw - inset, sh]),
1122             );
1123         }
1124 
1125         Some(pc.finish())
1126     }
1127 
1128     fn display_list_text(&self) -> bool {
1129         true
1130     }
1131 
1132     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
1133         self.pointer = (pos.x, pos.y);
1134         let mv = cce_ui::widget::Event::PointerMove { x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
1135         let mut roots = vec![self.search_box.id()];
1136         roots.extend(self.buttons_mut().map(|b| b.id()));
1137         roots.extend(self.form_boxes_mut().map(|b| b.id()));
1138         for root in roots {
1139             if self.ui_context.propagate_event(&mv, root) {
1140                 *needs_rebuild = true;
1141             }
1142         }
1143         let hover = self.row_at(pos.x, pos.y);
1144         if hover != self.hover_row {
1145             self.hover_row = hover;
1146             *needs_rebuild = true;
1147         }
1148     }
1149 
1150     fn handle_mouse_input(
1151         &mut self,
1152         button: MouseButton,
1153         state: ElementState,
1154         pos: LogicalPosition,
1155         needs_rebuild: &mut bool,
1156     ) -> Option<Self::Message> {
1157         let (lx, ly) = (pos.x, pos.y);
1158         let ev = cce_ui::widget::Event::MouseButton { button, state, x: lx, y: ly, local_x: lx, local_y: ly };
1159 
1160         // Buttons: propagate, then drain clicks into messages.
1161         let button_roots: Vec<_> = {
1162             let bs = self.buttons_mut();
1163             bs.iter().map(|b| b.id()).collect()
1164         };
1165         for root in button_roots {
1166             if self.ui_context.propagate_event(&ev, root) {
1167                 *needs_rebuild = true;
1168             }
1169         }
1170         if self.refresh_btn.take_click() {
1171             return Some(AppMessage::RefreshClicked);
1172         }
1173         if self.sync_btn.take_click() {
1174             return Some(AppMessage::SyncClicked);
1175         }
1176         if self.new_btn.take_click() {
1177             return Some(AppMessage::NewClicked);
1178         }
1179         if self.reveal_btn.take_click() {
1180             return Some(AppMessage::RevealClicked);
1181         }
1182         if self.copy_btn.take_click() {
1183             return Some(AppMessage::CopyClicked);
1184         }
1185         if self.edit_btn.take_click() {
1186             return Some(AppMessage::EditClicked);
1187         }
1188         if self.delete_btn.take_click() {
1189             return Some(AppMessage::DeleteClicked);
1190         }
1191         if self.save_btn.take_click() {
1192             return Some(AppMessage::SaveClicked);
1193         }
1194         if self.cancel_btn.take_click() {
1195             return Some(AppMessage::CancelClicked);
1196         }
1197 
1198         // Text boxes: unfocus the ones the press missed, then propagate.
1199         let mut box_roots = vec![self.search_box.id()];
1200         if self.editing() {
1201             box_roots.extend(self.form_boxes_mut().map(|b| b.id()));
1202         }
1203         if state == ElementState::Pressed {
1204             if !self.search_box.hit_test(lx, ly, &self.ui_context) {
1205                 self.search_box.unfocus();
1206             }
1207             if self.editing() {
1208                 if !self.title_box.hit_test(lx, ly, &self.ui_context) {
1209                     self.title_box.unfocus();
1210                 }
1211                 if !self.user_box.hit_test(lx, ly, &self.ui_context) {
1212                     self.user_box.unfocus();
1213                 }
1214                 if !self.url_box.hit_test(lx, ly, &self.ui_context) {
1215                     self.url_box.unfocus();
1216                 }
1217                 if !self.notes_box.hit_test(lx, ly, &self.ui_context) {
1218                     self.notes_box.unfocus();
1219                 }
1220                 if !self.pass_box.hit_test(lx, ly, &self.ui_context) {
1221                     self.pass_box.unfocus();
1222                 }
1223             }
1224         }
1225         for root in box_roots {
1226             if self.ui_context.propagate_event(&ev, root) {
1227                 *needs_rebuild = true;
1228             }
1229         }
1230 
1231         // List selection only while browsing — the form keeps its state.
1232         if !self.editing() && button == MouseButton::Left && state == ElementState::Pressed {
1233             if let Some(row) = self.row_at(lx, ly) {
1234                 let filtered = self.filtered();
1235                 let path = self.entries[filtered[row]].path.clone();
1236                 if self.selected.as_deref() != Some(path.as_str()) {
1237                     self.selected = Some(path);
1238                     self.revealed = None;
1239                     self.pending_delete = None;
1240                     *needs_rebuild = true;
1241                 }
1242             }
1243         }
1244         None
1245     }
1246 
1247     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
1248         let (lx, ly, lw, lh) = self.list_rect;
1249         if pos.x < lx || pos.x > lx + lw || pos.y < ly || pos.y > ly + lh {
1250             return;
1251         }
1252         self.scroll_motion.reconcile(0.0, self.scroll_y);
1253         let moved = self.scroll_motion.apply(delta, (LINE_PX, LINE_PX), Bounds::max(0.0), Bounds::max(self.max_scroll()));
1254         self.scroll_y = self.scroll_motion.y.pos();
1255         if moved {
1256             self.hover_row = self.row_at(self.pointer.0, self.pointer.1);
1257             *needs_rebuild = true;
1258         }
1259     }
1260 
1261     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
1262         if event.state == ElementState::Pressed && !event.repeat {
1263             match &event.logical_key {
1264                 Key::Named(NamedKey::Escape) => {
1265                     if self.editing() {
1266                         return Some(AppMessage::CancelClicked);
1267                     }
1268                     // TextBox never tracks ctx focus — `editing` is its focus signal.
1269                     if self.search_box.editing {
1270                         self.search_box.text.clear();
1271                         self.search_box.edit_buffer.clear();
1272                         self.search_box.unfocus();
1273                         self.scroll_y = 0.0;
1274                         *needs_rebuild = true;
1275                         return None;
1276                     }
1277                 }
1278                 Key::Named(NamedKey::Tab) if self.editing() => {
1279                     // TextBox never tracks ctx focus — `editing` is its focus signal.
1280                     let focused = [
1281                         self.title_box.editing,
1282                         self.user_box.editing,
1283                         self.url_box.editing,
1284                         self.notes_box.editing,
1285                         self.pass_box.editing,
1286                     ]
1287                     .iter()
1288                     .position(|&f| f);
1289                     let next = focused.map(|i| (i + 1) % 5).unwrap_or(0);
1290                     for (i, tb) in self.form_boxes_mut().into_iter().enumerate() {
1291                         if i == next {
1292                             tb.focus();
1293                         } else {
1294                             tb.unfocus();
1295                         }
1296                     }
1297                     *needs_rebuild = true;
1298                     return None;
1299                 }
1300                 Key::Named(NamedKey::Enter) if self.editing() => {
1301                     return Some(AppMessage::SaveClicked);
1302                 }
1303                 _ => {}
1304             }
1305         }
1306         let kev = cce_ui::widget::Event::KeyInput(event.clone());
1307         let mut roots = vec![self.search_box.id()];
1308         if self.editing() {
1309             roots.extend(self.form_boxes_mut().map(|b| b.id()));
1310         }
1311         for root in roots {
1312             if self.ui_context.propagate_event(&kev, root) {
1313                 *needs_rebuild = true;
1314             }
1315         }
1316         self.scroll_y = self.scroll_y.clamp(0.0, self.max_scroll());
1317         None
1318     }
1319 }
1320 
1321 fn main() {
1322     env_logger::init();
1323     cce_ui::engine::run::<SecretsApp>();
1324 }