generated from eblume/project-template
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>
711 lines
27 KiB
Rust
711 lines
27 KiB
Rust
//! 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 p1–p4 · #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(())
|
||
}
|