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

src/main.rs (78.4K)

   1 use secret_service::{EncryptionType, SecretService};
   2 
   3 use cce_ui::widget::Handle;
   4 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
   5 use cce_ui::widget::{Bounds, Button, ElementState, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey, ScrollMotion, ScrollbarActivity, TextBox, WidgetHost, LINE_PX};
   6 
   7 const LIST_W: f32 = 280.0;
   8 const ROW_H: f32 = 44.0;
   9 const STATUS_H: f32 = 30.0;
  10 const BTN_W: f32 = 90.0;
  11 const CLIPBOARD_CLEAR_SECS: u64 = 30;
  12 
  13 /// Entry fields written back as Secret Service attributes (cce-keyring-sync
  14 /// mirrors them to 1Password's username / url / notes; Title is the label).
  15 const EDIT_ATTRS: [&str; 3] = ["UserName", "URL", "Notes"];
  16 
  17 /// One Secret Service item, sans secret: the secret itself is fetched on
  18 /// demand by object path (reveal/copy) and never held in the list.
  19 #[derive(Clone, Debug)]
  20 struct EntryData {
  21     path: String,
  22     label: String,
  23     collection: String,
  24     attrs: Vec<(String, String)>,
  25 }
  26 
  27 impl EntryData {
  28     /// The dim second line of a list row: a username-ish attribute if present.
  29     fn hint(&self) -> Option<&str> {
  30         self.attrs
  31             .iter()
  32             .find(|(k, _)| k.eq_ignore_ascii_case("username") || k.eq_ignore_ascii_case("user"))
  33             .or_else(|| self.attrs.first())
  34             .map(|(_, v)| v.as_str())
  35             .filter(|v| !v.is_empty())
  36     }
  37 
  38     fn attr(&self, key: &str) -> &str {
  39         self.attrs
  40             .iter()
  41             .find(|(k, _)| k == key)
  42             .map(|(_, v)| v.as_str())
  43             .unwrap_or("")
  44     }
  45 }
  46 
  47 #[derive(Clone, Copy, Debug, PartialEq)]
  48 enum Purpose {
  49     Copy,
  50     Reveal,
  51 }
  52 
  53 #[derive(Clone)]
  54 enum Cmd {
  55     Reload,
  56     /// Ask cce-keyring-sync for a pass and reload — the resident daemon
  57     /// when it runs, the one-shot binary otherwise (see `run_sync`).
  58     Sync,
  59     GetSecret { path: String, purpose: Purpose },
  60     CreateItem { label: String, attrs: Vec<(String, String)>, secret: String },
  61     UpdateItem { path: String, label: String, attrs: Vec<(String, String)>, secret: Option<String> },
  62     DeleteItem { path: String },
  63 }
  64 
  65 /// The sync daemon's answer to a one-time code request.
  66 #[derive(Clone, Debug, PartialEq)]
  67 enum OtpReply {
  68     /// The code and the seconds it has left.
  69     Code(String, u64),
  70     /// The item has no one-time password field.
  71     Absent,
  72     Failed(String),
  73 }
  74 
  75 /// The selected entry's one-time code, as far as the UI knows it.
  76 enum Otp {
  77     /// Asked; nothing to show yet.
  78     Pending,
  79     /// `refreshing`: expired and re-asked — the old code stays up meanwhile,
  80     /// at 0 s, rather than blinking out for the length of an `op` call.
  81     Code { code: String, until: std::time::Instant, refreshing: bool },
  82     /// Reported on the status line; not re-asked until the selection moves.
  83     Failed,
  84 }
  85 
  86 #[derive(Clone, Debug)]
  87 enum AppMessage {
  88     Loaded(Vec<EntryData>),
  89     Status(String, bool),
  90     /// A transient progress line ("Loading entries…"): shown unless a sync
  91     /// is in flight, and never the end of one.
  92     Progress(String),
  93     Revealed { path: String, secret: String },
  94     SelectPath(String),
  95     RefreshClicked,
  96     SyncClicked,
  97     RevealClicked,
  98     CopyClicked,
  99     CopyOtpClicked,
 100     Otp { path: String, reply: OtpReply },
 101     NewClicked,
 102     EditClicked,
 103     DeleteClicked,
 104     SaveClicked,
 105     CancelClicked,
 106 }
 107 
 108 /// What the detail pane shows: the read-only entry view, or the entry form
 109 /// (`path: None` = creating a new entry).
 110 enum Mode {
 111     Browse,
 112     Edit { path: Option<String> },
 113 }
 114 
 115 /// cce-keyring-sync's state file: `last_run` (unix seconds) and the last
 116 /// run's one-line outcome. The daemon's only channel back to this UI.
 117 fn sync_state_path() -> std::path::PathBuf {
 118     cce_ui::config::cce_state_dir().join("keyring-sync/state.json")
 119 }
 120 
 121 fn read_sync_state() -> Option<(i64, String)> {
 122     let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(sync_state_path()).ok()?).ok()?;
 123     let last_run = v.get("last_run")?.as_i64()?;
 124     let last_result = v.get("last_result").and_then(|r| r.as_str()).unwrap_or("").to_string();
 125     Some((last_run, last_result))
 126 }
 127 
 128 /// Ask the resident daemon for a pass now. Its `op` authorization is the
 129 /// live one (KEYRING-SYNC.md, phase 0), so this raises no dialog; a
 130 /// one-shot `cce-keyring-sync sync` from here would. Fire-and-forget: a
 131 /// save does not wait for the mirror. False when no daemon is running.
 132 async fn poke_sync_daemon() -> bool {
 133     let active = tokio::process::Command::new("systemctl")
 134         .args(["--user", "is-active", "--quiet", "cce-keyring-sync.service"])
 135         .status()
 136         .await
 137         .map(|s| s.success())
 138         .unwrap_or(false);
 139     if !active {
 140         return false;
 141     }
 142     tokio::process::Command::new("systemctl")
 143         .args(["--user", "kill", "-s", "SIGUSR1", "cce-keyring-sync.service"])
 144         .status()
 145         .await
 146         .map(|s| s.success())
 147         .unwrap_or(false)
 148 }
 149 
 150 /// The Sync button. With the daemon up: poke it and wait for its state
 151 /// file to record a new run, then show that run's outcome. Without it: the
 152 /// one-shot binary, whose summary line ("synced: …", "in sync") or
 153 /// refusal text is the status.
 154 /// Returns the status line (text, is_error). The caller shows it *after*
 155 /// the reload that follows a sync, or the reload's "N entries" would wipe
 156 /// it a frame later.
 157 async fn run_sync() -> (String, bool) {
 158     let before = read_sync_state().map(|(t, _)| t).unwrap_or(0);
 159     if poke_sync_daemon().await {
 160         // A pass is one `op item list` plus writes; a dialog nobody
 161         // answers holds it 60 s. Wait a little past that.
 162         for _ in 0..180 {
 163             tokio::time::sleep(std::time::Duration::from_millis(500)).await;
 164             if let Some((t, result)) = read_sync_state() {
 165                 if t > before {
 166                     let is_error = result.starts_with("failed");
 167                     let msg = if result.is_empty() { "synced".to_string() } else { result };
 168                     return (msg, is_error);
 169                 }
 170             }
 171         }
 172         return ("sync daemon did not report within 90s".to_string(), true);
 173     }
 174     let out = tokio::process::Command::new("cce-keyring-sync")
 175         .arg("sync")
 176         .output()
 177         .await;
 178     match out {
 179         Ok(out) => {
 180             let pick = |bytes: &[u8]| {
 181                 String::from_utf8_lossy(bytes)
 182                     .lines()
 183                     .rev()
 184                     .find(|l| !l.trim().is_empty())
 185                     .unwrap_or("")
 186                     .to_string()
 187             };
 188             if out.status.success() {
 189                 let line = pick(&out.stdout);
 190                 let msg = if line.is_empty() { "synced".to_string() } else { line };
 191                 return (msg, false);
 192             } else {
 193                 let line = pick(&out.stderr);
 194                 let msg = if line.is_empty() { "sync failed".to_string() } else { line };
 195                 return (msg, true);
 196             }
 197         }
 198         Err(e) => {
 199             return (format!("cce-keyring-sync not runnable: {e}"), true);
 200         }
 201     }
 202 }
 203 
 204 // ── One-time codes ────────────────────────────────────────────────────────
 205 //
 206 // Asked of the resident cce-keyring-sync daemon over its socket
 207 // (src/bin/cce-keyring-sync/serve.rs): it holds the session's `op`
 208 // authorization, so a code costs no Authorize dialog — this process running
 209 // `op` itself would raise one per launch. 1Password computes the code; the
 210 // seed never leaves it. Only entries carrying an `op-item` stamp (the ones
 211 // the sync pairs) can have one.
 212 
 213 fn otp_socket() -> Option<std::path::PathBuf> {
 214     // For a shadow test against a stand-in daemon: the shadow shares the
 215     // live runtime dir, and binding the real path would unseat the live one.
 216     if let Some(p) = std::env::var_os("CCE_KEYRING_SYNC_SOCK") {
 217         return Some(p.into());
 218     }
 219     let dir = std::env::var("XDG_RUNTIME_DIR").ok().filter(|s| !s.is_empty())?;
 220     Some(std::path::PathBuf::from(dir).join("cce/keyring-sync.sock"))
 221 }
 222 
 223 /// Blocking: run it off the UI thread. A request that has to raise the
 224 /// Authorize dialog (the daemon's authorization lapsed) waits out its 60 s.
 225 fn request_otp(item_id: &str) -> OtpReply {
 226     use std::io::{BufRead, Write};
 227     let Some(path) = otp_socket() else {
 228         return OtpReply::Failed("no XDG_RUNTIME_DIR".into());
 229     };
 230     let Ok(mut stream) = std::os::unix::net::UnixStream::connect(&path) else {
 231         return OtpReply::Failed("the sync daemon is not running (cce-keyring-sync.service)".into());
 232     };
 233     let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(80)));
 234     if writeln!(stream, "otp {item_id}").is_err() {
 235         return OtpReply::Failed("the sync daemon hung up".into());
 236     }
 237     let mut line = String::new();
 238     match std::io::BufReader::new(stream).read_line(&mut line) {
 239         Ok(n) if n > 0 => parse_otp_reply(line.trim_end()),
 240         _ => OtpReply::Failed("no answer from the sync daemon".into()),
 241     }
 242 }
 243 
 244 fn parse_otp_reply(line: &str) -> OtpReply {
 245     if line == "none" {
 246         return OtpReply::Absent;
 247     }
 248     if let Some(e) = line.strip_prefix("err ") {
 249         return OtpReply::Failed(e.to_string());
 250     }
 251     let mut parts = line.strip_prefix("otp ").unwrap_or("").split(' ');
 252     match (parts.next(), parts.next().and_then(|t| t.parse().ok())) {
 253         (Some(code), Some(left)) if !code.is_empty() => OtpReply::Code(code.to_string(), left),
 254         _ => OtpReply::Failed(format!("unreadable reply: {line}")),
 255     }
 256 }
 257 
 258 /// "123456" → "123 456"; any other length as is.
 259 fn group_code(code: &str) -> String {
 260     if code.len() == 6 && code.is_ascii() {
 261         format!("{} {}", &code[..3], &code[3..])
 262     } else {
 263         code.to_string()
 264     }
 265 }
 266 
 267 /// Put `text` on the clipboard, and take it off again after
 268 /// [`CLIPBOARD_CLEAR_SECS`].
 269 ///
 270 /// The clipboard is served by `wl-copy --foreground` under `timeout`, in a
 271 /// process group of its own, so the clear does not depend on this app: when
 272 /// `timeout` ends `wl-copy`, the offer it was serving goes with it. Until
 273 /// 2026-10-02 the clear was a thread in this process while a forked
 274 /// `wl-copy` kept serving the secret, so closing cce-secrets within the 30s
 275 /// left the password on the clipboard indefinitely. If something else is
 276 /// copied first, `wl-copy` has already exited on losing the selection, so
 277 /// the timer can never wipe the newer content. `--sensitive` sets the
 278 /// `x-kde-passwordManagerHint` that clipboard-history tools honour.
 279 ///
 280 /// Falls back to the old in-process clear when `timeout` or `wl-copy` is
 281 /// missing.
 282 fn copy_then_clear(text: String) {
 283     use std::os::unix::process::CommandExt;
 284     let spawned = std::process::Command::new("timeout")
 285         .arg(CLIPBOARD_CLEAR_SECS.to_string())
 286         .args(["wl-copy", "--foreground", "--sensitive"])
 287         .stdin(std::process::Stdio::piped())
 288         .stdout(std::process::Stdio::null())
 289         .stderr(std::process::Stdio::null())
 290         .process_group(0)
 291         .spawn();
 292     match spawned {
 293         Ok(mut child) => {
 294             // The secret goes in on stdin, never argv. The thread reaps the
 295             // child while this app lives; if the app exits first, the child
 296             // carries on and is reaped by whoever inherits it.
 297             std::thread::spawn(move || {
 298                 if let Some(mut stdin) = child.stdin.take() {
 299                     use std::io::Write;
 300                     let _ = stdin.write_all(text.as_bytes());
 301                 }
 302                 let _ = child.wait();
 303             });
 304         }
 305         Err(e) => {
 306             eprintln!("cce-secrets: timeout/wl-copy unavailable ({e}); clearing in-process");
 307             cce_ui::widget::clipboard::copy_to_clipboard(&text);
 308             std::thread::spawn(move || {
 309                 std::thread::sleep(std::time::Duration::from_secs(CLIPBOARD_CLEAR_SECS));
 310                 if cce_ui::widget::clipboard::read_from_clipboard().as_deref() == Some(text.as_str()) {
 311                     cce_ui::widget::clipboard::copy_to_clipboard("");
 312                 }
 313             });
 314         }
 315     }
 316 }
 317 
 318 // ── Secret Service worker ─────────────────────────────────────────────────
 319 //
 320 // The D-Bus session lives on its own thread (single-thread tokio runtime,
 321 // notifier pattern): the UI sends commands over an mpsc, results come back
 322 // through the calloop channel into update(). Copied secrets go straight to
 323 // the clipboard from here — only revealed ones cross to the UI at all.
 324 
 325 fn spawn_worker(rx: std::sync::mpsc::Receiver<Cmd>, tx: calloop::channel::Sender<AppMessage>) {
 326     std::thread::spawn(move || {
 327         let rt = match tokio::runtime::Builder::new_current_thread().enable_all().build() {
 328             Ok(rt) => rt,
 329             Err(e) => {
 330                 let _ = tx.send(AppMessage::Status(format!("tokio runtime failed: {e}"), true));
 331                 return;
 332             }
 333         };
 334         rt.block_on(async move {
 335             let mut ss = match SecretService::connect(EncryptionType::Dh).await {
 336                 Ok(ss) => ss,
 337                 Err(e) => {
 338                     let _ = tx.send(AppMessage::Status(
 339                         format!("Secret Service unavailable: {e} — is gnome-keyring running?"),
 340                         true,
 341                     ));
 342                     return;
 343                 }
 344             };
 345             load_entries(&ss, &tx).await;
 346             while let Ok(cmd) = rx.recv() {
 347                 match cmd {
 348                     Cmd::Reload => load_entries(&ss, &tx).await,
 349                     Cmd::Sync => {
 350                         let (msg, is_error) = run_sync().await;
 351                         load_entries(&ss, &tx).await;
 352                         let _ = tx.send(AppMessage::Status(msg, is_error));
 353                     }
 354                     op => {
 355                         let edits = matches!(op, Cmd::CreateItem { .. } | Cmd::UpdateItem { .. } | Cmd::DeleteItem { .. });
 356                         let Err(first) = run_secret_op(&ss, &tx, op.clone()).await else {
 357                             if edits {
 358                                 // A saved entry reaches 1Password on the
 359                                 // daemon's next pass; ask for it now.
 360                                 poke_sync_daemon().await;
 361                             }
 362                             continue;
 363                         };
 364                         // The daemon may have restarted underneath us
 365                         // (gnome-keyring aborted on a GLib assertion and was
 366                         // relaunched, 2026-09-06). Listing survives that, but
 367                         // the session negotiated at connect died with the old
 368                         // process, and every secret transfer names it — so the
 369                         // list looks fine while Copy/Reveal/Save fail. Take a
 370                         // fresh connection (new session) and try exactly once
 371                         // more; a failure on the retry is a real one.
 372                         log::info!("secret op failed ({first}); reconnecting to the Secret Service and retrying once");
 373                         match SecretService::connect(EncryptionType::Dh).await {
 374                             Ok(fresh) => {
 375                                 ss = fresh;
 376                                 if let Err(second) = run_secret_op(&ss, &tx, op).await {
 377                                     let _ = tx.send(AppMessage::Status(second, true));
 378                                 }
 379                             }
 380                             Err(e) => {
 381                                 let _ = tx.send(AppMessage::Status(
 382                                     format!("{first} (reconnect to Secret Service failed: {e})"),
 383                                     true,
 384                                 ));
 385                             }
 386                         }
 387                     }
 388                 }
 389             }
 390         });
 391     });
 392 }
 393 
 394 /// The session-bound commands: anything that transfers a secret (or edits
 395 /// an item) through the session opened at connect. `Err` is the status line
 396 /// to show; the caller decides whether to retry on a fresh connection first.
 397 async fn run_secret_op(
 398     ss: &SecretService<'_>,
 399     tx: &calloop::channel::Sender<AppMessage>,
 400     cmd: Cmd,
 401 ) -> Result<(), String> {
 402     match cmd {
 403         Cmd::Reload | Cmd::Sync => Ok(()),
 404         Cmd::GetSecret { path, purpose } => fetch_secret(ss, tx, path, purpose).await,
 405         Cmd::CreateItem { label, attrs, secret } => create_item(ss, tx, label, attrs, secret).await,
 406         Cmd::UpdateItem { path, label, attrs, secret } => {
 407             update_item(ss, tx, path, label, attrs, secret).await
 408         }
 409         Cmd::DeleteItem { path } => delete_item(ss, tx, path).await,
 410     }
 411 }
 412 
 413 async fn load_entries(ss: &SecretService<'_>, tx: &calloop::channel::Sender<AppMessage>) {
 414     let _ = tx.send(AppMessage::Progress("Loading entries…".to_string()));
 415     let collections = match ss.get_all_collections().await {
 416         Ok(c) => c,
 417         Err(e) => {
 418             let _ = tx.send(AppMessage::Status(format!("Listing collections failed: {e}"), true));
 419             return;
 420         }
 421     };
 422     let mut entries = Vec::new();
 423     for col in &collections {
 424         let label = col.get_label().await.unwrap_or_else(|_| "collection".to_string());
 425         // Locked collection: unlocking prompts through the Secret Service
 426         // provider (gnome-keyring raises its own dialog and this await blocks
 427         // until it's answered); a refused prompt just skips the collection.
 428         if col.is_locked().await.unwrap_or(false) {
 429             let _ = tx.send(AppMessage::Status(
 430                 format!("Unlock \"{label}\" to load its entries…"),
 431                 false,
 432             ));
 433             if col.unlock().await.is_err() || col.is_locked().await.unwrap_or(true) {
 434                 let _ = tx.send(AppMessage::Status(
 435                     format!("Collection \"{label}\" stayed locked — Refresh to retry"),
 436                     true,
 437                 ));
 438                 continue;
 439             }
 440         }
 441         let items = match col.get_all_items().await {
 442             Ok(i) => i,
 443             Err(e) => {
 444                 let _ = tx.send(AppMessage::Status(format!("Listing \"{label}\" failed: {e}"), true));
 445                 continue;
 446             }
 447         };
 448         for item in items {
 449             let mut attrs: Vec<(String, String)> = item
 450                 .get_attributes()
 451                 .await
 452                 .unwrap_or_default()
 453                 .into_iter()
 454                 .filter(|(k, _)| k != "xdg:schema")
 455                 .collect();
 456             attrs.sort();
 457             entries.push(EntryData {
 458                 path: item.item_path.to_string(),
 459                 label: item.get_label().await.unwrap_or_default(),
 460                 collection: label.clone(),
 461                 attrs,
 462             });
 463         }
 464     }
 465     entries.sort_by(|a, b| a.label.to_lowercase().cmp(&b.label.to_lowercase()));
 466     let _ = tx.send(AppMessage::Loaded(entries));
 467 }
 468 
 469 async fn fetch_secret(
 470     ss: &SecretService<'_>,
 471     tx: &calloop::channel::Sender<AppMessage>,
 472     path: String,
 473     purpose: Purpose,
 474 ) -> Result<(), String> {
 475     let item = resolve_item(ss, &path).await?;
 476     let _ = item.ensure_unlocked().await;
 477     let bytes = item
 478         .get_secret()
 479         .await
 480         .map_err(|e| format!("Secret fetch failed: {e}"))?;
 481     let secret = String::from_utf8_lossy(&bytes).to_string();
 482     match purpose {
 483         Purpose::Copy => {
 484             copy_then_clear(secret);
 485             let _ = tx.send(AppMessage::Status(
 486                 format!("Secret copied — clipboard clears in {CLIPBOARD_CLEAR_SECS} s"),
 487                 false,
 488             ));
 489         }
 490         Purpose::Reveal => {
 491             let _ = tx.send(AppMessage::Revealed { path, secret });
 492         }
 493     }
 494     Ok(())
 495 }
 496 
 497 async fn resolve_item<'a>(
 498     ss: &'a SecretService<'a>,
 499     path: &str,
 500 ) -> Result<secret_service::Item<'a>, String> {
 501     let opath = zbus::zvariant::OwnedObjectPath::try_from(path.to_string())
 502         .map_err(|e| format!("Bad item path: {e}"))?;
 503     ss.get_item_by_path(opath)
 504         .await
 505         .map_err(|e| format!("Item lookup failed: {e}"))
 506 }
 507 
 508 async fn create_item(
 509     ss: &SecretService<'_>,
 510     tx: &calloop::channel::Sender<AppMessage>,
 511     label: String,
 512     attrs: Vec<(String, String)>,
 513     secret: String,
 514 ) -> Result<(), String> {
 515     let collection = ss
 516         .get_default_collection()
 517         .await
 518         .map_err(|e| format!("No default collection: {e}"))?;
 519     let _ = collection.ensure_unlocked().await;
 520     let attr_map = attrs.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
 521     let item = collection
 522         .create_item(&label, attr_map, secret.as_bytes(), false, "text/plain")
 523         .await
 524         .map_err(|e| format!("Create failed: {e}"))?;
 525     let new_path = item.item_path.to_string();
 526     let _ = tx.send(AppMessage::Status(format!("Created \"{label}\""), false));
 527     load_entries(ss, tx).await;
 528     let _ = tx.send(AppMessage::SelectPath(new_path));
 529     Ok(())
 530 }
 531 
 532 /// What an edit leaves on an item: its current attributes with the form's
 533 /// fields laid over them. A field the form left empty is removed; every
 534 /// attribute the form does not show is kept as it is.
 535 ///
 536 /// Until 2026-10-02 a save wrote the form's fields ALONE, and the Secret
 537 /// Service's `set_attributes` replaces the whole set. On a 1Password-paired
 538 /// item that dropped `op-item` / `op-vault`, so the next sync saw a new
 539 /// keyring item and an untouched remote one: it created a bare duplicate in
 540 /// 1Password and archived the original with its one-time-code seed and
 541 /// custom fields. On another app's item ("Chrome Safe Storage") it dropped
 542 /// `xdg:schema` and the lookup attributes the app finds its secret by.
 543 fn merge_edited_attributes(
 544     mut current: std::collections::HashMap<String, String>,
 545     edits: &[(String, String)],
 546 ) -> std::collections::HashMap<String, String> {
 547     for (key, value) in edits {
 548         if value.is_empty() {
 549             current.remove(key);
 550         } else {
 551             current.insert(key.clone(), value.clone());
 552         }
 553     }
 554     current
 555 }
 556 
 557 async fn update_item(
 558     ss: &SecretService<'_>,
 559     tx: &calloop::channel::Sender<AppMessage>,
 560     path: String,
 561     label: String,
 562     attrs: Vec<(String, String)>,
 563     secret: Option<String>,
 564 ) -> Result<(), String> {
 565     let item = resolve_item(ss, &path).await?;
 566     let _ = item.ensure_unlocked().await;
 567     // Read before anything is written: `set_attributes` replaces the whole
 568     // set, so without the current attributes there is nothing safe to save.
 569     let current = item
 570         .get_attributes()
 571         .await
 572         .map_err(|e| format!("Reading the item's attributes failed: {e}"))?;
 573     item.set_label(&label)
 574         .await
 575         .map_err(|e| format!("Saving label failed: {e}"))?;
 576     let merged = merge_edited_attributes(current, &attrs);
 577     let attr_map = merged.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
 578     item.set_attributes(attr_map)
 579         .await
 580         .map_err(|e| format!("Saving attributes failed: {e}"))?;
 581     if let Some(secret) = secret {
 582         item.set_secret(secret.as_bytes(), "text/plain")
 583             .await
 584             .map_err(|e| format!("Saving secret failed: {e}"))?;
 585     }
 586     let _ = tx.send(AppMessage::Status(format!("Saved \"{label}\""), false));
 587     load_entries(ss, tx).await;
 588     let _ = tx.send(AppMessage::SelectPath(path));
 589     Ok(())
 590 }
 591 
 592 async fn delete_item(
 593     ss: &SecretService<'_>,
 594     tx: &calloop::channel::Sender<AppMessage>,
 595     path: String,
 596 ) -> Result<(), String> {
 597     let item = resolve_item(ss, &path).await?;
 598     item.delete().await.map_err(|e| format!("Delete failed: {e}"))?;
 599     let _ = tx.send(AppMessage::Status("Entry deleted".to_string(), false));
 600     load_entries(ss, tx).await;
 601     Ok(())
 602 }
 603 
 604 // ── The entry list's scrollbar ────────────────────────────────────────────
 605 
 606 /// Pointer slop either side of the bar's strip, as the toolkit's bars take.
 607 const BAR_SLOP: f32 = 4.0;
 608 /// The track stops this far short of each end of the list.
 609 const BAR_TRACK_INSET: f32 = 4.0;
 610 /// The shortest thumb, as the toolkit's bars draw it.
 611 const BAR_MIN_THUMB: f32 = 20.0;
 612 
 613 /// The entry list's scrollbar: the DE's one design (cce-ui/CLAUDE.md, "Every
 614 /// scrollbar rides a centre line, behind the plate") — down the CENTRE of the
 615 /// list's width, over the rows, pills in the shared track and thumb colours.
 616 /// Pure geometry, so it is tested without a window.
 617 #[derive(Clone, Copy, Debug, PartialEq)]
 618 struct ListBar {
 619     x: f32,
 620     w: f32,
 621     track_y: f32,
 622     track_h: f32,
 623     thumb_y: f32,
 624     thumb_h: f32,
 625 }
 626 
 627 impl ListBar {
 628     /// The bar for a list at `list` (x, y, w, h) holding `content_h` of rows
 629     /// scrolled to `scroll_y`, `w` thick; `None` when nothing overflows.
 630     fn of(list: (f32, f32, f32, f32), content_h: f32, scroll_y: f32, w: f32) -> Option<Self> {
 631         let (lx, ly, lw, lh) = list;
 632         if content_h <= lh || lh <= 0.0 {
 633             return None;
 634         }
 635         let track_y = ly + BAR_TRACK_INSET;
 636         let track_h = (lh - 2.0 * BAR_TRACK_INSET).max(0.0);
 637         let thumb_h = if track_h <= BAR_MIN_THUMB {
 638             track_h
 639         } else {
 640             (track_h * lh / content_h).clamp(BAR_MIN_THUMB, track_h)
 641         };
 642         let ratio = (scroll_y / (content_h - lh)).clamp(0.0, 1.0);
 643         Some(Self {
 644             x: lx + (lw - w) * 0.5,
 645             w,
 646             track_y,
 647             track_h,
 648             thumb_y: track_y + ratio * (track_h - thumb_h),
 649             thumb_h,
 650         })
 651     }
 652 
 653     /// The bar's strip, slop included — what a pointer over it means. The
 654     /// caller gates presses on the bar being RAISED: a sunk one is behind
 655     /// the list's plate and a press on its lane is a press on the row.
 656     fn hits(&self, px: f32, py: f32) -> bool {
 657         px >= self.x - BAR_SLOP
 658             && px <= self.x + self.w + BAR_SLOP
 659             && py >= self.track_y
 660             && py <= self.track_y + self.track_h
 661     }
 662 
 663     fn on_thumb(&self, py: f32) -> bool {
 664         py >= self.thumb_y && py <= self.thumb_y + self.thumb_h
 665     }
 666 
 667     /// The scroll offset that puts the thumb's top at `thumb_top`.
 668     fn scroll_for(&self, thumb_top: f32, max_scroll: f32) -> f32 {
 669         let span = self.track_h - self.thumb_h;
 670         if span <= 0.0 {
 671             return 0.0;
 672         }
 673         ((thumb_top - self.track_y) / span).clamp(0.0, 1.0) * max_scroll
 674     }
 675 
 676     /// Track then thumb, as pills, their colours' alpha scaled by `alpha`:
 677     /// 1 for the idle copy under the list's plate, the activity's fade for
 678     /// the fore copy over the rows.
 679     fn paint(&self, pc: &mut cce_ui::scene::paint::PaintCtx, alpha: f32) {
 680         use cce_ui::scene::layout::Rect;
 681         let a = alpha.clamp(0.0, 1.0);
 682         if a <= 0.001 {
 683             return;
 684         }
 685         let dim = |mut c: [f32; 4]| {
 686             c[3] *= a;
 687             c
 688         };
 689         let all = (true, true, true, true);
 690         let track = Rect { x: self.x, y: self.track_y, width: self.w, height: self.track_h };
 691         pc.rounded_rect(track, self.w.min(self.track_h) * 0.5, all, dim(cce_ui::color::scrollbar_track_color()));
 692         let thumb = Rect { x: self.x, y: self.thumb_y, width: self.w, height: self.thumb_h };
 693         pc.rounded_rect(thumb, self.w.min(self.thumb_h) * 0.5, all, dim(cce_ui::color::scrollbar_thumb_color()));
 694     }
 695 }
 696 
 697 /// One frame of the bar's raise/sink: true while the frame loop must keep
 698 /// drawing — the latch flipped, the fade is moving, or the hold is still
 699 /// running (the sink has to be ticked to, or a still list never sinks).
 700 fn tick_bar(activity: &mut ScrollbarActivity, dt: f32, overflowing: bool, dragging: bool) -> bool {
 701     activity.tick(dt, overflowing, dragging) || activity.holding()
 702 }
 703 
 704 // ── Application ───────────────────────────────────────────────────────────
 705 
 706 struct SecretsApp {
 707     search_box: Handle<cce_ui::widget::Adapted<TextBox>>,
 708     refresh_btn: Handle<cce_ui::widget::Adapted<Button>>,
 709     sync_btn: Handle<cce_ui::widget::Adapted<Button>>,
 710     new_btn: Handle<cce_ui::widget::Adapted<Button>>,
 711     reveal_btn: Handle<cce_ui::widget::Adapted<Button>>,
 712     copy_btn: Handle<cce_ui::widget::Adapted<Button>>,
 713     otp_btn: Handle<cce_ui::widget::Adapted<Button>>,
 714     edit_btn: Handle<cce_ui::widget::Adapted<Button>>,
 715     delete_btn: Handle<cce_ui::widget::Adapted<Button>>,
 716     save_btn: Handle<cce_ui::widget::Adapted<Button>>,
 717     cancel_btn: Handle<cce_ui::widget::Adapted<Button>>,
 718     // The entry form, top to bottom (Tab order).
 719     title_box: Handle<cce_ui::widget::Adapted<TextBox>>,
 720     user_box: Handle<cce_ui::widget::Adapted<TextBox>>,
 721     url_box: Handle<cce_ui::widget::Adapted<TextBox>>,
 722     notes_box: Handle<cce_ui::widget::Adapted<TextBox>>,
 723     pass_box: Handle<cce_ui::widget::Adapted<TextBox>>,
 724 
 725     mode: Mode,
 726     entries: Vec<EntryData>,
 727     /// Selected entry's object path (stable across reloads and filtering).
 728     selected: Option<String>,
 729     /// Revealed (path, secret); cleared on selection change and reload.
 730     revealed: Option<(String, String)>,
 731     /// Path armed for deletion by the first Delete click.
 732     pending_delete: Option<String>,
 733     /// (entry path, its one-time code) for the selected entry; `ensure_otp`
 734     /// keeps it following the selection and the code's 30 s period.
 735     otp: Option<(String, Otp)>,
 736     /// Entries the daemon said have no code: not asked again this run.
 737     otp_absent: std::collections::HashSet<String>,
 738     /// The countdown second last painted, so `tick` redraws once a second.
 739     otp_drawn_left: u64,
 740 
 741     /// The DRAWN list offset — `scroll_motion` glides it (wheel) or coasts
 742     /// it (trackpad flick); direct writes (Escape reset, clamp) are adopted
 743     /// by the motion on its next step.
 744     scroll_y: f32,
 745     scroll_motion: ScrollMotion,
 746     /// The list's scrollbar idles behind the list's plate and a scroll
 747     /// raises it ([`ListBar`]); this is its hold and fade.
 748     bar_activity: ScrollbarActivity,
 749     /// A thumb drag in progress: the pointer's offset from the thumb's top.
 750     bar_grab: Option<f32>,
 751     /// List viewport (x, y, w, h), refreshed each paint for hit-testing.
 752     list_rect: (f32, f32, f32, f32),
 753     pointer: (f32, f32),
 754     hover_row: Option<usize>,
 755 
 756     status_msg: String,
 757     status_is_error: bool,
 758     /// Right side of the status line: "synced 4m ago", off the sync tool's
 759     /// state file. Cached — the file is only re-read every few seconds.
 760     sync_hint: String,
 761     sync_hint_at: Option<std::time::Instant>,
 762     /// A Sync pass is in flight: the reload it ends with must not replace
 763     /// "Syncing…" with "N entries" before the result line arrives.
 764     syncing: bool,
 765 
 766     cmd_tx: std::sync::mpsc::Sender<Cmd>,
 767     cmd_rx: Option<std::sync::mpsc::Receiver<Cmd>>,
 768     sender: calloop::channel::Sender<AppMessage>,
 769     ui_context: cce_ui::context::UiContext,
 770 }
 771 
 772 /// A TextBox's live content: `edit_buffer` while editing (`text` only syncs
 773 /// on commit — TextBox landmine).
 774 fn live_text(tb: &cce_ui::widget::Adapted<TextBox>) -> &str {
 775     if tb.editing {
 776         &tb.edit_buffer
 777     } else {
 778         &tb.text
 779     }
 780 }
 781 
 782 impl SecretsApp {
 783     /// Indices into `entries` matching the search box, in display order.
 784     fn filtered(&self) -> Vec<usize> {
 785         let query = live_text(&self.ui_context[self.search_box]).to_lowercase();
 786         (0..self.entries.len())
 787             .filter(|&i| {
 788                 if query.is_empty() {
 789                     return true;
 790                 }
 791                 let e = &self.entries[i];
 792                 e.label.to_lowercase().contains(&query)
 793                     || e.collection.to_lowercase().contains(&query)
 794                     || e.attrs.iter().any(|(_, v)| v.to_lowercase().contains(&query))
 795             })
 796             .collect()
 797     }
 798 
 799     fn selected_entry(&self) -> Option<&EntryData> {
 800         let sel = self.selected.as_deref()?;
 801         self.entries.iter().find(|e| e.path == sel)
 802     }
 803 
 804     fn revealed_secret(&self) -> Option<&str> {
 805         let (path, secret) = self.revealed.as_ref()?;
 806         (self.selected.as_deref() == Some(path.as_str())).then_some(secret.as_str())
 807     }
 808 
 809     fn max_scroll(&self) -> f32 {
 810         (self.filtered().len() as f32 * ROW_H - self.list_rect.3).max(0.0)
 811     }
 812 
 813     /// The list's scrollbar as it stands, `None` while nothing overflows.
 814     fn list_scrollbar(&self) -> Option<ListBar> {
 815         ListBar::of(
 816             self.list_rect,
 817             self.filtered().len() as f32 * ROW_H,
 818             self.scroll_y,
 819             cce_ui::layout::centred_scrollbar_width(),
 820         )
 821     }
 822 
 823     /// A direct write to `scroll_y` (a clamp, the Escape reset) is a scroll
 824     /// like any other: if it moved the list, the bar comes up.
 825     fn note_scroll_from(&mut self, before: f32) {
 826         if (self.scroll_y - before).abs() > 1e-3 {
 827             self.bar_activity.bump();
 828         }
 829     }
 830 
 831     /// Advance the wheel glide / flick coast; true while the offset is moving
 832     /// (the frame loop keeps drawing). Hover follows the rows under the pointer.
 833     fn tick_scroll(&mut self, dt: f32) -> bool {
 834         self.scroll_motion.reconcile(0.0, self.scroll_y);
 835         if !self.scroll_motion.is_animating() {
 836             return false;
 837         }
 838         let moved = self.scroll_motion.tick(dt, Bounds::max(0.0), Bounds::max(self.max_scroll()));
 839         self.scroll_y = self.scroll_motion.y.pos();
 840         if moved {
 841             // A glide or coast in motion keeps the bar raised.
 842             self.bar_activity.bump();
 843             self.hover_row = self.row_at(self.pointer.0, self.pointer.1);
 844         }
 845         moved || self.scroll_motion.is_animating()
 846     }
 847 
 848     fn row_at(&self, px: f32, py: f32) -> Option<usize> {
 849         let (lx, ly, lw, lh) = self.list_rect;
 850         if px < lx || px > lx + lw || py < ly || py > ly + lh {
 851             return None;
 852         }
 853         let row = ((py - ly + self.scroll_y) / ROW_H).floor();
 854         (row >= 0.0 && (row as usize) < self.filtered().len()).then_some(row as usize)
 855     }
 856 
 857     fn editing(&self) -> bool {
 858         matches!(self.mode, Mode::Edit { .. })
 859     }
 860 
 861     /// The code to show for the selected entry, with its seconds left.
 862     fn shown_otp(&self) -> Option<(&str, u64)> {
 863         let sel = self.selected.as_deref()?;
 864         match &self.otp {
 865             Some((path, Otp::Code { code, until, .. })) if path == sel => {
 866                 let left = until.saturating_duration_since(std::time::Instant::now()).as_secs_f32().ceil() as u64;
 867                 Some((code.as_str(), left))
 868             }
 869             _ => None,
 870         }
 871     }
 872 
 873     /// Keep `otp` on the selected entry: ask the daemon when the selection
 874     /// lands on a paired entry, and again when the code runs out. True when
 875     /// anything visible changed.
 876     fn ensure_otp(&mut self) -> bool {
 877         let want = self
 878             .selected_entry()
 879             .filter(|e| !e.attr("op-item").is_empty() && !self.otp_absent.contains(&e.path))
 880             .map(|e| (e.path.clone(), e.attr("op-item").to_string()));
 881         let Some((path, item_id)) = want.filter(|_| !self.editing()) else {
 882             return self.otp.take().is_some();
 883         };
 884         let ask = match &mut self.otp {
 885             Some((p, _)) if *p != path => true,
 886             None => true,
 887             Some((_, Otp::Code { until, refreshing, .. })) => {
 888                 if !*refreshing && std::time::Instant::now() >= *until {
 889                     *refreshing = true;
 890                     true
 891                 } else {
 892                     false
 893                 }
 894             }
 895             Some((_, Otp::Pending | Otp::Failed)) => false,
 896         };
 897         if !ask {
 898             return false;
 899         }
 900         let changed = !matches!(&self.otp, Some((p, Otp::Code { .. })) if *p == path);
 901         if changed {
 902             self.otp = Some((path.clone(), Otp::Pending));
 903         }
 904         let tx = self.sender.clone();
 905         std::thread::spawn(move || {
 906             let reply = request_otp(&item_id);
 907             let _ = tx.send(AppMessage::Otp { path, reply });
 908         });
 909         changed
 910     }
 911 
 912     fn form_boxes(&self) -> [Handle<cce_ui::widget::Adapted<TextBox>>; 5] {
 913         [
 914             self.title_box,
 915             self.user_box,
 916             self.url_box,
 917             self.notes_box,
 918             self.pass_box,
 919         ]
 920     }
 921 
 922     /// Open the form prefilled from `entry` (or blank for a new one).
 923     fn open_form(&mut self, entry: Option<&EntryData>) {
 924         let (title, user, url, notes) = match entry {
 925             Some(e) => (e.label.clone(), e.attr("UserName").to_string(), e.attr("URL").to_string(), e.attr("Notes").to_string()),
 926             None => Default::default(),
 927         };
 928         self.ui_context[self.title_box].set_value(&title);
 929         self.ui_context[self.user_box].set_value(&user);
 930         self.ui_context[self.url_box].set_value(&url);
 931         self.ui_context[self.notes_box].set_value(&notes);
 932         self.ui_context[self.pass_box].set_value("");
 933         self.ui_context[self.pass_box].placeholder = Some(
 934             if entry.is_some() { "leave blank to keep current" } else { "password" }.to_string(),
 935         );
 936         self.mode = Mode::Edit { path: entry.map(|e| e.path.clone()) };
 937         self.pending_delete = None;
 938         self.revealed = None;
 939         for b in self.form_boxes() {
 940             self.ui_context[b].unfocus();
 941         }
 942         self.ui_context[self.title_box].focus();
 943     }
 944 
 945     fn close_form(&mut self) {
 946         for b in self.form_boxes() {
 947             self.ui_context[b].unfocus();
 948         }
 949         self.mode = Mode::Browse;
 950     }
 951 
 952     /// Gather the form into a save command; errors go straight to the status line.
 953     fn save_form(&mut self) -> Option<Cmd> {
 954         let title = live_text(&self.ui_context[self.title_box]).trim().to_string();
 955         if title.is_empty() {
 956             self.status_msg = "Title is required".to_string();
 957             self.status_is_error = true;
 958             return None;
 959         }
 960         let values = [&self.ui_context[self.user_box], &self.ui_context[self.url_box], &self.ui_context[self.notes_box]]
 961             .map(|b| live_text(b).trim().to_string());
 962         // Every edited field, empty ones included: an update removes what was
 963         // cleared (`merge_edited_attributes`); a new item just skips them.
 964         let attrs: Vec<(String, String)> = EDIT_ATTRS
 965             .iter()
 966             .zip(values)
 967             .map(|(k, v)| (k.to_string(), v))
 968             .collect();
 969         let password = live_text(&self.ui_context[self.pass_box]).to_string();
 970         let Mode::Edit { path } = &self.mode else { return None };
 971         Some(match path {
 972             Some(path) => Cmd::UpdateItem {
 973                 path: path.clone(),
 974                 label: title,
 975                 attrs,
 976                 secret: (!password.is_empty()).then_some(password),
 977             },
 978             None => Cmd::CreateItem {
 979                 label: title,
 980                 attrs: attrs.into_iter().filter(|(_, v)| !v.is_empty()).collect(),
 981                 secret: password,
 982             },
 983         })
 984     }
 985 
 986     /// "synced 4m ago" from cce-keyring-sync's state file, refreshed at most
 987     /// every 5s — the daemon ticks every 5 minutes, so staleness is invisible.
 988     fn refresh_sync_hint(&mut self) {
 989         if self.sync_hint_at.is_some_and(|t| t.elapsed().as_secs() < 5) {
 990             return;
 991         }
 992         self.sync_hint_at = Some(std::time::Instant::now());
 993         self.sync_hint = read_sync_state()
 994             .map(|(t, _)| t)
 995             .filter(|&t| t > 0)
 996             .map(|t| {
 997                 let ago = (std::time::SystemTime::now()
 998                     .duration_since(std::time::UNIX_EPOCH)
 999                     .map(|d| d.as_secs() as i64)
1000                     .unwrap_or(0)
1001                     - t)
1002                     .max(0);
1003                 match ago {
1004                     0..=90 => "synced just now".to_string(),
1005                     91..=5400 => format!("synced {}m ago", ago / 60),
1006                     _ => format!("synced {}h ago", ago / 3600),
1007                 }
1008             })
1009             .unwrap_or_default();
1010     }
1011 
1012     fn buttons(&self) -> [Handle<cce_ui::widget::Adapted<Button>>; 10] {
1013         [
1014             self.refresh_btn,
1015             self.sync_btn,
1016             self.new_btn,
1017             self.reveal_btn,
1018             self.copy_btn,
1019             self.otp_btn,
1020             self.edit_btn,
1021             self.delete_btn,
1022             self.save_btn,
1023             self.cancel_btn,
1024         ]
1025     }
1026 
1027     fn widgets_iter(&self) -> Vec<&dyn WidgetHost> {
1028         vec![
1029             &self.ui_context[self.search_box],
1030             &self.ui_context[self.refresh_btn],
1031             &self.ui_context[self.sync_btn],
1032             &self.ui_context[self.new_btn],
1033             &self.ui_context[self.reveal_btn],
1034             &self.ui_context[self.copy_btn],
1035             &self.ui_context[self.otp_btn],
1036             &self.ui_context[self.edit_btn],
1037             &self.ui_context[self.delete_btn],
1038             &self.ui_context[self.save_btn],
1039             &self.ui_context[self.cancel_btn],
1040             &self.ui_context[self.title_box],
1041             &self.ui_context[self.user_box],
1042             &self.ui_context[self.url_box],
1043             &self.ui_context[self.notes_box],
1044             &self.ui_context[self.pass_box],
1045         ]
1046     }
1047 }
1048 
1049 fn park(btn: &mut cce_ui::widget::Adapted<Button>) {
1050     btn.set_rect(-1000.0, -1000.0, BTN_W, cce_ui::layout::button_height());
1051 }
1052 
1053 fn srgb_u8(linear: [f32; 4]) -> [u8; 3] {
1054     let srgb = cce_ui::colors::to_srgb(linear);
1055     [
1056         (srgb[0] * 255.0) as u8,
1057         (srgb[1] * 255.0) as u8,
1058         (srgb[2] * 255.0) as u8,
1059     ]
1060 }
1061 
1062 impl Application for SecretsApp {
1063     type Message = AppMessage;
1064 
1065     fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
1066         Some(&self.ui_context)
1067     }
1068 
1069     fn create(sender: cce_ui::engine::AppSender<Self::Message>) -> Self {
1070         // The app keeps calloop's sender; `AppSender` converts into it.
1071         let sender: calloop::channel::Sender<Self::Message> = sender.into();
1072         let (cmd_tx, cmd_rx) = std::sync::mpsc::channel();
1073         // The context owns the widgets; the app keeps their handles.
1074         let mut ui_context = cce_ui::context::UiContext::new();
1075         Self {
1076             search_box: ui_context.insert(TextBox::new(String::new()).with_placeholder("Search")),
1077             refresh_btn: ui_context.insert(Button::new(0.0, 0.0, BTN_W, cce_ui::layout::button_height()).with_label("Refresh")),
1078             sync_btn: ui_context.insert(Button::new(0.0, 0.0, BTN_W, cce_ui::layout::button_height()).with_label("Sync")),
1079             new_btn: ui_context.insert(Button::new(0.0, 0.0, BTN_W, cce_ui::layout::button_height()).with_label("New")),
1080             reveal_btn: ui_context.insert(Button::new(0.0, 0.0, BTN_W, cce_ui::layout::button_height()).with_label("Reveal")),
1081             copy_btn: ui_context.insert(Button::new(0.0, 0.0, BTN_W, cce_ui::layout::button_height()).with_label("Copy")),
1082             otp_btn: ui_context.insert(Button::new(0.0, 0.0, BTN_W, cce_ui::layout::button_height()).with_label("Copy code")),
1083             edit_btn: ui_context.insert(Button::new(0.0, 0.0, BTN_W, cce_ui::layout::button_height()).with_label("Edit")),
1084             delete_btn: ui_context.insert(Button::new(0.0, 0.0, BTN_W, cce_ui::layout::button_height()).with_label("Delete")),
1085             save_btn: ui_context.insert(Button::new(0.0, 0.0, BTN_W, cce_ui::layout::button_height()).with_label("Save")),
1086             cancel_btn: ui_context.insert(Button::new(0.0, 0.0, BTN_W, cce_ui::layout::button_height()).with_label("Cancel")),
1087             title_box: ui_context.insert(TextBox::new(String::new()).with_placeholder("title")),
1088             user_box: ui_context.insert(TextBox::new(String::new()).with_placeholder("username")),
1089             url_box: ui_context.insert(TextBox::new(String::new()).with_placeholder("url")),
1090             notes_box: ui_context.insert(TextBox::new(String::new()).with_placeholder("notes")),
1091             pass_box: ui_context.insert(TextBox::new(String::new()).with_password(true).with_placeholder("password")),
1092             mode: Mode::Browse,
1093             entries: Vec::new(),
1094             selected: None,
1095             revealed: None,
1096             pending_delete: None,
1097             otp: None,
1098             otp_absent: std::collections::HashSet::new(),
1099             otp_drawn_left: 0,
1100             scroll_y: 0.0,
1101             scroll_motion: ScrollMotion::new(),
1102             bar_activity: ScrollbarActivity::new(),
1103             bar_grab: None,
1104             // Placed by the first frame; empty until then so nothing hit-tests.
1105             list_rect: (0.0, 0.0, 0.0, 0.0),
1106             pointer: (0.0, 0.0),
1107             hover_row: None,
1108             status_msg: "Connecting to Secret Service…".to_string(),
1109             status_is_error: false,
1110             sync_hint: String::new(),
1111             sync_hint_at: None,
1112             syncing: false,
1113             cmd_tx,
1114             cmd_rx: Some(cmd_rx),
1115             sender,
1116             ui_context,
1117         }
1118     }
1119 
1120     fn settings(&self) -> WindowSettings {
1121         WindowSettings {
1122             title: "CCE Secrets".to_string(),
1123             app_id: "cce-secrets".to_string(),
1124             width: 760,
1125             height: 520,
1126             fullscreen: false,
1127             min_size: Some((560, 380)),
1128         }
1129     }
1130 
1131     fn register_sources(&mut self, _handle: &calloop::LoopHandle<'_, EngineState<Self>>) {
1132         if let Some(rx) = self.cmd_rx.take() {
1133             spawn_worker(rx, self.sender.clone());
1134         }
1135     }
1136 
1137     fn update(&mut self, msg: Self::Message, needs_rebuild: &mut bool, _exit: &mut bool) {
1138         *needs_rebuild = true;
1139         match msg {
1140             AppMessage::Loaded(entries) => {
1141                 self.entries = entries;
1142                 self.revealed = None;
1143                 self.pending_delete = None;
1144                 if self.selected_entry().is_none() {
1145                     self.selected = None;
1146                 }
1147                 let before = self.scroll_y;
1148                 self.scroll_y = self.scroll_y.clamp(0.0, self.max_scroll());
1149                 self.note_scroll_from(before);
1150                 if self.syncing {
1151                     return;
1152                 }
1153                 self.status_msg = if self.entries.is_empty() {
1154                     "No entries — run `cce-keyring-sync adopt --vault <name>` to seed the keyring from 1Password".to_string()
1155                 } else {
1156                     format!("{} entries", self.entries.len())
1157                 };
1158                 self.status_is_error = false;
1159             }
1160             AppMessage::Status(msg, is_error) => {
1161                 self.syncing = false;
1162                 self.status_msg = msg;
1163                 self.status_is_error = is_error;
1164             }
1165             AppMessage::Progress(msg) => {
1166                 if !self.syncing {
1167                     self.status_msg = msg;
1168                     self.status_is_error = false;
1169                 }
1170             }
1171             AppMessage::Revealed { path, secret } => {
1172                 if self.selected.as_deref() == Some(path.as_str()) {
1173                     self.revealed = Some((path, secret));
1174                 }
1175             }
1176             AppMessage::SelectPath(path) => {
1177                 self.selected = Some(path);
1178                 self.revealed = None;
1179                 self.pending_delete = None;
1180             }
1181             AppMessage::RefreshClicked => {
1182                 let _ = self.cmd_tx.send(Cmd::Reload);
1183             }
1184             AppMessage::SyncClicked => {
1185                 self.syncing = true;
1186                 self.status_msg = "Syncing…".to_string();
1187                 self.status_is_error = false;
1188                 let _ = self.cmd_tx.send(Cmd::Sync);
1189             }
1190             AppMessage::RevealClicked => {
1191                 if let Some(sel) = self.selected.clone() {
1192                     if self.revealed_secret().is_some() {
1193                         self.revealed = None;
1194                     } else {
1195                         let _ = self.cmd_tx.send(Cmd::GetSecret { path: sel, purpose: Purpose::Reveal });
1196                     }
1197                 }
1198             }
1199             AppMessage::CopyClicked => {
1200                 if let Some(sel) = self.selected.clone() {
1201                     let _ = self.cmd_tx.send(Cmd::GetSecret { path: sel, purpose: Purpose::Copy });
1202                 }
1203             }
1204             AppMessage::CopyOtpClicked => {
1205                 if let Some((code, _)) = self.shown_otp() {
1206                     copy_then_clear(code.to_string());
1207                     self.status_msg = format!("Code copied — clipboard clears in {CLIPBOARD_CLEAR_SECS} s");
1208                     self.status_is_error = false;
1209                 }
1210             }
1211             AppMessage::Otp { path, reply } => {
1212                 // A reply for an entry no longer selected is dropped; the
1213                 // next selection asks afresh.
1214                 if self.otp.as_ref().is_none_or(|(p, _)| *p != path) {
1215                     return;
1216                 }
1217                 self.otp = match reply {
1218                     OtpReply::Code(code, left) => Some((
1219                         path,
1220                         Otp::Code {
1221                             code,
1222                             until: std::time::Instant::now() + std::time::Duration::from_secs(left),
1223                             refreshing: false,
1224                         },
1225                     )),
1226                     OtpReply::Absent => {
1227                         self.otp_absent.insert(path);
1228                         None
1229                     }
1230                     OtpReply::Failed(e) => {
1231                         self.status_msg = format!("One-time code: {e}");
1232                         self.status_is_error = true;
1233                         Some((path, Otp::Failed))
1234                     }
1235                 };
1236             }
1237             AppMessage::NewClicked => self.open_form(None),
1238             AppMessage::EditClicked => {
1239                 if let Some(entry) = self.selected_entry().cloned() {
1240                     self.open_form(Some(&entry));
1241                 }
1242             }
1243             AppMessage::DeleteClicked => {
1244                 if let Some(sel) = self.selected.clone() {
1245                     if self.pending_delete.as_deref() == Some(sel.as_str()) {
1246                         self.pending_delete = None;
1247                         self.status_msg = "Deleting…".to_string();
1248                         self.status_is_error = false;
1249                         let _ = self.cmd_tx.send(Cmd::DeleteItem { path: sel });
1250                     } else {
1251                         self.pending_delete = Some(sel);
1252                         self.status_msg = "Click Confirm to delete this entry".to_string();
1253                         self.status_is_error = false;
1254                     }
1255                 }
1256             }
1257             AppMessage::SaveClicked => {
1258                 if let Some(cmd) = self.save_form() {
1259                     self.status_msg = "Saving…".to_string();
1260                     self.status_is_error = false;
1261                     let _ = self.cmd_tx.send(cmd);
1262                     self.close_form();
1263                 }
1264             }
1265             AppMessage::CancelClicked => self.close_form(),
1266         }
1267     }
1268 
1269     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
1270         if self.tick_scroll(dt) {
1271             *needs_rebuild = true;
1272         }
1273         let overflowing = self.filtered().len() as f32 * ROW_H > self.list_rect.3;
1274         if tick_bar(&mut self.bar_activity, dt, overflowing, self.bar_grab.is_some()) {
1275             *needs_rebuild = true;
1276         }
1277         if self.ensure_otp() {
1278             *needs_rebuild = true;
1279         }
1280         if self.shown_otp().is_some_and(|(_, left)| left != self.otp_drawn_left) {
1281             *needs_rebuild = true;
1282         }
1283     }
1284 
1285     /// While a code is up, wake often enough to step its countdown (tick
1286     /// dt is not wall clock; the deadline is an `Instant`).
1287     fn idle_poll_interval(&self) -> Option<std::time::Duration> {
1288         self.shown_otp().map(|_| std::time::Duration::from_millis(250))
1289     }
1290 
1291     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
1292         use cce_ui::scene::layout::Rect;
1293         cce_ui::scale::set_scale_factor(scale as f32);
1294         let sw = size.width as f32;
1295         let sh = size.height as f32;
1296 
1297         let mut pc = cce_ui::scene::paint::PaintCtx::new();
1298         let quad = |pc: &mut cce_ui::scene::paint::PaintCtx, x: f32, y: f32, w: f32, h: f32, c: [f32; 4]| {
1299             pc.quad(Rect { x, y, width: w, height: h }, c);
1300         };
1301 
1302         // The standard root plate (cce-ui PlateSpec::window): the DE root
1303         // material at its opacity, the shared silhouette arc, the rolled rim.
1304         pc.root_plate(sw, sh);
1305 
1306         // Spacing is the ladder (cce-ui/CLAUDE.md): the window edge is
1307         // `inset`, siblings on the root plate stand `gap` apart, and inside
1308         // the two panes content sits `pad` off the rim with `pgap` between
1309         // blocks. The literals that remain are sizes and text line advances.
1310         let inset = cce_ui::layout::root_plate_inset();
1311         let gap = cce_ui::layout::root_plate_gap();
1312         let pad = cce_ui::layout::plate_padding();
1313         let pgap = cce_ui::layout::plate_gap();
1314         let btn_h = cce_ui::layout::button_height();
1315         let box_h = cce_ui::layout::textbox_height();
1316 
1317         // ── Left panel: search + entry list ──
1318         self.ui_context[self.search_box].set_rect(inset, inset, LIST_W, box_h);
1319         self.ui_context[self.refresh_btn].set_rect(sw - inset - BTN_W, inset, BTN_W, btn_h);
1320         self.ui_context[self.new_btn].set_rect(sw - inset - BTN_W * 2.0 - gap, inset, BTN_W, btn_h);
1321         self.ui_context[self.sync_btn].set_rect(sw - inset - BTN_W * 3.0 - 2.0 * gap, inset, BTN_W, btn_h);
1322 
1323         // Below the taller of the search box and the button row beside it.
1324         let list_y = inset + box_h.max(btn_h) + gap;
1325         let list_h = (sh - list_y - STATUS_H - gap).max(0.0);
1326         self.list_rect = (inset, list_y, LIST_W, list_h);
1327         // The scrollbar's idle copy, at full alpha UNDER the list's
1328         // translucent plate, every frame — raised or not, since the fore copy
1329         // fades in over it and dropping this at the latch would blink the bar.
1330         let bar = self.list_scrollbar();
1331         if let Some(bar) = bar {
1332             bar.paint(&mut pc, 1.0);
1333         }
1334         quad(&mut pc, inset, list_y, LIST_W, list_h, cce_ui::color::list_bg_color());
1335 
1336         let filtered = self.filtered();
1337         let list_bounds = Some([inset, list_y, inset + LIST_W, list_y + list_h]);
1338         for (row, &ei) in filtered.iter().enumerate() {
1339             let ry = list_y + row as f32 * ROW_H - self.scroll_y;
1340             if ry + ROW_H < list_y || ry > list_y + list_h {
1341                 continue;
1342             }
1343             let entry = &self.entries[ei];
1344             let is_selected = self.selected.as_deref() == Some(entry.path.as_str());
1345             if is_selected {
1346                 quad(&mut pc, inset, ry, LIST_W, ROW_H, [0.10, 0.28, 0.17, 1.0]);
1347             } else if self.hover_row == Some(row) {
1348                 quad(&mut pc, inset, ry, LIST_W, ROW_H, [1.0, 1.0, 1.0, 0.04]);
1349             }
1350             // TODO(style): the two text lines sit at fixed offsets inside the
1351             // ROW_H row — a line rhythm, not a rung.
1352             pc.text_with(
1353                 entry.label.clone(),
1354                 inset + pad,
1355                 ry + 8.0,
1356                 12.0,
1357                 srgb_u8(cce_ui::colors::TEXT_HEADER),
1358                 None,
1359                 list_bounds,
1360             );
1361             if let Some(hint) = entry.hint() {
1362                 pc.text_with(
1363                     hint.to_string(),
1364                     inset + pad,
1365                     ry + 25.0,
1366                     10.0,
1367                     srgb_u8(cce_ui::colors::TEXT_DIM),
1368                     None,
1369                     list_bounds,
1370                 );
1371             }
1372         }
1373 
1374         // The scrollbar's fore copy, over the rows at the activity's fade:
1375         // a scroll raises it out of the plate, and it sinks back once idle.
1376         if let Some(bar) = bar {
1377             bar.paint(&mut pc, self.bar_activity.fade());
1378         }
1379 
1380         // ── Right panel: detail view or the entry form ──
1381         let dx = inset + LIST_W + gap;
1382         let dw = (sw - dx - inset).max(0.0);
1383         let detail_bounds = Some([dx, list_y, dx + dw, list_y + list_h]);
1384         // The pane's content starts one plate padding below its top; the
1385         // 22.0 under the 15px header is that line's advance, not a rung.
1386         let hy = list_y + pad;
1387         match &self.mode {
1388             Mode::Edit { path } => {
1389                 let header = if path.is_some() { "Edit entry" } else { "New entry" };
1390                 pc.text_with(header.to_string(), dx, hy, 15.0, srgb_u8(cce_ui::colors::TEXT_HEADER), None, detail_bounds);
1391                 let labels = ["Title", "UserName", "URL", "Notes", "Password"];
1392                 let mut fy = hy + 22.0 + pgap;
1393                 let box_w = (dw - 4.0).min(320.0); // TODO(style): 4px slack on the field width, not a rung
1394                 // Each field is a 10px label strip (14.0) over a textbox. The
1395                 // fields are `pgap` apart rather than `control_gap()`: five
1396                 // control-height gaps overrun the default window height.
1397                 for (label, tb) in labels.iter().zip(self.form_boxes()) {
1398                     self.ui_context[tb].set_rect(dx, fy + 14.0, box_w, box_h);
1399                     pc.text_with(label.to_string(), dx, fy, 10.0, srgb_u8(cce_ui::colors::TEXT_DIM), None, None);
1400                     fy += 14.0 + box_h + pgap;
1401                 }
1402                 self.ui_context[self.save_btn].set_rect(dx, fy, BTN_W, btn_h);
1403                 self.ui_context[self.cancel_btn].set_rect(dx + BTN_W + pgap, fy, BTN_W, btn_h);
1404                 for b in [self.reveal_btn, self.copy_btn, self.otp_btn, self.edit_btn, self.delete_btn, self.new_btn] {
1405                     park(&mut self.ui_context[b]);
1406                 }
1407             }
1408             Mode::Browse => {
1409                 park(&mut self.ui_context[self.save_btn]);
1410                 park(&mut self.ui_context[self.cancel_btn]);
1411                 for tb in self.form_boxes() {
1412                     self.ui_context[tb].set_rect(-1000.0, -1000.0, 10.0, 10.0);
1413                 }
1414                 if let Some(entry) = self.selected_entry().cloned() {
1415                     pc.text_with(entry.label.clone(), dx, hy, 15.0, srgb_u8(cce_ui::colors::TEXT_HEADER), None, detail_bounds);
1416                     pc.text_with(entry.collection.clone(), dx, hy + 22.0, 10.0, srgb_u8(cce_ui::colors::TEXT_DIM), None, detail_bounds);
1417 
1418                     // The header block (a 15px line, a 10px line), a pane gap,
1419                     // then the attribute rows at their 22.0 line advance.
1420                     let mut ay = hy + 22.0 + 14.0 + pgap;
1421                     for (key, value) in &entry.attrs {
1422                         pc.text_with(key.clone(), dx, ay, 10.0, srgb_u8(cce_ui::colors::TEXT_DIM), None, detail_bounds);
1423                         pc.text_with(value.clone(), dx + 120.0, ay, 11.0, srgb_u8(cce_ui::colors::TEXT_FG), None, detail_bounds);
1424                         ay += 22.0;
1425                     }
1426 
1427                     ay += pgap;
1428                     pc.text_with("secret".to_string(), dx, ay, 10.0, srgb_u8(cce_ui::colors::TEXT_DIM), None, detail_bounds);
1429                     let (secret_text, revealed) = match self.revealed_secret() {
1430                         Some(s) => (s.to_string(), true),
1431                         None => ("••••••••••••".to_string(), false),
1432                     };
1433                     pc.text_with(secret_text, dx + 120.0, ay, 11.0, srgb_u8(cce_ui::colors::TEXT_FG), None, detail_bounds);
1434 
1435                     let otp = self.shown_otp().map(|(code, left)| (group_code(code), left));
1436                     if let Some((code, left)) = &otp {
1437                         self.otp_drawn_left = *left;
1438                         ay += 22.0;
1439                         pc.text_with("one-time code".to_string(), dx, ay, 10.0, srgb_u8(cce_ui::colors::TEXT_DIM), None, detail_bounds);
1440                         pc.text_with(format!("{code}  ·  {left}s"), dx + 120.0, ay, 11.0, srgb_u8(cce_ui::colors::TEXT_FG), None, detail_bounds);
1441                     }
1442 
1443                     self.ui_context[self.reveal_btn].set_label(if revealed { "Hide" } else { "Reveal" });
1444                     self.ui_context[self.delete_btn].set_label(
1445                         if self.pending_delete.as_deref() == Some(entry.path.as_str()) { "Confirm" } else { "Delete" },
1446                     );
1447                     // Two button rows under the secret line (14.0, its advance).
1448                     let by = ay + 14.0 + pgap;
1449                     self.ui_context[self.reveal_btn].set_rect(dx, by, BTN_W, btn_h);
1450                     self.ui_context[self.copy_btn].set_rect(dx + BTN_W + pgap, by, BTN_W, btn_h);
1451                     self.ui_context[self.edit_btn].set_rect(dx, by + btn_h + pgap, BTN_W, btn_h);
1452                     self.ui_context[self.delete_btn].set_rect(dx + BTN_W + pgap, by + btn_h + pgap, BTN_W, btn_h);
1453                     // Copy code ends the first row when the pane is wide
1454                     // enough, else opens a third.
1455                     if otp.is_some() {
1456                         let third = dx + 2.0 * (BTN_W + pgap);
1457                         if third + BTN_W <= dx + dw {
1458                             self.ui_context[self.otp_btn].set_rect(third, by, BTN_W, btn_h);
1459                         } else {
1460                             self.ui_context[self.otp_btn].set_rect(dx, by + 2.0 * (btn_h + pgap), BTN_W, btn_h);
1461                         }
1462                     } else {
1463                         park(&mut self.ui_context[self.otp_btn]);
1464                     }
1465                 } else {
1466                     let hint = if self.entries.is_empty() { "" } else { "Select an entry" };
1467                     pc.text_with(hint.to_string(), dx, hy, 11.0, srgb_u8(cce_ui::colors::TEXT_DIM), None, detail_bounds);
1468                     for b in [self.reveal_btn, self.copy_btn, self.otp_btn, self.edit_btn, self.delete_btn] {
1469                         park(&mut self.ui_context[b]);
1470                     }
1471                 }
1472             }
1473         }
1474 
1475         // ── Widgets + status line ──
1476         // Each widget as it paints itself — plate, relief, text — through the
1477         // toolkit's walk. (Until 2026-10-08 they were drawn through the legacy
1478         // tuple views: a flat fill in `color()`, the plain quads, then the
1479         // text, so no control here had the relief every other app's has.)
1480         for w in self.widgets_iter() {
1481             cce_ui::scene::painter::paint_root_into(&self.ui_context, w, &mut pc);
1482         }
1483 
1484         let status_color = if self.status_is_error { [0xee, 0x5c, 0x5c] } else { srgb_u8(cce_ui::colors::TEXT_DIM) };
1485         pc.text_with(
1486             self.status_msg.clone(),
1487             inset,
1488             sh - STATUS_H + 6.0, // TODO(style): seats the 10px line in the STATUS_H band, not a rung
1489             10.0,
1490             status_color,
1491             None,
1492             Some([inset, sh - STATUS_H, sw - inset, sh]),
1493         );
1494         self.refresh_sync_hint();
1495         if !self.sync_hint.is_empty() {
1496             let w = cce_ui::widget::display::measure_text_width(&self.sync_hint, &cce_ui::layout::read_preferred_fonts().0, 10.0);
1497             pc.text_with(
1498                 self.sync_hint.clone(),
1499                 sw - inset - w,
1500                 sh - STATUS_H + 6.0,
1501                 10.0,
1502                 srgb_u8(cce_ui::colors::TEXT_DIM),
1503                 None,
1504                 Some([inset, sh - STATUS_H, sw - inset, sh]),
1505             );
1506         }
1507 
1508         Some(pc.finish())
1509     }
1510 
1511     fn display_list_text(&self) -> bool {
1512         true
1513     }
1514 
1515     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
1516         self.pointer = (pos.x, pos.y);
1517         let bar = self.list_scrollbar();
1518         // Hover only SUSTAINS a raised bar; the activity ignores it on a sunk one.
1519         self.bar_activity.set_hover(bar.is_some_and(|b| b.hits(pos.x, pos.y)));
1520         if let (Some(grab), Some(bar)) = (self.bar_grab, bar) {
1521             let before = self.scroll_y;
1522             self.scroll_y = bar.scroll_for(pos.y - grab, self.max_scroll());
1523             self.scroll_motion.y.jump_to(self.scroll_y);
1524             if (self.scroll_y - before).abs() > 1e-3 {
1525                 *needs_rebuild = true;
1526             }
1527         }
1528         // The shared context menu (a text box's) gets the pointer to itself
1529         // while open: its row highlight.
1530         if cce_ui::widget::context_menu::is_visible() {
1531             if cce_ui::widget::context_menu::cursor_moved(pos.x, pos.y) {
1532                 *needs_rebuild = true;
1533             }
1534             return;
1535         }
1536         let mv = cce_ui::widget::Event::PointerMove { x: pos.x, y: pos.y, local_x: pos.x, local_y: pos.y };
1537         let mut roots = vec![self.search_box.id()];
1538         roots.extend(self.buttons().map(|b| b.id()));
1539         roots.extend(self.form_boxes().map(|b| b.id()));
1540         for root in roots {
1541             if self.ui_context.propagate_event(&mv, root) {
1542                 *needs_rebuild = true;
1543             }
1544         }
1545         let hover = self.row_at(pos.x, pos.y);
1546         if hover != self.hover_row {
1547             self.hover_row = hover;
1548             *needs_rebuild = true;
1549         }
1550     }
1551 
1552     fn handle_mouse_input(
1553         &mut self,
1554         button: MouseButton,
1555         state: ElementState,
1556         pos: LogicalPosition,
1557         needs_rebuild: &mut bool,
1558     ) -> Option<Self::Message> {
1559         let (lx, ly) = (pos.x, pos.y);
1560         let ev = cce_ui::widget::Event::MouseButton { button, state, x: lx, y: ly, local_x: lx, local_y: ly };
1561 
1562         // A thumb drag ends wherever the button comes up; the release
1563         // refreshes the hold, as a scroll does.
1564         if button == MouseButton::Left && state == ElementState::Released && self.bar_grab.take().is_some() {
1565             self.bar_activity.bump();
1566             *needs_rebuild = true;
1567         }
1568 
1569         // The shared context menu a right-click on a text box opens takes every
1570         // click while open (after the release above, so a thumb drag still
1571         // ends): a row runs, a press anywhere else dismisses it. The toolkit
1572         // leaves this routing to the app; without it the menu could not be
1573         // closed by clicking outside it, and its rows did nothing.
1574         if cce_ui::widget::context_menu::is_visible() {
1575             if cce_ui::widget::context_menu::mouse_input(button, state, lx, ly, Some(&mut self.ui_context)) {
1576                 *needs_rebuild = true;
1577             }
1578             return None;
1579         }
1580 
1581         // Buttons: propagate, then drain clicks into messages.
1582         let button_roots: Vec<_> = {
1583             let bs = self.buttons();
1584             bs.iter().map(|b| b.id()).collect()
1585         };
1586         for root in button_roots {
1587             if self.ui_context.propagate_event(&ev, root) {
1588                 *needs_rebuild = true;
1589             }
1590         }
1591         if self.ui_context[self.refresh_btn].take_click() {
1592             return Some(AppMessage::RefreshClicked);
1593         }
1594         if self.ui_context[self.sync_btn].take_click() {
1595             return Some(AppMessage::SyncClicked);
1596         }
1597         if self.ui_context[self.new_btn].take_click() {
1598             return Some(AppMessage::NewClicked);
1599         }
1600         if self.ui_context[self.reveal_btn].take_click() {
1601             return Some(AppMessage::RevealClicked);
1602         }
1603         if self.ui_context[self.copy_btn].take_click() {
1604             return Some(AppMessage::CopyClicked);
1605         }
1606         if self.ui_context[self.otp_btn].take_click() {
1607             return Some(AppMessage::CopyOtpClicked);
1608         }
1609         if self.ui_context[self.edit_btn].take_click() {
1610             return Some(AppMessage::EditClicked);
1611         }
1612         if self.ui_context[self.delete_btn].take_click() {
1613             return Some(AppMessage::DeleteClicked);
1614         }
1615         if self.ui_context[self.save_btn].take_click() {
1616             return Some(AppMessage::SaveClicked);
1617         }
1618         if self.ui_context[self.cancel_btn].take_click() {
1619             return Some(AppMessage::CancelClicked);
1620         }
1621 
1622         // Text boxes: unfocus the ones the press missed, then propagate.
1623         let mut box_roots = vec![self.search_box.id()];
1624         if self.editing() {
1625             box_roots.extend(self.form_boxes().map(|b| b.id()));
1626         }
1627         if state == ElementState::Pressed {
1628             if !self.ui_context[self.search_box].hit_test(lx, ly, &self.ui_context) {
1629                 self.ui_context[self.search_box].unfocus();
1630             }
1631             if self.editing() {
1632                 if !self.ui_context[self.title_box].hit_test(lx, ly, &self.ui_context) {
1633                     self.ui_context[self.title_box].unfocus();
1634                 }
1635                 if !self.ui_context[self.user_box].hit_test(lx, ly, &self.ui_context) {
1636                     self.ui_context[self.user_box].unfocus();
1637                 }
1638                 if !self.ui_context[self.url_box].hit_test(lx, ly, &self.ui_context) {
1639                     self.ui_context[self.url_box].unfocus();
1640                 }
1641                 if !self.ui_context[self.notes_box].hit_test(lx, ly, &self.ui_context) {
1642                     self.ui_context[self.notes_box].unfocus();
1643                 }
1644                 if !self.ui_context[self.pass_box].hit_test(lx, ly, &self.ui_context) {
1645                     self.ui_context[self.pass_box].unfocus();
1646                 }
1647             }
1648         }
1649         for root in box_roots {
1650             if self.ui_context.propagate_event(&ev, root) {
1651                 *needs_rebuild = true;
1652             }
1653         }
1654 
1655         // The scrollbar takes a press only while RAISED: sunk, it is behind
1656         // the list's plate and the press is the row's. On the thumb it grabs
1657         // where it was pressed; on the track the thumb jumps under the pointer.
1658         if button == MouseButton::Left && state == ElementState::Pressed && self.bar_activity.raised() {
1659             if let Some(bar) = self.list_scrollbar().filter(|b| b.hits(lx, ly)) {
1660                 let grab = if bar.on_thumb(ly) { ly - bar.thumb_y } else { bar.thumb_h * 0.5 };
1661                 self.bar_grab = Some(grab);
1662                 self.scroll_y = bar.scroll_for(ly - grab, self.max_scroll());
1663                 self.scroll_motion.y.jump_to(self.scroll_y);
1664                 self.bar_activity.bump();
1665                 self.hover_row = self.row_at(lx, ly);
1666                 *needs_rebuild = true;
1667                 return None;
1668             }
1669         }
1670 
1671         // List selection only while browsing — the form keeps its state.
1672         if !self.editing() && button == MouseButton::Left && state == ElementState::Pressed {
1673             if let Some(row) = self.row_at(lx, ly) {
1674                 let filtered = self.filtered();
1675                 let path = self.entries[filtered[row]].path.clone();
1676                 if self.selected.as_deref() != Some(path.as_str()) {
1677                     self.selected = Some(path);
1678                     self.revealed = None;
1679                     self.pending_delete = None;
1680                     *needs_rebuild = true;
1681                 }
1682             }
1683         }
1684         None
1685     }
1686 
1687     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
1688         let (lx, ly, lw, lh) = self.list_rect;
1689         if pos.x < lx || pos.x > lx + lw || pos.y < ly || pos.y > ly + lh {
1690             return;
1691         }
1692         self.scroll_motion.reconcile(0.0, self.scroll_y);
1693         let moved = self.scroll_motion.apply(delta, (LINE_PX, LINE_PX), Bounds::max(0.0), Bounds::max(self.max_scroll()));
1694         self.scroll_y = self.scroll_motion.y.pos();
1695         // A wheel raises the bar, even one that meets the end of the list.
1696         self.bar_activity.bump();
1697         if moved {
1698             self.hover_row = self.row_at(self.pointer.0, self.pointer.1);
1699             *needs_rebuild = true;
1700         }
1701     }
1702 
1703     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Self::Message> {
1704         if event.state == ElementState::Pressed && !event.repeat {
1705             match &event.logical_key {
1706                 Key::Named(NamedKey::Escape) => {
1707                     if self.editing() {
1708                         return Some(AppMessage::CancelClicked);
1709                     }
1710                     // TextBox never tracks ctx focus — `editing` is its focus signal.
1711                     if self.ui_context[self.search_box].editing {
1712                         self.ui_context[self.search_box].text.clear();
1713                         self.ui_context[self.search_box].edit_buffer.clear();
1714                         self.ui_context[self.search_box].unfocus();
1715                         let before = self.scroll_y;
1716                         self.scroll_y = 0.0;
1717                         self.note_scroll_from(before);
1718                         *needs_rebuild = true;
1719                         return None;
1720                     }
1721                 }
1722                 Key::Named(NamedKey::Tab) if self.editing() => {
1723                     // TextBox never tracks ctx focus — `editing` is its focus signal.
1724                     let focused = [
1725                         self.ui_context[self.title_box].editing,
1726                         self.ui_context[self.user_box].editing,
1727                         self.ui_context[self.url_box].editing,
1728                         self.ui_context[self.notes_box].editing,
1729                         self.ui_context[self.pass_box].editing,
1730                     ]
1731                     .iter()
1732                     .position(|&f| f);
1733                     let next = focused.map(|i| (i + 1) % 5).unwrap_or(0);
1734                     for (i, tb) in self.form_boxes().into_iter().enumerate() {
1735                         if i == next {
1736                             self.ui_context[tb].focus();
1737                         } else {
1738                             self.ui_context[tb].unfocus();
1739                         }
1740                     }
1741                     *needs_rebuild = true;
1742                     return None;
1743                 }
1744                 Key::Named(NamedKey::Enter) if self.editing() => {
1745                     return Some(AppMessage::SaveClicked);
1746                 }
1747                 _ => {}
1748             }
1749         }
1750         let kev = cce_ui::widget::Event::KeyInput(event.clone());
1751         let mut roots = vec![self.search_box.id()];
1752         if self.editing() {
1753             roots.extend(self.form_boxes().map(|b| b.id()));
1754         }
1755         for root in roots {
1756             if self.ui_context.propagate_event(&kev, root) {
1757                 *needs_rebuild = true;
1758             }
1759         }
1760         let before = self.scroll_y;
1761         self.scroll_y = self.scroll_y.clamp(0.0, self.max_scroll());
1762         self.note_scroll_from(before);
1763         None
1764     }
1765 }
1766 
1767 fn main() {
1768     env_logger::init();
1769     cce_ui::engine::run::<SecretsApp>();
1770 }
1771 
1772 #[cfg(test)]
1773 mod tests {
1774     use super::*;
1775 
1776     #[test]
1777     fn an_edit_keeps_every_attribute_the_form_does_not_show() {
1778         let current: std::collections::HashMap<String, String> = [
1779             ("op-item", "abc123"),
1780             ("op-vault", "Personal"),
1781             ("xdg:schema", "org.freedesktop.Secret.Generic"),
1782             ("UserName", "old@example.org"),
1783             ("URL", "https://old.example.org"),
1784             ("Notes", "keep me"),
1785         ]
1786         .into_iter()
1787         .map(|(k, v)| (k.to_string(), v.to_string()))
1788         .collect();
1789         let edits = vec![
1790             ("UserName".to_string(), "new@example.org".to_string()),
1791             ("URL".to_string(), String::new()),
1792             ("Notes".to_string(), "keep me".to_string()),
1793         ];
1794         let merged = merge_edited_attributes(current, &edits);
1795         // The pairing and the schema survive: losing them is what duplicated
1796         // and archived 1Password items.
1797         assert_eq!(merged.get("op-item").map(String::as_str), Some("abc123"));
1798         assert_eq!(merged.get("op-vault").map(String::as_str), Some("Personal"));
1799         assert_eq!(merged.get("xdg:schema").map(String::as_str), Some("org.freedesktop.Secret.Generic"));
1800         // The form's fields are what it says: changed, cleared, unchanged.
1801         assert_eq!(merged.get("UserName").map(String::as_str), Some("new@example.org"));
1802         assert!(!merged.contains_key("URL"), "a cleared field is removed");
1803         assert_eq!(merged.get("Notes").map(String::as_str), Some("keep me"));
1804         assert_eq!(merged.len(), 5);
1805     }
1806 
1807     #[test]
1808     fn the_list_bar_rides_the_centre_line_and_only_when_it_overflows() {
1809         let list = (10.0, 100.0, 280.0, 200.0);
1810         assert_eq!(ListBar::of(list, 200.0, 0.0, 6.0), None, "nothing to scroll, no bar");
1811 
1812         // 800 px of rows in a 200 px list.
1813         let top = ListBar::of(list, 800.0, 0.0, 6.0).unwrap();
1814         assert!((top.x + top.w * 0.5 - (10.0 + 140.0)).abs() < 1e-4, "down the list's centre line");
1815         assert_eq!((top.track_y, top.track_h), (104.0, 192.0), "the track stops 4 px short of each end");
1816         assert!((top.thumb_h - 48.0).abs() < 1e-4, "a quarter of the rows is a quarter of the track");
1817         assert_eq!(top.thumb_y, top.track_y);
1818 
1819         let end = ListBar::of(list, 800.0, 600.0, 6.0).unwrap();
1820         assert!((end.thumb_y + end.thumb_h - (end.track_y + end.track_h)).abs() < 1e-4, "scrolled to the end");
1821         assert!((end.scroll_for(end.thumb_y, 600.0) - 600.0).abs() < 1e-3);
1822         assert!((top.scroll_for(top.thumb_y, 600.0)).abs() < 1e-3);
1823 
1824         // A long list keeps the toolkit's shortest thumb.
1825         let long = ListBar::of(list, 100_000.0, 0.0, 6.0).unwrap();
1826         assert_eq!(long.thumb_h, BAR_MIN_THUMB);
1827 
1828         // The strip with its slop, and nothing past the track's ends.
1829         assert!(top.hits(top.x - BAR_SLOP, 150.0));
1830         assert!(top.hits(top.x + top.w + BAR_SLOP, 150.0));
1831         assert!(!top.hits(top.x - BAR_SLOP - 1.0, 150.0));
1832         assert!(!top.hits(top.x + 1.0, top.track_y - 1.0));
1833     }
1834 
1835     #[test]
1836     fn the_list_bar_keeps_frames_coming_until_it_has_sunk() {
1837         let mut a = ScrollbarActivity::new();
1838         // Hover never raises a sunk bar.
1839         a.set_hover(true);
1840         assert!(!tick_bar(&mut a, 0.016, true, false));
1841         assert!(!a.raised());
1842         a.set_hover(false);
1843 
1844         // A scroll raises it, and frames keep coming until the sink has
1845         // faded all the way out — then they stop.
1846         a.bump();
1847         let mut frames = 0;
1848         while tick_bar(&mut a, 0.016, true, false) {
1849             frames += 1;
1850             assert!(frames < 1000, "the bar never settled");
1851         }
1852         assert!(frames > 0);
1853         assert!(!a.raised());
1854         assert_eq!(a.fade(), 0.0);
1855 
1856         // A pointer over a RAISED bar holds it up past the hold.
1857         a.bump();
1858         tick_bar(&mut a, 0.016, true, false);
1859         assert!(a.raised());
1860         a.set_hover(true);
1861         for _ in 0..200 {
1862             tick_bar(&mut a, 0.016, true, false);
1863         }
1864         assert!(a.raised(), "hover sustains a raised bar");
1865     }
1866 
1867     #[test]
1868     fn daemon_replies_parse() {
1869         assert_eq!(parse_otp_reply("otp 123456 17"), OtpReply::Code("123456".into(), 17));
1870         assert_eq!(parse_otp_reply("none"), OtpReply::Absent);
1871         assert_eq!(parse_otp_reply("err not a mirrored item"), OtpReply::Failed("not a mirrored item".into()));
1872         assert!(matches!(parse_otp_reply("otp 123456"), OtpReply::Failed(_)));
1873         assert!(matches!(parse_otp_reply(""), OtpReply::Failed(_)));
1874         assert_eq!(group_code("123456"), "123 456");
1875         assert_eq!(group_code("12345678"), "12345678");
1876     }
1877 }