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(¬es);
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 }