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