use std::collections::BTreeSet;
use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Timelike, Utc};
use hifitime::Epoch;
use GORBIE::prelude::CardCtx;
use GORBIE::themes::{blend, colorhash};
use crate::memory::{self};
use crate::memory_cover::{chunk_about_archive_message, chunk_about_exec_result, chunk_references};
use crate::schemas::memory::{ctx, KIND_CHUNK_ID};
use crate::widgets::storage::{DatasetRevision, DatasetView};
use triblespace::core::id::Id;
use triblespace::core::metadata;
use triblespace::prelude::*;
const MAX_CHUNKS: usize = 40;
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 chunk_color(id: Id) -> egui::Color32 {
colorhash::ral_categorical(id.as_ref())
}
#[derive(Clone, Debug)]
struct ChunkRow {
id: Id,
start: DateTime<Utc>,
end: DateTime<Utc>,
summary: String,
reference_count: usize,
about_exec_result: Option<Id>,
about_archive_message: Option<Id>,
}
impl ChunkRow {
fn span_seconds(&self) -> i64 {
(self.end - self.start).num_seconds().max(0)
}
}
struct MemorySnapshot {
cached_revision: DatasetRevision,
chunks: Vec<ChunkRow>,
total: usize,
}
impl MemorySnapshot {
fn refresh(dataset: DatasetView<'_>) -> Self {
let space = dataset.facts;
let mut chunks = Vec::new();
for (id, start, end) in find!(
(
id: Id,
start: Inline<inlineencodings::NsTAIInterval>,
end: Inline<inlineencodings::NsTAIInterval>
),
pattern!(space, [{
?id @ metadata::tag: &KIND_CHUNK_ID,
ctx::start_at: ?start,
ctx::end_at: ?end,
}])
) {
let Ok((start, _)): Result<(Epoch, Epoch), _> = start.try_from_inline() else {
continue;
};
let Ok((end, _)): Result<(Epoch, Epoch), _> = end.try_from_inline() else {
continue;
};
let (Some(start), Some(end)) = (epoch_to_chrono(start), epoch_to_chrono(end)) else {
continue;
};
if end < start {
continue;
}
let summary_handles: BTreeSet<memory::TextHandle> = find!(
handle: memory::TextHandle,
pattern!(space, [{ id @ ctx::summary: ?handle }])
)
.collect();
let summaries = if summary_handles.is_empty() {
vec!["Image memory".to_owned()]
} else {
summary_handles
.into_iter()
.map(|handle| {
memory::read_text(dataset.reader, handle)
.unwrap_or_else(|_| "[summary unavailable]".to_owned())
})
.collect()
};
for summary in summaries {
chunks.push(ChunkRow {
id,
start,
end,
summary,
reference_count: chunk_references(space, id).len(),
about_exec_result: chunk_about_exec_result(space, id),
about_archive_message: chunk_about_archive_message(space, id),
});
}
}
chunks.sort_by(|left, right| {
(left.id, left.start, left.end, &left.summary).cmp(&(
right.id,
right.start,
right.end,
&right.summary,
))
});
chunks.dedup_by(|left, right| {
left.id == right.id
&& left.start == right.start
&& left.end == right.end
&& left.summary == right.summary
});
let total = chunks.len();
chunks.sort_by(|a, b| b.start.cmp(&a.start).then_with(|| a.id.cmp(&b.id)));
chunks.truncate(MAX_CHUNKS);
MemorySnapshot {
cached_revision: dataset.revision,
chunks,
total,
}
}
}
fn epoch_to_chrono(e: Epoch) -> Option<DateTime<Utc>> {
let secs = e.to_unix_seconds();
if !secs.is_finite() {
return None;
}
let whole = secs.floor();
if whole < i64::MIN as f64 || whole > i64::MAX as f64 {
return None;
}
let nanos = ((secs - whole) * 1e9).round().clamp(0.0, 999_999_999.0) as u32;
Utc.timestamp_opt(whole as i64, nanos).single()
}
fn format_chunk_range(start: DateTime<Utc>, end: DateTime<Utc>) -> String {
if start.date_naive() == end.date_naive() {
format!(
"{} {:02}:{:02} → {:02}:{:02}",
short_date(start.date_naive()),
start.hour(),
start.minute(),
end.hour(),
end.minute(),
)
} else {
format!(
"{} {:02}:{:02} → {} {:02}:{:02}",
short_date(start.date_naive()),
start.hour(),
start.minute(),
short_date(end.date_naive()),
end.hour(),
end.minute(),
)
}
}
fn short_date(d: NaiveDate) -> String {
let weekday = d.format("%a").to_string().to_uppercase();
let month = d.format("%b").to_string().to_uppercase();
format!("{weekday} {} {month}", d.day())
}
fn format_span(secs: i64) -> String {
let s = secs.max(1);
if s >= 86_400 {
let d = s as f32 / 86_400.0;
if d >= 10.0 {
format!("{d:.0}D")
} else {
format!("{d:.1}D")
}
} else if s >= 3_600 {
let h = s as f32 / 3_600.0;
if h >= 10.0 {
format!("{h:.0}H")
} else {
format!("{h:.1}H")
}
} else if s >= 60 {
format!("{}M", s / 60)
} else {
format!("{s}S")
}
}
fn id_hex(id: Id) -> String {
format!("{id:x}")
}
fn first_line(text: &str, max_chars: usize) -> String {
let line = text.lines().next().unwrap_or("").trim_start();
if line.chars().count() > max_chars {
let truncated: String = line.chars().take(max_chars.saturating_sub(1)).collect();
format!("{truncated}…")
} else {
line.to_string()
}
}
pub struct MemoryViewer {
snapshot: Option<MemorySnapshot>,
}
impl Default for MemoryViewer {
fn default() -> Self {
Self { snapshot: None }
}
}
impl MemoryViewer {
pub fn new() -> Self {
Self::default()
}
pub fn render(&mut self, ctx: &mut CardCtx<'_>, dataset: DatasetView<'_>) {
let need_refresh = match self.snapshot.as_ref() {
None => true,
Some(l) => l.cached_revision != dataset.revision,
};
if need_refresh {
self.snapshot = Some(MemorySnapshot::refresh(dataset));
}
ctx.section("Memory", |ctx| {
let Some(snapshot) = self.snapshot.as_ref() else {
return;
};
ctx.grid(|g| {
g.full(|ctx| {
let ui = ctx.ui_mut();
let shown = snapshot.chunks.len();
let label = if shown < snapshot.total {
format!(
"SHOWING {shown} OF {} MEMORY CHUNKS (NEWEST FIRST)",
snapshot.total,
)
} else {
format!("{shown} MEMORY CHUNK{}", if shown == 1 { "" } else { "S" },)
};
ui.label(
egui::RichText::new(label)
.monospace()
.strong()
.small()
.color(color_muted(ui)),
);
});
if snapshot.chunks.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{1F9E0}") .size(28.0)
.color(color_muted(ui)),
);
ui.add_space(4.0);
ui.label(
egui::RichText::new("No memory chunks yet.")
.monospace()
.small()
.strong()
.color(color_muted(ui)),
);
});
ui.add_space(16.0);
});
return;
}
for chunk in &snapshot.chunks {
g.full(|ctx| {
render_chunk_card(ctx.ui_mut(), chunk);
});
}
});
});
}
}
fn render_chunk_card(ui: &mut egui::Ui, chunk: &ChunkRow) {
let bubble_fill = ui.visuals().window_fill;
let accent = chunk_color(chunk.id);
let text_on_accent = colorhash::text_color_on(accent);
let body_text = colorhash::text_color_on(bubble_fill);
let body_muted = blend(body_text, bubble_fill, 0.22);
egui::Frame::NONE
.fill(bubble_fill)
.stroke(egui::Stroke::new(1.0_f32, 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: 6,
bottom: 6,
})
.show(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 2.0;
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format_chunk_range(chunk.start, chunk.end))
.monospace()
.strong()
.color(text_on_accent),
);
ui.label(
egui::RichText::new(format!("· {}", format_span(chunk.span_seconds())))
.monospace()
.small()
.strong()
.color(text_on_accent),
);
if chunk.reference_count > 0 {
ui.label(
egui::RichText::new(format!(
"· {} REF{}",
chunk.reference_count,
if chunk.reference_count == 1 { "" } else { "S" }
))
.monospace()
.small()
.color(text_on_accent),
);
}
});
let preview = first_line(&chunk.summary, 90);
if !preview.is_empty() {
ui.label(
egui::RichText::new(preview)
.size(14.0)
.color(text_on_accent),
);
}
});
egui::Frame::NONE
.fill(bubble_fill)
.corner_radius(egui::CornerRadius::ZERO)
.inner_margin(egui::Margin {
left: 10,
right: 10,
top: 6,
bottom: 8,
})
.show(ui, |ui| {
ui.set_min_width(ui.available_width());
ui.spacing_mut().item_spacing.y = 4.0;
let rest = body_rest(&chunk.summary, 180);
if !rest.is_empty() {
ui.label(egui::RichText::new(rest).size(13.0).color(body_text));
}
let has_provenance =
chunk.about_exec_result.is_some() || chunk.about_archive_message.is_some();
if has_provenance {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = egui::vec2(4.0, 4.0);
if let Some(eid) = chunk.about_exec_result {
render_provenance_chip(ui, "EXEC", eid);
}
if let Some(mid) = chunk.about_archive_message {
render_provenance_chip(ui, "MSG", mid);
}
});
}
ui.label(
egui::RichText::new(id_hex(chunk.id))
.monospace()
.small()
.color(body_muted),
);
});
});
}
fn render_provenance_chip(ui: &mut egui::Ui, label: &str, id: Id) {
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(format!("\u{261E} {label} {}", id_hex(id))) .monospace()
.small()
.strong()
.color(text),
);
});
}
fn body_rest(text: &str, max_chars: usize) -> String {
let after_first = text.split_once('\n').map(|(_, rest)| rest).unwrap_or("");
let trimmed = after_first.trim_start_matches(['\n', ' ']);
if trimmed.is_empty() {
return String::new();
}
if trimmed.chars().count() > max_chars {
let truncated: String = trimmed.chars().take(max_chars.saturating_sub(1)).collect();
format!("{truncated}…")
} else {
trimmed.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
use crate::memory_cover::all_chunk_ids;
use triblespace::prelude::{Fragment, TryToInline};
fn point(seconds: f64) -> memory::IntervalValue {
let at = Epoch::from_tai_seconds(seconds);
(at, at).try_to_inline().unwrap()
}
fn chunk(summary: &str) -> (Fragment, Id) {
memory::chunk_fragment(memory::ChunkDraft {
content: memory::ChunkDraftContent::Text(summary.to_owned()),
start_at: point(10.0),
end_at: point(20.0),
lens: None,
references: BTreeSet::new(),
about_exec_result: None,
about_archive_message: None,
observed_at: BTreeSet::new(),
aliases: BTreeSet::new(),
})
.unwrap()
}
#[test]
fn multiple_tellings_of_one_span_coexist() {
let (mut fragment, first) = chunk("first telling");
let (second_fragment, second) = chunk("second telling");
fragment += second_fragment;
let mut ids = all_chunk_ids(fragment.facts());
ids.sort_unstable();
let mut expected = vec![first, second];
expected.sort_unstable();
assert_eq!(ids, expected);
}
}