facett-file/src/lib.rs
//! **facett-file** — a file-attachment facet for ordning.
//!
//! A **drop zone** + an **attachment list** (name, size, type) with remove
//! buttons. In the ärende profile an attachment is a **handling**; this widget is
//! the upload/list surface, while the legal metadata (direction, diarienummer,
//! sekretess) lives on the case. The widget reports *intents* (files dropped,
//! a file removed) for the host to apply; it does no file I/O itself.
//!
//! Design: `ordning/.nornir/widgets/facett-file.md`. Built in ordning;
//! **destined for facett**.
use egui::{Color32, Sense, Ui, Vec2};
use serde::{Deserialize, Serialize};
/// A file already attached to the case.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileRef {
pub name: String,
pub size: u64,
/// MIME type or extension hint.
pub mime: String,
}
impl FileRef {
pub fn new(name: impl Into<String>, size: u64, mime: impl Into<String>) -> Self {
Self { name: name.into(), size, mime: mime.into() }
}
}
/// An attachment list + drop zone. Drain [`take_added`](Files::take_added) and
/// [`take_removed`](Files::take_removed) each frame to apply changes to the case.
#[derive(Default)]
pub struct Files {
files: Vec<FileRef>,
added: Vec<FileRef>,
removed: Vec<String>,
read_only: bool,
}
impl Files {
pub fn new() -> Self {
Self::default()
}
pub fn set_files(&mut self, files: Vec<FileRef>) {
self.files = files;
}
pub fn read_only(mut self, ro: bool) -> Self {
self.read_only = ro;
self
}
pub fn files(&self) -> &[FileRef] {
&self.files
}
/// Files dropped/added since the last call (the host persists them, then
/// re-`set_files` with the authoritative list).
pub fn take_added(&mut self) -> Vec<FileRef> {
std::mem::take(&mut self.added)
}
/// Names removed since the last call.
pub fn take_removed(&mut self) -> Vec<String> {
std::mem::take(&mut self.removed)
}
/// Attach a file **headlessly** — the robot counterpart of an OS drag-drop.
/// It lands in `files` AND in the `take_added` outbox, exactly like a drop.
/// Rejected (returns `false`) when the pane is read-only or the name already
/// exists, so a robot script cannot fake a live input surface.
pub fn attach(&mut self, file: FileRef) -> bool {
if self.read_only || self.files.iter().any(|f| f.name == file.name) {
return false;
}
self.files.push(file.clone());
self.added.push(file);
true
}
/// Detach a file by name — the robot counterpart of the ✕ button. `false`
/// when read-only or the name is unknown.
pub fn detach(&mut self, name: &str) -> bool {
if self.read_only {
return false;
}
match self.files.iter().position(|f| f.name == name) {
Some(i) => {
self.files.remove(i);
self.removed.push(name.to_string());
true
}
None => false,
}
}
/// The **shared demo fixture** — three attachments spanning the mime icons
/// the painter branches on (pdf / image / data). Consumed by the robot and
/// matrix tests (L5: one fixture, not one per test file).
pub fn local() -> Self {
let mut f = Self::new();
f.set_files(vec![
FileRef::new("ansokan.pdf", 184_320, "application/pdf"),
FileRef::new("situationsplan.png", 962_144, "image/png"),
FileRef::new("matningar.csv", 4_096, "text/csv"),
]);
f
}
pub fn title(&self) -> &str {
"Attachments"
}
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"count": self.files.len(),
"total_size": self.files.iter().map(|f| f.size).sum::<u64>(),
"read_only": self.read_only,
"files": self.files,
"pending_added": self.added,
"pending_removed": self.removed,
})
}
pub fn ui(&mut self, ui: &mut Ui) {
// Consume any OS file-drops onto the window (egui surfaces them on the ctx).
if !self.read_only {
let dropped = ui.ctx().input(|i| i.raw.dropped_files.clone());
for d in dropped {
// egui 0.36: a dropped file is a `DroppedFileHandle` (trait object). The
// name is the path's file name (on the web the path IS the file name);
// the size/MIME come from the browser `File` on the web, as the old
// `bytes`/`mime` fields did. Native drops carried no bytes and no MIME
// before either, so they still report size 0 and an extension MIME.
let name = d
.path()
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "fil".to_string());
#[cfg(target_arch = "wasm32")]
let (size, mime) = d
.web_file()
.map(|f| (f.size() as u64, f.type_()))
.unwrap_or((0, String::new()));
#[cfg(not(target_arch = "wasm32"))]
let (size, mime) = (0u64, String::new());
let mime = if mime.is_empty() { ext_of(&name) } else { mime };
self.added.push(FileRef::new(name, size, mime));
}
}
// The drop zone.
if !self.read_only {
let (rect, resp) = ui.allocate_exact_size(
Vec2::new(ui.available_width().min(360.0), 44.0),
Sense::hover(),
);
let hovering_files = ui.ctx().input(|i| !i.raw.hovered_files.is_empty());
let color = if hovering_files {
Color32::from_rgb(247, 198, 0)
} else {
Color32::from_rgb(70, 74, 84)
};
ui.painter().rect_stroke(
rect,
6.0,
egui::Stroke::new(1.5_f32, color),
egui::StrokeKind::Inside,
);
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
if hovering_files { "Släpp filer här" } else { "⬇ Dra filer hit för att bifoga" },
egui::FontId::proportional(13.0),
color,
);
resp.on_hover_text("Drag files from your file manager onto this zone");
}
ui.add_space(6.0);
// The list.
if self.files.is_empty() {
ui.label(egui::RichText::new("Inga bifogade filer").small().color(Color32::GRAY));
}
let mut remove: Option<String> = None;
for f in &self.files {
ui.horizontal(|ui| {
ui.label(file_icon(&f.mime));
ui.label(&f.name);
ui.label(egui::RichText::new(human_size(f.size)).small().color(Color32::GRAY));
if !self.read_only && ui.small_button("🗑").clicked() {
remove = Some(f.name.clone());
}
});
}
if let Some(name) = remove {
self.files.retain(|f| f.name != name);
self.removed.push(name);
}
}
}
fn ext_of(name: &str) -> String {
name.rsplit('.').next().map(|e| e.to_lowercase()).unwrap_or_default()
}
fn file_icon(mime: &str) -> &'static str {
let m = mime.to_lowercase();
if m.contains("pdf") {
"📄"
} else if m.contains("image") || ["png", "jpg", "jpeg", "gif", "webp", "tiff"].iter().any(|e| m.ends_with(e)) {
"🖼"
} else if m.contains("zip") || m.contains("compress") {
"🗜"
} else if ["doc", "docx", "odt", "txt", "md"].iter().any(|e| m.ends_with(e)) {
"📝"
} else {
"📎"
}
}
fn human_size(bytes: u64) -> String {
const U: [&str; 4] = ["B", "KB", "MB", "GB"];
let mut v = bytes as f64;
let mut i = 0;
while v >= 1024.0 && i < U.len() - 1 {
v /= 1024.0;
i += 1;
}
if i == 0 {
format!("{bytes} B")
} else {
format!("{v:.1} {}", U[i])
}
}
// ── The host-facing `Facet` contract ───────────────────────────────────────────
impl facett_core::Facet for Files {
fn title(&self) -> &str {
Files::title(self)
}
fn ui(&mut self, ui: &mut Ui) {
Files::ui(self, ui)
}
fn state_json(&self) -> serde_json::Value {
Files::state_json(self)
}
/// The **robot drive seam**: `{"attach":{"name":..,"size":..,"mime":..}}` is
/// the headless twin of an OS drop; `{"detach":"name"}` the ✕ button.
fn update_json(&mut self, msg_json: &str) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else { return };
if let Some(name) = v.get("detach").and_then(|s| s.as_str()) {
self.detach(name);
return;
}
if let Some(a) = v.get("attach") {
let (Some(name), Some(mime)) =
(a.get("name").and_then(|s| s.as_str()), a.get("mime").and_then(|s| s.as_str()))
else {
return;
};
let size = a.get("size").and_then(|s| s.as_u64()).unwrap_or(0);
self.attach(FileRef::new(name, size, mime));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn human_size_scales() {
assert_eq!(human_size(512), "512 B");
assert_eq!(human_size(2048), "2.0 KB");
assert_eq!(human_size(5 * 1024 * 1024), "5.0 MB");
}
#[test]
fn ext_and_icon() {
assert_eq!(ext_of("ansökan.PDF"), "pdf");
assert_eq!(file_icon("application/pdf"), "📄");
assert_eq!(file_icon("image/png"), "🖼");
}
#[test]
fn remove_emits_intent_and_state() {
let mut f = Files::new();
f.set_files(vec![FileRef::new("a.pdf", 1024, "application/pdf"), FileRef::new("b.png", 2048, "image/png")]);
// simulate the remove path
f.files.retain(|x| x.name != "a.pdf");
f.removed.push("a.pdf".into());
assert_eq!(f.take_removed(), vec!["a.pdf".to_string()]);
let s = f.state_json();
assert_eq!(s["count"], 1);
assert_eq!(s["total_size"], 2048);
}
}