use std::collections::{BTreeMap, HashMap, HashSet};
use GORBIE::prelude::CardCtx;
use GORBIE::themes::colorhash;
use triblespace::core::id::{ufoid, ExclusiveId, Id};
use triblespace::core::metadata;
use triblespace::core::repo::pile::Pile;
use triblespace::core::repo::{CommitHandle, Workspace};
use triblespace::core::trible::TribleSet;
use triblespace::core::value::schemas::hash::{Blake3, Handle};
use triblespace::core::value::{TryToValue, Value};
use triblespace::macros::{entity, find, pattern};
use triblespace::prelude::blobschemas::LongString;
use triblespace::prelude::valueschemas::NsTAIInterval;
use triblespace::prelude::View;
use crate::schemas::compass::{
board as compass, DEFAULT_STATUSES, KIND_GOAL_ID, KIND_NOTE_ID, KIND_PRIORITIZE_ID,
KIND_STATUS_ID,
};
type TextHandle = Value<Handle<Blake3, LongString>>;
type IntervalValue = Value<NsTAIInterval>;
fn fmt_id_full(id: Id) -> String {
format!("{id:x}")
}
fn id_prefix(id: Id) -> String {
let s = fmt_id_full(id);
if s.len() > 8 {
s[..8].to_string()
} else {
s
}
}
fn now_tai_ns() -> i128 {
hifitime::Epoch::now()
.map(|e| e.to_tai_duration().total_nanoseconds())
.unwrap_or(0)
}
fn now_epoch() -> hifitime::Epoch {
hifitime::Epoch::now().unwrap_or_else(|_| hifitime::Epoch::from_gregorian_utc(1970, 1, 1, 0, 0, 0, 0))
}
fn epoch_interval(epoch: hifitime::Epoch) -> IntervalValue {
(epoch, epoch).try_to_value().unwrap()
}
fn format_age(now_key: i128, maybe_key: Option<i128>) -> String {
let Some(key) = maybe_key else {
return "-".to_string();
};
let delta_ns = now_key.saturating_sub(key);
let delta_s = (delta_ns / 1_000_000_000).max(0) as i64;
if delta_s < 60 {
format!("{delta_s}s")
} else if delta_s < 60 * 60 {
format!("{}m", delta_s / 60)
} else if delta_s < 24 * 60 * 60 {
format!("{}h", delta_s / 3600)
} else {
format!("{}d", delta_s / 86_400)
}
}
fn color_todo() -> egui::Color32 {
egui::Color32::from_rgb(0x57, 0xa6, 0x39) }
fn color_doing() -> egui::Color32 {
egui::Color32::from_rgb(0xf7, 0xba, 0x0b) }
fn color_blocked() -> egui::Color32 {
egui::Color32::from_rgb(0xcc, 0x0a, 0x17) }
fn color_done() -> egui::Color32 {
egui::Color32::from_rgb(0x15, 0x4e, 0xa1) }
fn color_muted() -> egui::Color32 {
egui::Color32::from_rgb(0x4d, 0x55, 0x59) }
fn color_frame() -> egui::Color32 {
egui::Color32::from_rgb(0x29, 0x32, 0x36) }
fn card_bg() -> egui::Color32 {
egui::Color32::from_rgb(0x33, 0x3b, 0x40)
}
fn status_color(status: &str) -> egui::Color32 {
match status {
"todo" => color_todo(),
"doing" => color_doing(),
"blocked" => color_blocked(),
"done" => color_done(),
_ => color_muted(),
}
}
fn tag_color(tag: &str) -> egui::Color32 {
colorhash::ral_categorical(tag.as_bytes())
}
fn truncate_inline(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let take: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{take}…")
}
#[derive(Clone, Debug)]
struct GoalRow {
id: Id,
id_prefix: String,
title: String,
tags: Vec<String>,
status: String,
status_at: Option<i128>,
created_at: Option<i128>,
note_count: usize,
parent: Option<Id>,
higher_over: Vec<Id>,
}
impl GoalRow {
fn sort_key(&self) -> i128 {
self.status_at.or(self.created_at).unwrap_or(i128::MIN)
}
}
#[derive(Clone, Debug)]
struct NoteRow {
at: Option<i128>,
body: String,
}
struct CompassLive {
space: TribleSet,
cached_head: Option<CommitHandle>,
}
impl CompassLive {
fn refresh(ws: &mut Workspace<Pile<Blake3>>) -> Self {
let space = ws
.checkout(..)
.map(|co| co.into_facts())
.unwrap_or_else(|e| {
eprintln!("[compass] checkout: {e:?}");
TribleSet::new()
});
Self {
space,
cached_head: ws.head(),
}
}
fn text(&self, ws: &mut Workspace<Pile<Blake3>>, h: TextHandle) -> String {
ws.get::<View<str>, LongString>(h)
.map(|v| {
let s: &str = v.as_ref();
s.to_string()
})
.unwrap_or_default()
}
fn goals(&self, ws: &mut Workspace<Pile<Blake3>>) -> Vec<GoalRow> {
let mut by_id: HashMap<Id, GoalRow> = HashMap::new();
let title_rows: Vec<(Id, TextHandle, (i128, i128))> = find!(
(gid: Id, title: TextHandle, ts: (i128, i128)),
pattern!(&self.space, [{
?gid @
metadata::tag: &KIND_GOAL_ID,
compass::title: ?title,
metadata::created_at: ?ts,
}])
)
.collect();
for (gid, title_handle, ts) in title_rows {
if by_id.contains_key(&gid) {
continue;
}
let title = self.text(ws, title_handle);
by_id.insert(
gid,
GoalRow {
id: gid,
id_prefix: id_prefix(gid),
title,
tags: Vec::new(),
status: "todo".to_string(),
status_at: None,
created_at: Some(ts.0),
note_count: 0,
parent: None,
higher_over: Vec::new(),
},
);
}
for (gid, tag) in find!(
(gid: Id, tag: String),
pattern!(&self.space, [{
?gid @
metadata::tag: &KIND_GOAL_ID,
compass::tag: ?tag,
}])
) {
if let Some(row) = by_id.get_mut(&gid) {
row.tags.push(tag);
}
}
for (gid, parent) in find!(
(gid: Id, parent: Id),
pattern!(&self.space, [{
?gid @
metadata::tag: &KIND_GOAL_ID,
compass::parent: ?parent,
}])
) {
if let Some(row) = by_id.get_mut(&gid) {
row.parent = Some(parent);
}
}
for (gid, status, ts) in find!(
(gid: Id, status: String, ts: (i128, i128)),
pattern!(&self.space, [{
_?event @
metadata::tag: &KIND_STATUS_ID,
compass::task: ?gid,
compass::status: ?status,
metadata::created_at: ?ts,
}])
) {
if let Some(row) = by_id.get_mut(&gid) {
let replace = match row.status_at {
None => true,
Some(prev) => ts.0 > prev,
};
if replace {
row.status = status;
row.status_at = Some(ts.0);
}
}
}
for gid in find!(
gid: Id,
pattern!(&self.space, [{
_?event @
metadata::tag: &KIND_NOTE_ID,
compass::task: ?gid,
}])
) {
if let Some(row) = by_id.get_mut(&gid) {
row.note_count += 1;
}
}
for (higher, lower) in find!(
(higher: Id, lower: Id),
pattern!(&self.space, [{
_?event @
metadata::tag: &KIND_PRIORITIZE_ID,
compass::higher: ?higher,
compass::lower: ?lower,
}])
) {
if let Some(row) = by_id.get_mut(&higher) {
if !row.higher_over.contains(&lower) {
row.higher_over.push(lower);
}
}
}
for row in by_id.values_mut() {
row.tags.sort();
row.tags.dedup();
}
by_id.into_values().collect()
}
fn notes_for(&self, ws: &mut Workspace<Pile<Blake3>>, goal_id: Id) -> Vec<NoteRow> {
let raw: Vec<(TextHandle, (i128, i128))> = find!(
(note_handle: TextHandle, ts: (i128, i128)),
pattern!(&self.space, [{
_?event @
metadata::tag: &KIND_NOTE_ID,
compass::task: &goal_id,
compass::note: ?note_handle,
metadata::created_at: ?ts,
}])
)
.collect();
let mut notes: Vec<NoteRow> = raw
.into_iter()
.map(|(h, ts)| NoteRow {
at: Some(ts.0),
body: self.text(ws, h),
})
.collect();
notes.sort_by(|a, b| b.at.cmp(&a.at));
notes
}
fn add_goal(
ws: &mut Workspace<Pile<Blake3>>,
title: String,
status: String,
parent: Option<Id>,
tags: Vec<String>,
) -> Id {
let task_id: ExclusiveId = ufoid();
let task_ref: Id = task_id.id;
let now = epoch_interval(now_epoch());
let title_handle = ws.put::<LongString, _>(title);
let mut change = TribleSet::new();
change += entity! { &task_id @
metadata::tag: &KIND_GOAL_ID,
compass::title: title_handle,
metadata::created_at: now,
compass::parent?: parent.as_ref(),
compass::tag*: tags.iter().map(|t| t.as_str()),
};
let status_id: ExclusiveId = ufoid();
change += entity! { &status_id @
metadata::tag: &KIND_STATUS_ID,
compass::task: &task_ref,
compass::status: status.as_str(),
metadata::created_at: now,
};
ws.commit(change, "add goal");
task_ref
}
fn move_status(ws: &mut Workspace<Pile<Blake3>>, task_id: Id, status: String) {
let now = epoch_interval(now_epoch());
let status_id: ExclusiveId = ufoid();
let mut change = TribleSet::new();
change += entity! { &status_id @
metadata::tag: &KIND_STATUS_ID,
compass::task: &task_id,
compass::status: status.as_str(),
metadata::created_at: now,
};
ws.commit(change, "move goal");
}
fn add_note(ws: &mut Workspace<Pile<Blake3>>, task_id: Id, body: String) {
let now = epoch_interval(now_epoch());
let note_id: ExclusiveId = ufoid();
let body_handle = ws.put::<LongString, _>(body);
let mut change = TribleSet::new();
change += entity! { ¬e_id @
metadata::tag: &KIND_NOTE_ID,
compass::task: &task_id,
compass::note: body_handle,
metadata::created_at: now,
};
ws.commit(change, "add goal note");
}
}
fn order_rows(rows: Vec<GoalRow>) -> Vec<(GoalRow, usize)> {
let mut by_id: HashMap<Id, GoalRow> = HashMap::new();
for row in rows {
by_id.insert(row.id, row);
}
let ids: HashSet<Id> = by_id.keys().copied().collect();
let mut children: HashMap<Id, Vec<Id>> = HashMap::new();
let mut roots = Vec::new();
for (id, row) in &by_id {
if let Some(parent) = row.parent {
if ids.contains(&parent) {
children.entry(parent).or_default().push(*id);
continue;
}
}
roots.push(*id);
}
let sort_ids = |items: &mut Vec<Id>, by_id: &HashMap<Id, GoalRow>| {
items.sort_by(|a, b| {
let a_row = by_id.get(a);
let b_row = by_id.get(b);
let a_key = a_row.map(|r| r.sort_key()).unwrap_or(i128::MIN);
let b_key = b_row.map(|r| r.sort_key()).unwrap_or(i128::MIN);
b_key
.cmp(&a_key)
.then_with(|| {
let at = a_row.map(|r| r.title.as_str()).unwrap_or("");
let bt = b_row.map(|r| r.title.as_str()).unwrap_or("");
at.to_lowercase().cmp(&bt.to_lowercase())
})
.then_with(|| a.cmp(b))
});
};
sort_ids(&mut roots, &by_id);
for kids in children.values_mut() {
sort_ids(kids, &by_id);
}
let mut ordered = Vec::new();
let mut visited = HashSet::new();
fn walk(
id: Id,
depth: usize,
by_id: &HashMap<Id, GoalRow>,
children: &HashMap<Id, Vec<Id>>,
visited: &mut HashSet<Id>,
out: &mut Vec<(GoalRow, usize)>,
) {
if !visited.insert(id) {
return;
}
let Some(row) = by_id.get(&id) else {
return;
};
out.push((row.clone(), depth));
if let Some(kids) = children.get(&id) {
for kid in kids {
walk(*kid, depth + 1, by_id, children, visited, out);
}
}
}
for root in roots {
walk(root, 0, &by_id, &children, &mut visited, &mut ordered);
}
let leftovers: Vec<Id> = by_id.keys().copied().filter(|id| !visited.contains(id)).collect();
for id in leftovers {
walk(id, 0, &by_id, &children, &mut visited, &mut ordered);
}
ordered
}
#[derive(Default)]
struct ComposeForm {
open: bool,
title: String,
tags: String,
parent_prefix: String,
}
pub struct CompassBoard {
live: Option<CompassLive>,
expanded_goal: Option<Id>,
collapsed: HashSet<Id>,
compose: HashMap<String, ComposeForm>,
note_inputs: HashMap<Id, String>,
status_menu: Option<Id>,
column_height: f32,
}
impl Default for CompassBoard {
fn default() -> Self {
Self {
live: None,
expanded_goal: None,
collapsed: HashSet::new(),
compose: HashMap::new(),
note_inputs: HashMap::new(),
status_menu: None,
column_height: 500.0,
}
}
}
impl CompassBoard {
pub fn new() -> Self {
Self::default()
}
pub fn with_column_height(mut self, height: f32) -> Self {
self.column_height = height.max(120.0);
self
}
pub fn render(&mut self, ctx: &mut CardCtx<'_>, ws: &mut Workspace<Pile<Blake3>>) {
let head = ws.head();
let need_refresh = match self.live.as_ref() {
None => true,
Some(l) => l.cached_head != head,
};
if need_refresh {
self.live = Some(CompassLive::refresh(ws));
}
let live = self.live.as_ref().expect("refreshed above");
let mut goals = live.goals(ws);
goals.sort_by(|a, b| {
b.sort_key()
.cmp(&a.sort_key())
.then_with(|| a.title.to_lowercase().cmp(&b.title.to_lowercase()))
.then_with(|| a.id.cmp(&b.id))
});
let mut by_status: BTreeMap<String, Vec<GoalRow>> = BTreeMap::new();
for g in goals.clone() {
by_status.entry(g.status.clone()).or_default().push(g);
}
let mut columns: Vec<String> = DEFAULT_STATUSES.iter().map(|s| s.to_string()).collect();
let mut extras: Vec<String> = by_status
.keys()
.filter(|s| !DEFAULT_STATUSES.contains(&s.as_str()))
.cloned()
.collect();
extras.sort();
columns.extend(extras);
let title_by_id: HashMap<Id, String> = goals
.iter()
.map(|g| (g.id, g.title.clone()))
.collect();
let column_data: Vec<(String, Vec<(GoalRow, usize)>)> = columns
.into_iter()
.map(|s| {
let rows = by_status.remove(&s).unwrap_or_default();
let ordered = order_rows(rows);
(s, ordered)
})
.collect();
let expanded = self.expanded_goal;
let expanded_notes: Option<(Id, Vec<NoteRow>)> = expanded.map(|gid| {
let notes = live.notes_for(ws, gid);
(gid, notes)
});
let column_height = self.column_height;
let total_goals: usize = column_data.iter().map(|(_, r)| r.len()).sum();
let mut add_intent: Option<AddIntent> = None;
let mut move_intent: Option<(Id, String)> = None;
let mut note_intent: Option<(Id, String)> = None;
let expanded_goal = &mut self.expanded_goal;
let collapsed = &mut self.collapsed;
let compose = &mut self.compose;
let note_inputs = &mut self.note_inputs;
let status_menu = &mut self.status_menu;
ctx.section("Compass", |ctx| {
let ui = ctx.ui_mut();
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
ui.label(
egui::RichText::new(format!("{total_goals} GOALS"))
.monospace()
.strong()
.small()
.color(color_muted()),
);
ui.label(
egui::RichText::new("\u{00b7}")
.small()
.color(color_muted()),
);
for (status, rows) in &column_data {
if rows.is_empty() {
continue;
}
let (dot, _) = ui.allocate_exact_size(
egui::vec2(8.0, 8.0),
egui::Sense::hover(),
);
ui.painter().circle_filled(
dot.center(),
3.5,
status_color(status),
);
ui.label(
egui::RichText::new(status.to_uppercase())
.monospace()
.strong()
.small(),
);
ui.label(
egui::RichText::new(rows.len().to_string())
.monospace()
.small()
.color(color_muted()),
);
}
});
if total_goals == 0 && column_data.iter().all(|(s, _)| !compose.contains_key(s)) {
render_empty_state(
ctx.ui_mut(),
"\u{1f9ed}",
"No goals yet",
Some("Click + ADD in a column below to start tracking work."),
);
}
const LANE_GAP: f32 = 10.0;
let ui = ctx.ui_mut();
let mut card_rects: HashMap<Id, egui::Rect> = HashMap::new();
let lane_width = ui.available_width();
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = LANE_GAP;
for (status, rows) in &column_data {
let form = compose.entry(status.clone()).or_default();
render_column(
ui,
status,
rows,
lane_width,
column_height,
expanded_goal,
expanded_notes.as_ref(),
collapsed,
note_inputs,
status_menu,
form,
&title_by_id,
&mut card_rects,
&mut add_intent,
&mut move_intent,
&mut note_intent,
);
}
});
let painter = ui.painter();
for row in column_data.iter().flat_map(|(_, rs)| rs) {
let (src_row, _depth) = row;
let Some(from_rect) = card_rects.get(&src_row.id) else {
continue;
};
let base = status_color(&src_row.status);
let edge_color = egui::Color32::from_rgba_unmultiplied(
base.r(),
base.g(),
base.b(),
200,
);
for lower in &src_row.higher_over {
let Some(to_rect) = card_rects.get(lower) else {
continue;
};
draw_priority_edge(painter, *from_rect, *to_rect, edge_color);
}
}
});
if let Some(intent) = add_intent {
let status = intent.status.clone();
let _ = CompassLive::add_goal(ws, intent.title, status.clone(), intent.parent, intent.tags);
if let Some(form) = self.compose.get_mut(&status) {
form.open = false;
form.title.clear();
form.tags.clear();
form.parent_prefix.clear();
}
self.live = None;
}
if let Some((id, status)) = move_intent {
CompassLive::move_status(ws, id, status);
self.status_menu = None;
self.live = None;
}
if let Some((id, body)) = note_intent {
let body_trimmed = body.trim();
if !body_trimmed.is_empty() {
CompassLive::add_note(ws, id, body_trimmed.to_string());
if let Some(buf) = self.note_inputs.get_mut(&id) {
buf.clear();
}
self.live = None;
}
}
}
}
struct AddIntent {
title: String,
status: String,
parent: Option<Id>,
tags: Vec<String>,
}
#[allow(clippy::too_many_arguments)]
fn render_column(
ui: &mut egui::Ui,
status: &str,
rows: &[(GoalRow, usize)],
width: f32,
height: f32,
expanded_goal: &mut Option<Id>,
expanded_notes: Option<&(Id, Vec<NoteRow>)>,
collapsed: &mut HashSet<Id>,
note_inputs: &mut HashMap<Id, String>,
status_menu: &mut Option<Id>,
form: &mut ComposeForm,
title_by_id: &HashMap<Id, String>,
card_rects: &mut HashMap<Id, egui::Rect>,
add_intent: &mut Option<AddIntent>,
move_intent: &mut Option<(Id, String)>,
note_intent: &mut Option<(Id, String)>,
) {
let status_col = status_color(status);
let frame_response = egui::Frame::NONE
.fill(color_frame())
.corner_radius(egui::CornerRadius::same(6))
.inner_margin(egui::Margin {
left: 12, right: 8,
top: 8,
bottom: 8,
})
.show(ui, |ui| {
let _ = width; ui.set_width(ui.available_width());
ui.set_min_height(height);
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
ui.label(
egui::RichText::new(status.to_uppercase())
.monospace()
.strong()
.color(status_col),
);
render_chip(ui, &rows.len().to_string(), color_muted());
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
let (label, hint) = if form.open {
("×", "Close compose form")
} else {
("+ ADD", "New goal in this column")
};
if ui
.add(
egui::Button::new(
egui::RichText::new(label)
.small()
.monospace()
.strong(),
),
)
.on_hover_text(hint)
.clicked()
{
form.open = !form.open;
}
},
);
});
ui.add_space(4.0);
if form.open {
render_compose_form(ui, status, form, add_intent);
ui.add_space(6.0);
}
let ancestors_collapsed: HashSet<Id> = {
let mut hidden: HashSet<Id> = HashSet::new();
let mut path: Vec<(Id, usize)> = Vec::new();
for (row, depth) in rows {
while path.last().map(|(_, d)| *d >= *depth).unwrap_or(false) {
path.pop();
}
let parent_hidden = path.iter().any(|(pid, _)| {
hidden.contains(pid) || collapsed.contains(pid)
});
if parent_hidden {
hidden.insert(row.id);
}
path.push((row.id, *depth));
}
hidden
};
egui::ScrollArea::vertical()
.id_salt(("compass_column", status))
.max_height(height)
.auto_shrink([false, false])
.scroll_source(egui::scroll_area::ScrollSource {
scroll_bar: true,
drag: false,
mouse_wheel: true,
})
.show(ui, |ui| {
if rows.is_empty() && !form.open {
ui.add_space(8.0);
ui.vertical_centered(|ui| {
ui.label(
egui::RichText::new(format!(
"NO {} GOALS",
status.to_uppercase()
))
.monospace()
.small()
.color(color_muted()),
);
});
ui.add_space(8.0);
return;
}
for (row, depth) in rows {
if ancestors_collapsed.contains(&row.id) {
continue;
}
render_goal_card(
ui,
row,
*depth,
expanded_goal,
expanded_notes,
collapsed,
note_inputs,
status_menu,
title_by_id,
card_rects,
move_intent,
note_intent,
);
ui.add_space(6.0);
}
});
});
});
let frame_rect = frame_response.response.rect;
let accent = egui::Rect::from_min_size(
frame_rect.min,
egui::vec2(4.0, frame_rect.height()),
);
ui.painter().rect_filled(
accent,
egui::CornerRadius {
nw: 6,
sw: 6,
ne: 0,
se: 0,
},
status_col,
);
}
fn render_compose_form(
ui: &mut egui::Ui,
status: &str,
form: &mut ComposeForm,
add_intent: &mut Option<AddIntent>,
) {
egui::Frame::NONE
.fill(card_bg())
.corner_radius(egui::CornerRadius::same(4))
.inner_margin(egui::Margin::symmetric(8, 6))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
ui.label(
egui::RichText::new("NEW GOAL \u{2192}")
.small()
.monospace()
.strong()
.color(color_muted()),
);
ui.label(
egui::RichText::new(status.to_uppercase())
.small()
.monospace()
.strong()
.color(status_color(status)),
);
});
ui.add_space(2.0);
ui.add(
egui::TextEdit::singleline(&mut form.title)
.hint_text("title")
.desired_width(f32::INFINITY),
);
ui.add(
egui::TextEdit::singleline(&mut form.tags)
.hint_text("tags (space-separated)")
.desired_width(f32::INFINITY),
);
ui.add(
egui::TextEdit::singleline(&mut form.parent_prefix)
.hint_text("parent id prefix (optional)")
.desired_width(f32::INFINITY),
);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
let submit_enabled = !form.title.trim().is_empty() && add_intent.is_none();
let fill = status_color(status);
let text = colorhash::text_color_on(fill);
if ui
.add_enabled(
submit_enabled,
egui::Button::new(
egui::RichText::new("CREATE")
.small()
.monospace()
.strong()
.color(text),
)
.fill(fill),
)
.clicked()
{
let parent = resolve_prefix_hack(&form.parent_prefix);
let tags: Vec<String> = form
.tags
.split_whitespace()
.map(|s| s.trim_start_matches('#').to_string())
.filter(|s| !s.is_empty())
.collect();
*add_intent = Some(AddIntent {
title: form.title.trim().to_string(),
status: status.to_string(),
parent,
tags,
});
}
if ui
.add(egui::Button::new(
egui::RichText::new("CANCEL")
.small()
.monospace()
.color(color_muted()),
))
.clicked()
{
form.open = false;
form.title.clear();
form.tags.clear();
form.parent_prefix.clear();
}
});
});
}
fn resolve_prefix_hack(prefix: &str) -> Option<Id> {
let trimmed = prefix.trim();
if trimmed.is_empty() {
return None;
}
Id::from_hex(trimmed)
}
#[allow(clippy::too_many_arguments)]
fn render_goal_card(
ui: &mut egui::Ui,
row: &GoalRow,
depth: usize,
expanded_goal: &mut Option<Id>,
expanded_notes: Option<&(Id, Vec<NoteRow>)>,
collapsed: &mut HashSet<Id>,
note_inputs: &mut HashMap<Id, String>,
status_menu: &mut Option<Id>,
title_by_id: &HashMap<Id, String>,
card_rects: &mut HashMap<Id, egui::Rect>,
move_intent: &mut Option<(Id, String)>,
note_intent: &mut Option<(Id, String)>,
) {
const DEP_LINE_STEP: f32 = 6.0;
const DEP_LINE_BASE: f32 = 8.0;
let dep_lines = depth.min(3);
let dep_indent = if dep_lines == 0 {
0.0
} else {
(dep_lines as f32 * DEP_LINE_STEP) + DEP_LINE_BASE
};
let is_expanded = *expanded_goal == Some(row.id);
let is_collapsed = collapsed.contains(&row.id);
let card_response = egui::Frame::NONE
.fill(card_bg())
.corner_radius(egui::CornerRadius::same(4))
.outer_margin(egui::Margin {
left: dep_indent as i8,
right: 0,
top: 0,
bottom: 0,
})
.inner_margin(egui::Margin::symmetric(8, 6))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
render_status_chip(ui, &row.status, status_color(&row.status));
let tri = if is_collapsed { "▸" } else { "▾" };
if ui
.add(
egui::Label::new(
egui::RichText::new(tri).monospace().color(color_muted()),
)
.sense(egui::Sense::click()),
)
.clicked()
{
if is_collapsed {
collapsed.remove(&row.id);
} else {
collapsed.insert(row.id);
}
}
ui.add(
egui::Label::new(egui::RichText::new(&row.title).monospace())
.wrap_mode(egui::TextWrapMode::Wrap),
);
});
ui.horizontal(|ui| {
let id_text = if let Some(parent) = row.parent {
format!("^{} {}", id_prefix(parent), row.id_prefix)
} else {
row.id_prefix.clone()
};
ui.label(
egui::RichText::new(id_text)
.monospace()
.small()
.color(color_muted()),
);
if row.note_count > 0 {
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
render_chip(
ui,
&format!("{}n", row.note_count),
color_muted(),
);
},
);
}
});
let has_prio = !row.higher_over.is_empty();
if has_prio || !row.tags.is_empty() {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(4.0, 4.0);
for lower in &row.higher_over {
let target_label = title_by_id
.get(lower)
.map(|t| truncate_inline(t, 16))
.unwrap_or_else(|| id_prefix(*lower));
render_chip(
ui,
&format!("▲ {target_label}"),
egui::Color32::from_rgb(0x55, 0x3f, 0x7f),
);
}
for tag in &row.tags {
let tag_label = truncate_inline(tag, 18);
render_chip(ui, &format!("#{tag_label}"), tag_color(tag));
}
});
}
})
.response;
let click_id = ui.make_persistent_id(("compass_goal", row.id));
let response = ui.interact(card_response.rect, click_id, egui::Sense::click());
if response.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
response
.clone()
.on_hover_text("Click to expand · Shift+click or right-click to move");
}
if response.clicked() {
if *expanded_goal == Some(row.id) {
*expanded_goal = None;
} else {
*expanded_goal = Some(row.id);
}
}
let secondary = response.secondary_clicked();
if secondary || response.hovered() && ui.input(|i| i.modifiers.shift && i.pointer.any_click()) {
*status_menu = Some(row.id);
}
if *status_menu == Some(row.id) {
egui::Window::new(format!("move_menu_{}", row.id_prefix))
.title_bar(false)
.resizable(false)
.fixed_pos(card_response.rect.right_top())
.show(ui.ctx(), |ui| {
ui.label(
egui::RichText::new("MOVE TO")
.small()
.monospace()
.strong()
.color(color_muted()),
);
for status in DEFAULT_STATUSES {
if status == row.status {
continue;
}
let fill = status_color(status);
let text = colorhash::text_color_on(fill);
if ui
.add(
egui::Button::new(
egui::RichText::new(status.to_uppercase())
.small()
.monospace()
.strong()
.color(text),
)
.fill(fill),
)
.clicked()
{
*move_intent = Some((row.id, status.to_string()));
}
}
if ui
.add(egui::Button::new(
egui::RichText::new("CANCEL")
.small()
.monospace()
.color(color_muted()),
))
.clicked()
{
*status_menu = None;
}
});
}
if is_expanded {
let notes: &[NoteRow] = expanded_notes
.filter(|(gid, _)| *gid == row.id)
.map(|(_, n)| n.as_slice())
.unwrap_or(&[]);
egui::Frame::NONE
.stroke(egui::Stroke::new(1.0, color_muted()))
.outer_margin(egui::Margin {
left: dep_indent as i8,
right: 0,
top: 0,
bottom: 0,
})
.inner_margin(egui::Margin::symmetric(8, 6))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
ui.label(
egui::RichText::new("MOVE TO")
.small()
.monospace()
.strong()
.color(color_muted()),
);
for status in DEFAULT_STATUSES {
if status == row.status {
continue;
}
let fill = status_color(status);
let text = colorhash::text_color_on(fill);
if ui
.add(egui::Button::new(
egui::RichText::new(status.to_uppercase())
.small()
.monospace()
.strong()
.color(text),
).fill(fill))
.clicked()
{
*move_intent = Some((row.id, status.to_string()));
}
}
});
ui.separator();
let now = now_tai_ns();
if notes.is_empty() {
ui.add_space(4.0);
ui.vertical_centered(|ui| {
ui.label(
egui::RichText::new("NO NOTES YET")
.monospace()
.small()
.color(color_muted()),
);
});
ui.add_space(4.0);
} else {
let status_col = status_color(&row.status);
for note in notes {
let note_resp = egui::Frame::NONE
.fill(card_bg())
.corner_radius(egui::CornerRadius::same(3))
.inner_margin(egui::Margin {
left: 8,
right: 6,
top: 4,
bottom: 4,
})
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.label(
egui::RichText::new(format_age(now, note.at))
.small()
.monospace()
.color(color_muted()),
);
ui.add(
egui::Label::new(
egui::RichText::new(¬e.body).small(),
)
.wrap_mode(egui::TextWrapMode::Wrap),
);
});
let r = note_resp.response.rect;
let painter = ui.painter();
painter.rect_filled(
egui::Rect::from_min_size(
r.min,
egui::vec2(2.0, r.height()),
),
0.0,
status_col,
);
ui.add_space(3.0);
}
}
ui.separator();
let buf = note_inputs.entry(row.id).or_default();
ui.add(
egui::TextEdit::multiline(buf)
.hint_text("new note…")
.desired_rows(2)
.desired_width(f32::INFINITY),
);
ui.horizontal(|ui| {
let submit_enabled =
!buf.trim().is_empty() && note_intent.is_none();
if ui
.add_enabled(submit_enabled, egui::Button::new("+ Note"))
.clicked()
{
*note_intent = Some((row.id, buf.clone()));
}
});
});
ui.add_space(4.0);
}
let rect = card_response.rect;
card_rects.insert(row.id, rect);
let painter = ui.painter();
let stroke = egui::Stroke::new(1.2, color_muted());
for idx in 0..dep_lines {
let x = rect.left() - dep_indent + 4.0 + (idx as f32 * DEP_LINE_STEP);
let y1 = rect.top() + 0.5;
let y2 = rect.bottom() - 0.5;
painter.line_segment([egui::pos2(x, y1), egui::pos2(x, y2)], stroke);
}
}
fn draw_priority_edge(
painter: &egui::Painter,
from: egui::Rect,
to: egui::Rect,
color: egui::Color32,
) {
let (start, end, dir) = if from.center().x < to.center().x {
(
egui::pos2(from.right(), from.center().y),
egui::pos2(to.left() - 6.0, to.center().y),
1.0_f32,
)
} else {
(
egui::pos2(from.left(), from.center().y),
egui::pos2(to.right() + 6.0, to.center().y),
-1.0_f32,
)
};
let dx = (end.x - start.x).abs().max(40.0).min(240.0) * 0.5;
let c1 = egui::pos2(start.x + dir * dx, start.y);
let c2 = egui::pos2(end.x - dir * dx, end.y);
let stroke = egui::Stroke::new(1.5, color);
painter.add(egui::Shape::CubicBezier(egui::epaint::CubicBezierShape {
points: [start, c1, c2, end],
closed: false,
fill: egui::Color32::TRANSPARENT,
stroke: egui::epaint::PathStroke::new(stroke.width, stroke.color),
}));
let head_len = 6.0;
let back_x = end.x - dir * head_len;
let tip = end;
let back = egui::pos2(back_x, end.y);
let wing_up = egui::pos2(back_x, end.y - 3.5);
let wing_dn = egui::pos2(back_x, end.y + 3.5);
painter.add(egui::Shape::convex_polygon(
vec![tip, wing_up, back, wing_dn],
color,
egui::Stroke::NONE,
));
}
fn render_empty_state(ui: &mut egui::Ui, glyph: &str, headline: &str, hint: Option<&str>) {
ui.add_space(16.0);
ui.vertical_centered(|ui| {
ui.label(
egui::RichText::new(glyph)
.size(28.0)
.color(color_muted()),
);
ui.add_space(4.0);
ui.label(
egui::RichText::new(headline)
.monospace()
.small()
.strong()
.color(color_muted()),
);
if let Some(h) = hint {
ui.add_space(2.0);
ui.label(
egui::RichText::new(h)
.small()
.color(color_muted()),
);
}
});
ui.add_space(16.0);
}
fn render_chip(ui: &mut egui::Ui, label: &str, fill: egui::Color32) {
let text = colorhash::text_color_on(fill);
egui::Frame::NONE
.fill(fill)
.corner_radius(egui::CornerRadius::same(4))
.inner_margin(egui::Margin::symmetric(6, 1))
.show(ui, |ui| {
ui.label(egui::RichText::new(label).small().color(text));
});
}
fn render_status_chip(ui: &mut egui::Ui, label: &str, fill: egui::Color32) {
let text = colorhash::text_color_on(fill);
egui::Frame::NONE
.fill(fill)
.corner_radius(egui::CornerRadius::same(3))
.inner_margin(egui::Margin::symmetric(6, 2))
.show(ui, |ui| {
ui.label(
egui::RichText::new(label.to_uppercase())
.small()
.monospace()
.strong()
.color(text),
);
});
}