hephaestus/crates/heph-quickadd/src/app.rs
Erich Blume 911255fece style: cargo fmt — normalize earlier hand-committed files
Run rustfmt over files landed in earlier commits this session that weren't
fmt-checked (heph-quickadd, the heph-tui undo/move wave, the hephd quickadd
supervisor). Pure formatting (struct/if-else expansion, line wrapping); no
behavior change. Restores `cargo fmt --check` clean for CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 20:38:44 -07:00

711 lines
27 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The warm quick-capture popover (tech-spec §8 — global capture surface).
//!
//! Snappiness is the whole point ([[design]] §6.2.1 "save state and walk away in
//! milliseconds"): the process stays **always running and warm**, its window
//! pre-created and merely hidden, so the global hotkey only *toggles it visible
//! and focuses the field* — never spawns anything. Saving is **optimistic**: on
//! Enter the window hides immediately and `task.create` runs on a background
//! thread, so perceived latency is just the keystroke. A failed save re-shows the
//! window with the text restored, so a capture is never silently lost.
use std::path::PathBuf;
use std::sync::mpsc::{Receiver, Sender};
use chrono::NaiveDate;
use eframe::egui;
use global_hotkey::hotkey::{Code, HotKey, Modifiers};
use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState};
use hephd::quickadd::{self, Parsed, Project};
use hephd::Client;
use serde_json::json;
/// Font size for the chrome around the field (header, chips, hints). The capture
/// field itself stays `Heading`-sized — only everything *else* is bumped up here.
const LABEL_SIZE: f32 = 15.0;
/// Dim text color for the chip row / hints. Brighter than egui's default muted
/// grey so it reads clearly against the dark HUD background.
const DIM: egui::Color32 = egui::Color32::from_gray(190);
/// Window width and the base (collapsed) height, logical points. The window grows
/// downward to fit the `#project` autocomplete list, then shrinks back.
const WIN_W: f32 = 620.0;
const BASE_H: f32 = 150.0;
/// One autocomplete row's height, and the cap on how many show at once.
const AC_ROW_H: f32 = 26.0;
const AC_MAX_ROWS: usize = 6;
/// Seconds of idle (no typing) before the example hint fades in — so it never
/// distracts while you're on a roll, only nudges if you pause on an empty field.
const HINT_DELAY: f64 = 2.0;
/// Example capture lines, one chosen at random each time the popover opens — a
/// rotating cheat-sheet for the inline syntax (priorities, dates, recurrence,
/// `#project`). Unresolved `#tags` just stay in the title, so these are safe even
/// though they reference projects a given store may not have.
const HINTS: &[&str] = &[
"Water plants tomorrow p2 #Chores every 3 days",
"Call the dentist fri p1",
"Email Sarah the report today",
"Buy milk #Errands",
"Renew passport +30d p2",
"Review pull requests p3 #Work",
"Take out recycling every other wed",
"Pay rent every 1st p1",
"Stretch every day",
"Submit timesheet every friday #Work",
"Water the garden every 2 days",
"Back up the laptop every week p3",
"Book flights +1w p2 #Travel",
"Doctor appointment 2026-07-15 p1",
"Read a chapter today #Reading",
"Standup notes every weekday #Work",
"Change the air filter every 3 months",
"File taxes every April 15 p1",
"Clean the gutters every 6 months #Home",
"Wish Mom happy birthday every May 4 p1",
"Vacuum the house every saturday #Chores",
"Replace toothbrush every 3 months",
"Prep slides for monday p2 #Work",
"Walk the dog every day",
"Refill prescription every 30 days p2 #Health",
"Grocery run +2d #Errands",
"Mow the lawn every week #Home",
"Schedule a 1:1 with Alex thu p3 #Work",
"Send the invoice every 15th p2",
"Defrost the freezer every 6 months",
"Update the resume +14d p3",
"Check smoke detectors every 6 months #Home",
"Plan the sprint every other monday #Work",
"Order coffee beans every 2 weeks",
"Call grandma every sunday p2",
"Rotate the car tires every 6 months #Car",
"Weekly review every friday p2",
"Pick up dry cleaning tomorrow #Errands",
"Pay the credit card every 28th p1",
"Tidy the inbox every day p4",
];
/// Pick a hint pseudo-randomly, never the same one twice in a row. No `rand`
/// dep: the sub-second nanos of the wall clock are plenty of entropy for
/// "different one each time I open it".
fn random_hint(prev: &str) -> &'static str {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0) as usize;
let mut idx = nanos % HINTS.len();
if HINTS[idx] == prev && HINTS.len() > 1 {
idx = (idx + 1) % HINTS.len();
}
HINTS[idx]
}
/// Local today, for `datespec` resolution. This is a UI surface, not `heph-core`,
/// so reading the wall clock here is fine (the TUI does the same).
fn today_local() -> NaiveDate {
chrono::Local::now().date_naive()
}
/// A finished background save, surfaced back to the UI thread.
enum SaveOutcome {
Ok,
/// The RPC failed — re-show with this text restored and the error shown.
Err {
text: String,
message: String,
},
}
pub struct QuickAdd {
socket: PathBuf,
/// The single capture field.
text: String,
/// Projects cached for `#name` resolution; refreshed in the background on show.
projects: Vec<Project>,
/// Whether the popover is currently shown.
visible: bool,
/// Request `request_focus()` on the field on the next frame (after a show).
focus_pending: bool,
/// An inline error from the last failed save, cleared on the next keystroke.
error: Option<String>,
/// The example hint shown this open (rotated on each show).
current_hint: &'static str,
/// egui time (seconds) at/after which the idle hint may fade in. Pushed back
/// on every keystroke so it only shows when you pause on an empty field.
hint_at: f64,
/// Highlighted row in the `#project` autocomplete list.
ac_selected: usize,
/// The `#…` query the autocomplete list last reflected, so we reset the
/// selection only when it actually changes.
ac_last_query: Option<String>,
/// The window inner-height we last applied, so we resize only on change.
win_h_applied: f32,
/// Keep the manager alive for the process lifetime (drop = unregister).
_hotkey_manager: GlobalHotKeyManager,
hotkey_id: u32,
/// When hephd supervises us, self-exit once orphaned (the daemon died) so we
/// never leak past it. Holds the parent pid we were spawned under, if any.
orphan_parent: Option<i32>,
projects_rx: Receiver<Vec<Project>>,
projects_tx: Sender<Vec<Project>>,
save_rx: Receiver<SaveOutcome>,
save_tx: Sender<SaveOutcome>,
}
impl QuickAdd {
pub fn new(cc: &eframe::CreationContext<'_>, socket: PathBuf, start_visible: bool) -> Self {
let supervised = std::env::var("HEPH_QUICKADD_SUPERVISED").ok().as_deref() == Some("1");
// Register ⌘' globally. On macOS, SUPER maps to the Command key.
let manager = GlobalHotKeyManager::new().expect("global hotkey manager");
let hotkey = HotKey::new(Some(Modifiers::SUPER), Code::Quote);
let hotkey_id = hotkey.id();
if let Err(e) = manager.register(hotkey) {
// Most commonly: another heph-quickadd already holds ⌘'. When
// supervised, that means a previous instance is still alive — exit so
// we don't pile up duplicates (the supervisor will stop retrying once
// the original is healthy). When run by hand, keep going (the window
// still works via launch/`once`), just without the hotkey.
eprintln!("heph-quickadd: could not register ⌘' global hotkey: {e}");
if supervised {
std::process::exit(0);
}
}
// Baseline parent pid for orphan detection (macOS supervision only).
let orphan_parent = if supervised {
current_parent_pid()
} else {
None
};
let (projects_tx, projects_rx) = std::sync::mpsc::channel();
let (save_tx, save_rx) = std::sync::mpsc::channel();
let mut app = Self {
socket,
text: String::new(),
projects: Vec::new(),
visible: false,
focus_pending: false,
error: None,
current_hint: HINTS[0],
hint_at: 0.0,
ac_selected: 0,
ac_last_query: None,
win_h_applied: BASE_H,
_hotkey_manager: manager,
hotkey_id,
orphan_parent,
projects_rx,
projects_tx,
save_rx,
save_tx,
};
// Warm the project cache without blocking startup.
app.refresh_projects(&cc.egui_ctx);
if start_visible {
app.show(&cc.egui_ctx);
}
app
}
/// Fetch projects on a background thread; the result lands via `projects_rx`.
fn refresh_projects(&self, ctx: &egui::Context) {
let socket = self.socket.clone();
let tx = self.projects_tx.clone();
let ctx = ctx.clone();
std::thread::spawn(move || {
if let Ok(projects) = fetch_projects(&socket) {
let _ = tx.send(projects);
ctx.request_repaint();
}
});
}
fn show(&mut self, ctx: &egui::Context) {
self.visible = true;
self.focus_pending = true;
self.current_hint = random_hint(self.current_hint);
// Hold the hint back ~2s — show it only if you pause on the empty field.
self.hint_at = ctx.input(|i| i.time) + HINT_DELAY;
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
// Center on the active monitor when we know its size.
if let Some(monitor) = ctx.input(|i| i.viewport().monitor_size) {
let size = ctx.input(|i| i.viewport().outer_rect.map(|r| r.size()));
let win = size.unwrap_or(egui::vec2(560.0, 120.0));
let pos = egui::pos2(
((monitor.x - win.x) * 0.5).max(0.0),
((monitor.y - win.y) * 0.35).max(0.0),
);
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos));
}
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
self.refresh_projects(ctx);
}
fn hide(&mut self, ctx: &egui::Context) {
self.visible = false;
self.ac_last_query = None;
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
// Collapse back to the base height so the next open never flashes at a
// stale (expanded) size.
if (self.win_h_applied - BASE_H).abs() > 0.5 {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(WIN_W, BASE_H)));
self.win_h_applied = BASE_H;
}
}
/// Optimistic submit: hide now, create in the background.
fn submit(&mut self, ctx: &egui::Context) {
let parsed = quickadd::parse(&self.text, today_local(), &self.projects);
if parsed.title.is_empty() {
return; // nothing to capture — leave the field as-is
}
let text = std::mem::take(&mut self.text);
self.error = None;
self.hide(ctx);
let socket = self.socket.clone();
let tx = self.save_tx.clone();
let ctx = ctx.clone();
std::thread::spawn(move || {
let outcome = match create_task(&socket, &parsed) {
Ok(()) => SaveOutcome::Ok,
Err(e) => SaveOutcome::Err {
text,
message: e.to_string(),
},
};
let _ = tx.send(outcome);
ctx.request_repaint();
});
}
/// Drain the global-hotkey channel; show on a fresh ⌘' press.
fn poll_hotkey(&mut self, ctx: &egui::Context) {
while let Ok(ev) = GlobalHotKeyEvent::receiver().try_recv() {
if ev.id == self.hotkey_id && ev.state == HotKeyState::Pressed {
if self.visible {
self.hide(ctx);
} else {
self.show(ctx);
}
}
}
}
fn drain_background(&mut self, ctx: &egui::Context) {
while let Ok(projects) = self.projects_rx.try_recv() {
self.projects = projects;
}
while let Ok(outcome) = self.save_rx.try_recv() {
if let SaveOutcome::Err { text, message } = outcome {
self.text = text;
self.error = Some(message);
self.show(ctx);
}
}
}
}
impl eframe::App for QuickAdd {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.poll_hotkey(ctx);
self.drain_background(ctx);
if self.visible {
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
self.text.clear();
self.error = None;
self.hide(ctx);
} else {
self.draw(ctx);
}
// Smooth while interacting.
ctx.request_repaint();
} else {
// Supervised + orphaned (the daemon that spawned us is gone) → exit so
// we never outlive hephd. Checked on the idle path only, so it costs
// nothing while the popover is open.
if let Some(orig) = self.orphan_parent {
if current_parent_pid() != Some(orig) {
std::process::exit(0);
}
}
// Idle: poll the hotkey channel ~33×/s so show latency stays well under
// a frame, with negligible cost (the hidden window presents nothing).
ctx.request_repaint_after(std::time::Duration::from_millis(30));
}
}
/// Transparent background so the rounded panel reads as a floating HUD.
fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] {
[0.0, 0.0, 0.0, 0.0]
}
}
impl QuickAdd {
fn draw(&mut self, ctx: &egui::Context) {
let parsed = quickadd::parse(&self.text, today_local(), &self.projects);
// The closure returns how many autocomplete rows it drew, so we can size
// the window to fit them.
let ac_rows = egui::CentralPanel::default()
.frame(
egui::Frame::new()
.fill(egui::Color32::from_rgb(0x1e, 0x1e, 0x24))
.corner_radius(10.0)
.inner_margin(egui::Margin::same(14))
.stroke(egui::Stroke::new(1.0, egui::Color32::from_gray(60))),
)
.show(ctx, |ui| {
ui.label(
egui::RichText::new("heph · new task")
.color(egui::Color32::from_gray(170))
.size(LABEL_SIZE),
);
ui.add_space(6.0);
// The hint only fades in after ~2s idle on an empty field.
let now = ui.input(|i| i.time);
let hint = if self.text.is_empty() && now >= self.hint_at {
self.current_hint
} else {
""
};
let out = egui::TextEdit::singleline(&mut self.text)
.hint_text(hint)
.desired_width(f32::INFINITY)
.font(egui::TextStyle::Heading)
.show(ui);
let field_id = out.response.id;
let cursor_idx = out.cursor_range.map(|r| r.primary.index);
let resp = out.response;
if self.focus_pending {
resp.request_focus();
self.focus_pending = false;
}
if resp.changed() {
self.error = None;
// Typing (or clearing) pushes the hint back, so it reappears
// only after another idle pause.
self.hint_at = now + HINT_DELAY;
}
let rows = self.draw_autocomplete(ui, field_id, cursor_idx, resp.has_focus());
// Enter always submits — the autocomplete (Tab/↑/↓/click) never
// hijacks it, so the muscle-reflex save stays sacred.
let submitted = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
// When not autocompleting, the lower area is the live chip preview.
if rows == 0 {
ui.add_space(8.0);
self.draw_preview(ui, &parsed);
if let Some(err) = &self.error {
ui.add_space(4.0);
ui.label(
egui::RichText::new(format!("⚠ not saved: {err}"))
.color(egui::Color32::from_rgb(0xe0, 0x6c, 0x60))
.size(LABEL_SIZE),
);
}
}
if submitted {
self.submit(ctx);
}
rows
})
.inner;
// Grow/shrink the window to fit the autocomplete list — only on change, so
// we don't spam the windowing system with resize commands every frame.
let target_h = if ac_rows > 0 {
BASE_H + 6.0 + ac_rows as f32 * AC_ROW_H
} else {
BASE_H
};
if (target_h - self.win_h_applied).abs() > 0.5 {
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(
WIN_W, target_h,
)));
self.win_h_applied = target_h;
}
}
/// The `#project` autocomplete: when the cursor sits in a `#…` token, list the
/// matching projects (all of them right after a bare `#`). ↑/↓ move the
/// highlight, **Tab** or a click accepts, inserting the full `#Project Name `.
/// Returns the number of rows drawn (0 = inactive), for window sizing.
fn draw_autocomplete(
&mut self,
ui: &mut egui::Ui,
field_id: egui::Id,
cursor_idx: Option<usize>,
focused: bool,
) -> usize {
let active = if focused {
cursor_idx.and_then(|ci| active_project_query(&self.text, ci))
} else {
None
};
let Some((hash_idx, query)) = active else {
self.ac_last_query = None;
return 0;
};
let matches = project_matches(&self.projects, &query);
if matches.is_empty() {
self.ac_last_query = None;
return 0;
}
// Reset the highlight only when the query text actually changes.
if self.ac_last_query.as_deref() != Some(query.as_str()) {
self.ac_selected = 0;
self.ac_last_query = Some(query.clone());
}
self.ac_selected = self.ac_selected.min(matches.len() - 1);
// Keep Tab and ↑/↓ on the field instead of letting egui's focus system
// spend them on traversal — otherwise Tab just moved focus to a row and
// the list flickered without ever accepting. (egui resolves focus at the
// start of the next frame, so the filter must be set a frame ahead; we set
// it every frame the popup is open.)
ui.memory_mut(|m| {
m.set_focus_lock_filter(
field_id,
egui::EventFilter {
tab: true,
vertical_arrows: true,
horizontal_arrows: false,
escape: false,
},
);
});
// Grab navigation/accept keys before egui spends them on focus traversal.
let (mut up, mut down, mut tab) = (false, false, false);
ui.input_mut(|i| {
down = i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown);
up = i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp);
tab = i.consume_key(egui::Modifiers::NONE, egui::Key::Tab);
});
let n = matches.len();
if down {
self.ac_selected = (self.ac_selected + 1) % n;
}
if up {
self.ac_selected = (self.ac_selected + n - 1) % n;
}
ui.add_space(6.0);
let mut accept: Option<String> = tab.then(|| matches[self.ac_selected].clone());
for (i, title) in matches.iter().enumerate().take(AC_MAX_ROWS) {
let selected = i == self.ac_selected;
let text = egui::RichText::new(format!("📁 {title}"))
.size(LABEL_SIZE)
.color(if selected { egui::Color32::WHITE } else { DIM });
if ui.selectable_label(selected, text).clicked() {
accept = Some(title.clone());
}
}
if let Some(title) = accept {
apply_completion(&mut self.text, hash_idx, query.chars().count(), &title);
let new_idx = hash_idx + 1 + title.chars().count() + 1;
if let Some(mut st) = egui::widgets::text_edit::TextEditState::load(ui.ctx(), field_id)
{
let cc = egui::text::CCursor::new(new_idx);
st.cursor
.set_char_range(Some(egui::text::CCursorRange::one(cc)));
st.store(ui.ctx(), field_id);
}
ui.ctx().memory_mut(|m| m.request_focus(field_id));
self.ac_last_query = None;
ui.ctx().request_repaint();
}
n.min(AC_MAX_ROWS)
}
/// The live-parsed chip row: ⚑ attention · 📁 project · ⏰ do-date · ↻ recurrence.
fn draw_preview(&self, ui: &mut egui::Ui, parsed: &Parsed) {
ui.horizontal(|ui| {
let dim = DIM;
let mut any = false;
if let Some(att) = parsed.attention {
let (label, color) = match att {
heph_core::Attention::Red => {
("⚑ red", egui::Color32::from_rgb(0xe0, 0x6c, 0x60))
}
heph_core::Attention::Orange => {
("⚑ orange", egui::Color32::from_rgb(0xe5, 0xc0, 0x7b))
}
heph_core::Attention::Blue => {
("⚑ blue", egui::Color32::from_rgb(0x61, 0xaf, 0xef))
}
heph_core::Attention::White => ("⚑ white", egui::Color32::from_gray(200)),
};
ui.label(egui::RichText::new(label).color(color).size(LABEL_SIZE));
any = true;
}
if let Some(id) = &parsed.project_id {
let title = self
.projects
.iter()
.find(|p| &p.id == id)
.map(|p| p.title.as_str())
.unwrap_or("project");
ui.label(
egui::RichText::new(format!("📁 {title}"))
.color(dim)
.size(LABEL_SIZE),
);
any = true;
}
if let Some(do_ms) = parsed.do_date {
ui.label(
egui::RichText::new(format!("{}", fmt_day(do_ms)))
.color(dim)
.size(LABEL_SIZE),
);
any = true;
}
if parsed.recurrence.is_some() {
ui.label(egui::RichText::new("↻ recurs").color(dim).size(LABEL_SIZE));
any = true;
}
if !any {
ui.label(
egui::RichText::new("type p1p4 · #project · a date · every …")
.color(egui::Color32::from_gray(140))
.size(LABEL_SIZE),
);
}
});
}
}
/// The current parent process id, for orphan detection. `None` off macOS (where
/// hephd does not supervise a helper — there is no Aqua session to inherit).
fn current_parent_pid() -> Option<i32> {
#[cfg(target_os = "macos")]
{
Some(unsafe { libc::getppid() })
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
/// If the cursor sits inside a `#…` project token, return `(hash_char_index,
/// query)` — the chars between the `#` and the cursor. `None` when the cursor is
/// past a token (preceded by whitespace) or the nearest `#` is glued to a word
/// (matching the parser's whitespace tokenization). Empty query (just typed `#`)
/// is valid and means "list everything".
fn active_project_query(text: &str, cursor: usize) -> Option<(usize, String)> {
let chars: Vec<char> = text.chars().collect();
let cursor = cursor.min(chars.len());
if cursor == 0 || chars[cursor - 1].is_whitespace() {
return None;
}
let mut i = cursor;
while i > 0 {
i -= 1;
if chars[i] == '#' {
if i == 0 || chars[i - 1].is_whitespace() {
let query: String = chars[i + 1..cursor].iter().collect();
if query.contains('#') {
return None;
}
return Some((i, query));
}
return None; // '#' glued to a preceding word → not a tag
}
}
None
}
/// Project titles matching `query` (case-insensitive): prefix matches first, then
/// substring matches. An empty query lists every project (sorted as fetched).
fn project_matches(projects: &[Project], query: &str) -> Vec<String> {
let q = query.trim().to_lowercase();
if q.is_empty() {
return projects.iter().map(|p| p.title.clone()).collect();
}
let (mut prefix, mut contains) = (Vec::new(), Vec::new());
for p in projects {
let t = p.title.to_lowercase();
if t.starts_with(&q) {
prefix.push(p.title.clone());
} else if t.contains(&q) {
contains.push(p.title.clone());
}
}
prefix.extend(contains);
prefix
}
/// Replace the `query` chars after the `#` with the chosen `title` plus a trailing
/// space, so `#Cam│ …` becomes `#Camano Chores │…`.
fn apply_completion(text: &mut String, hash_idx: usize, query_len: usize, title: &str) {
let chars: Vec<char> = text.chars().collect();
let start = (hash_idx + 1).min(chars.len());
let end = (start + query_len).min(chars.len());
let mut out: String = chars[..start].iter().collect();
out.push_str(title);
out.push(' ');
out.extend(chars[end..].iter());
*text = out;
}
/// Format an epoch-ms (local midnight) do-date as a compact `Mon D`.
fn fmt_day(ms: i64) -> String {
use chrono::TimeZone;
chrono::Local
.timestamp_millis_opt(ms)
.single()
.map(|dt| dt.format("%b %-d").to_string())
.unwrap_or_default()
}
fn fetch_projects(socket: &std::path::Path) -> anyhow::Result<Vec<Project>> {
let mut client = Client::connect(socket)?;
let v = client.call("node.list", json!({ "kind": "project" }))?;
let nodes: Vec<heph_core::Node> = serde_json::from_value(v)?;
let mut projects: Vec<Project> = nodes
.into_iter()
.map(|n| Project {
id: n.id,
title: n.title,
})
.collect();
projects.sort_by(|a, b| a.title.cmp(&b.title));
Ok(projects)
}
fn create_task(socket: &std::path::Path, parsed: &Parsed) -> anyhow::Result<()> {
let mut client = Client::connect(socket)?;
client.call(
"task.create",
json!({
"title": parsed.title,
"attention": parsed.attention,
"do_date": parsed.do_date,
"recurrence": parsed.recurrence,
"project_id": parsed.project_id,
}),
)?;
Ok(())
}