use GORBIE::prelude::CardCtx;
use GORBIE::themes::colorhash;
use triblespace::core::id::Id;
use crate::relations::{self, Head, ProfileInput, ProfileView};
use crate::widgets::storage::{DatasetRevision, DatasetView};
fn color_muted(ui: &egui::Ui) -> egui::Color32 {
if ui.visuals().dark_mode {
egui::Color32::from_rgb(0x9a, 0x9a, 0x9a)
} else {
egui::Color32::from_rgb(0x6a, 0x6a, 0x6a)
}
}
fn color_frame(ui: &egui::Ui) -> egui::Color32 {
if ui.visuals().dark_mode {
egui::Color32::from_rgb(0x29, 0x32, 0x36)
} else {
egui::Color32::from_rgb(0xec, 0xec, 0xec)
}
}
fn person_color(id: Id) -> egui::Color32 {
colorhash::ral_categorical(id.as_ref())
}
#[derive(Clone, Debug)]
struct PersonRow {
id: Id,
alias: Option<String>,
aliases: Vec<String>,
first_name: Option<String>,
last_name: Option<String>,
display_name: Option<String>,
email: Option<String>,
affinities: Vec<String>,
notices: Vec<String>,
}
impl PersonRow {
fn empty(id: Id) -> Self {
Self {
id,
alias: None,
aliases: Vec::new(),
first_name: None,
last_name: None,
display_name: None,
email: None,
affinities: Vec::new(),
notices: Vec::new(),
}
}
fn from_profile(id: Id, mut profile: ProfileInput) -> Self {
profile.aliases.sort();
profile.aliases.dedup();
profile.emails.sort();
profile.emails.dedup();
profile.affinities.sort();
profile.affinities.dedup();
Self {
id,
alias: Some(profile.label),
aliases: profile.aliases,
first_name: profile.first_name,
last_name: profile.last_name,
display_name: profile.display_name,
email: profile.emails.into_iter().next(),
affinities: profile.affinities,
notices: Vec::new(),
}
}
}
fn row_from_profile_view(person: Id, view: ProfileView) -> PersonRow {
match view {
ProfileView::Current { value, .. } => PersonRow::from_profile(person, value),
ProfileView::Forked(heads) => {
let mut row = PersonRow::empty(person);
row.notices.push(format!(
"PROFILE FORK · {}",
heads
.iter()
.map(|id| format!("{id:x}"))
.collect::<Vec<_>>()
.join(", ")
));
row
}
ProfileView::Invalid(error) => {
let mut row = PersonRow::empty(person);
row.notices.push(format!("INVALID PROFILE · {error}"));
row
}
}
}
impl PersonRow {
fn primary(&self) -> String {
if let Some(a) = self.alias.as_ref() {
if !a.is_empty() {
return a.clone();
}
}
match (self.first_name.as_ref(), self.last_name.as_ref()) {
(Some(f), Some(l)) if !f.is_empty() && !l.is_empty() => {
return format!("{f} {l}");
}
(Some(f), _) if !f.is_empty() => return f.clone(),
(_, Some(l)) if !l.is_empty() => return l.clone(),
_ => {}
}
if let Some(d) = self.display_name.as_ref() {
if !d.is_empty() {
return d.clone();
}
}
id_hex(self.id)
}
fn secondary(&self) -> Option<String> {
let primary = self.primary();
let full = match (self.first_name.as_ref(), self.last_name.as_ref()) {
(Some(f), Some(l)) if !f.is_empty() && !l.is_empty() => Some(format!("{f} {l}")),
_ => self.display_name.clone(),
};
match full {
Some(f) if f != primary && !f.is_empty() => Some(f),
_ => None,
}
}
fn sort_key(&self) -> String {
self.alias
.clone()
.unwrap_or_else(|| self.primary())
.to_lowercase()
}
}
fn id_hex(id: Id) -> String {
format!("{id:x}")
}
struct RelationsLive {
cached_revision: DatasetRevision,
people: Vec<PersonRow>,
}
impl RelationsLive {
fn refresh(dataset: DatasetView<'_>) -> Self {
let mut people: Vec<PersonRow> =
relations::person_profile_views(dataset.reader, dataset.facts)
.into_iter()
.map(|(person, view)| {
let mut row = row_from_profile_view(person, view);
match relations::lifecycle_head(dataset.facts, person) {
Ok(Head::Unique(snapshot)) => {
match relations::lifecycle_snapshot(dataset.facts, snapshot) {
Ok(lifecycle) if lifecycle.retired => {
row.notices.push("RETIRED".to_owned());
}
Ok(_) => {}
Err(error) => {
row.notices.push(format!("INVALID LIFECYCLE · {error}"))
}
}
}
Ok(Head::Forked(heads)) => row.notices.push(format!(
"LIFECYCLE FORK · {}",
heads
.iter()
.map(|id| format!("{id:x}"))
.collect::<Vec<_>>()
.join(", ")
)),
Ok(Head::Missing) => row.notices.push("MISSING LIFECYCLE".to_owned()),
Err(error) => row.notices.push(format!("INVALID LIFECYCLE · {error}")),
}
row
})
.collect();
people.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
RelationsLive {
cached_revision: dataset.revision,
people,
}
}
}
pub struct RelationsViewer {
live: Option<RelationsLive>,
}
impl Default for RelationsViewer {
fn default() -> Self {
Self { live: None }
}
}
impl RelationsViewer {
pub fn new() -> Self {
Self::default()
}
pub fn render(&mut self, ctx: &mut CardCtx<'_>, dataset: DatasetView<'_>) {
let need_refresh = match self.live.as_ref() {
None => true,
Some(l) => l.cached_revision != dataset.revision,
};
if need_refresh {
self.live = Some(RelationsLive::refresh(dataset));
}
ctx.section("Relations", |ctx| {
let Some(live) = self.live.as_ref() else {
return;
};
let mut search = ctx.search();
let needle = search.query().to_lowercase();
let search_active = !needle.is_empty();
let visible_count = live
.people
.iter()
.filter(|p| !search_active || person_matches_search(p, &needle))
.count();
ctx.grid(|g| {
g.full(|ctx| {
let ui = ctx.ui_mut();
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 12.0;
ui.label(
egui::RichText::new(format!(
"{visible_count} / {} PEOPLE",
live.people.len()
))
.monospace()
.strong()
.small()
.color(color_muted(ui)),
);
});
});
if live.people.is_empty() {
g.full(|ctx| {
let ui = ctx.ui_mut();
ui.add_space(16.0);
ui.vertical_centered(|ui| {
ui.label(
egui::RichText::new("\u{1F465}") .size(28.0)
.color(color_muted(ui)),
);
ui.add_space(4.0);
ui.label(
egui::RichText::new("No relations yet.")
.monospace()
.small()
.strong()
.color(color_muted(ui)),
);
});
ui.add_space(16.0);
});
return;
}
for person in &live.people {
if search_active && !person_matches_search(person, &needle) {
continue;
}
let match_info = if search_active {
Some(search.report(egui::Id::new(("relations_match", person.id))))
} else {
None
};
let is_focused = match_info.as_ref().map_or(false, |i| i.is_focused);
g.full(|ctx| {
let ui = ctx.ui_mut();
let pre_y = ui.cursor().min.y;
render_person(ui, person, &needle, is_focused);
if let Some(info) = match_info {
if info.should_scroll_to {
let post_y = ui.cursor().min.y;
let rect = egui::Rect::from_min_max(
egui::pos2(ui.min_rect().left(), pre_y),
egui::pos2(ui.min_rect().right(), post_y),
);
ui.scroll_to_rect(rect, Some(egui::Align::Center));
}
}
});
}
});
});
}
}
fn person_matches_search(p: &PersonRow, needle: &str) -> bool {
let candidates = [
p.alias.as_deref(),
p.first_name.as_deref(),
p.last_name.as_deref(),
p.display_name.as_deref(),
p.email.as_deref(),
];
for c in candidates.iter().flatten() {
if c.to_lowercase().contains(needle) {
return true;
}
}
for value in p.aliases.iter().chain(&p.affinities).chain(&p.notices) {
if value.to_lowercase().contains(needle) {
return true;
}
}
false
}
const STROKE_INSET: f32 = 1.0;
const NAME_FONT_SIZE: f32 = 18.0;
fn render_person(ui: &mut egui::Ui, person: &PersonRow, search_needle: &str, focused: bool) {
let bubble_fill = ui.visuals().window_fill;
let accent = person_color(person.id);
let text_on_accent = colorhash::text_color_on(accent);
let muted = color_muted(ui);
egui::Frame::NONE
.fill(bubble_fill)
.stroke(egui::Stroke::new(STROKE_INSET, color_frame(ui)))
.shadow(egui::epaint::Shadow {
offset: [2, 2],
blur: 0,
spread: 0,
color: egui::Color32::from_black_alpha(48),
})
.corner_radius(egui::CornerRadius::ZERO)
.inner_margin(egui::Margin::ZERO)
.show(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 0.0;
egui::Frame::NONE
.fill(accent)
.corner_radius(egui::CornerRadius::ZERO)
.inner_margin(egui::Margin {
left: 10,
right: 10,
top: 8,
bottom: 8,
})
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 2.0;
let name_format = egui::TextFormat {
font_id: egui::FontId::new(NAME_FONT_SIZE, egui::FontFamily::Proportional),
color: text_on_accent,
..Default::default()
};
GORBIE::search::highlight_label(
ui,
&person.primary(),
search_needle,
name_format,
focused,
);
ui.label(
egui::RichText::new(id_hex(person.id))
.monospace()
.small()
.color(text_on_accent),
);
});
egui::Frame::NONE
.fill(bubble_fill)
.corner_radius(egui::CornerRadius::ZERO)
.inner_margin(egui::Margin {
left: 10,
right: 10,
top: 8,
bottom: 10,
})
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 2.0;
if let Some(secondary) = person.secondary() {
GORBIE::search::highlight_label(
ui,
&secondary,
search_needle,
body_format(ui, ui.visuals().text_color()),
focused,
);
}
if let Some(email) = person.email.as_ref() {
GORBIE::search::highlight_label(
ui,
email,
search_needle,
mono_small_format(ui, muted),
focused,
);
}
if !person.affinities.is_empty() {
ui.add_space(2.0);
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(4.0, 4.0);
for aff in &person.affinities {
render_tag_chip(ui, aff);
}
});
}
for notice in &person.notices {
ui.add_space(2.0);
ui.label(
egui::RichText::new(notice)
.monospace()
.small()
.strong()
.color(egui::Color32::from_rgb(0xe6, 0x32, 0x46)),
);
}
});
});
}
fn render_tag_chip(ui: &mut egui::Ui, label: &str) {
let fill = colorhash::ral_categorical(label.as_bytes());
let text = colorhash::text_color_on(fill);
egui::Frame::NONE
.fill(fill)
.corner_radius(egui::CornerRadius::ZERO)
.inner_margin(egui::Margin::symmetric(5, 1))
.show(ui, |ui| {
ui.label(
egui::RichText::new(label)
.monospace()
.small()
.strong()
.color(text),
);
});
}
fn body_format(ui: &egui::Ui, color: egui::Color32) -> egui::TextFormat {
egui::TextFormat {
font_id: egui::TextStyle::Body.resolve(ui.style()),
color,
..Default::default()
}
}
fn mono_small_format(ui: &egui::Ui, color: egui::Color32) -> egui::TextFormat {
egui::TextFormat {
font_id: egui::TextStyle::Monospace.resolve(ui.style()),
color,
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn id(byte: u8) -> Id {
Id::new([byte; 16]).unwrap()
}
#[test]
fn canonical_profile_input_maps_without_timestamp_arbitration() {
let row = PersonRow::from_profile(
id(1),
ProfileInput {
label: "example".to_owned(),
aliases: vec!["sample".to_owned(), "sample".to_owned()],
affinities: vec!["operator".to_owned()],
first_name: Some("Example".to_owned()),
emails: vec![
"z@example.invalid".to_owned(),
"a@example.invalid".to_owned(),
],
..ProfileInput::default()
},
);
assert_eq!(row.primary(), "example");
assert_eq!(row.aliases, vec!["sample"]);
assert_eq!(row.email.as_deref(), Some("a@example.invalid"));
assert!(row.notices.is_empty());
}
#[test]
fn forked_profile_projection_remains_visible() {
let person = id(2);
let first = id(3);
let second = id(4);
let row = row_from_profile_view(person, ProfileView::Forked(vec![first, second]));
assert_eq!(row.id, person);
assert_eq!(row.notices.len(), 1);
assert!(row.notices[0].contains(&format!("{first:x}")));
assert!(row.notices[0].contains(&format!("{second:x}")));
}
}