// 檔案卡片和預覽 (the original: a rounded card with the file's icon, name and size; clicking it // opens a large viewer — spreadsheets as a grid with row numbers, documents rendered, pictures // shown — with download and close at the top right). use super::*; use crate::preview::{self, Preview}; use std::sync::{Arc, Mutex}; pub struct FileView { pub name: String, pub path: String, preview: Preview, sheet: usize, image: Option, /// PDF pages (or a video's first frame) drawn on a worker thread. job: Option>>, pages: Vec, audio: Option, audio_err: Option, /// The audio's length, known before it plays. audio_len: Option, } /// Pictures made on a worker thread for the viewer. #[derive(Default)] pub struct PageJob { ready: Vec, total: usize, done: bool, error: Option, } /// An audio file playing inside AnyBot (rodio: the same on macOS, Windows and Linux). pub struct AudioPlay { _sink: rodio::MixerDeviceSink, player: rodio::Player, total: Option, } impl AudioPlay { fn start(path: &str) -> Result { let mut sink = rodio::DeviceSinkBuilder::open_default_sink().map_err(|_| t("找不到可以播放聲音的裝置").to_string())?; sink.log_on_drop(false); let file = std::fs::File::open(path).map_err(|e| format!("{}:{e}", t("讀不到檔案")))?; let dec = match rodio::Decoder::try_from(file) { Ok(d) => d, // Opus and the rarer formats: let ffmpeg (when installed) turn it into WAV first. Err(_) => { let wav = std::env::temp_dir().join(format!("anybot-audio-{}.wav", std::process::id())); let ok = std::process::Command::new("ffmpeg") .env("PATH", crate::cloudbox::tool_path()) .args(["-loglevel", "error", "-y", "-i", path]) .arg(&wav) .stdin(std::process::Stdio::null()) .output() .map(|o| o.status.success()) .unwrap_or(false); let f = std::fs::File::open(&wav).ok().filter(|_| ok); f.and_then(|f| rodio::Decoder::try_from(f).ok()).ok_or_else(|| t("這種音訊格式沒辦法在 AnyBot 裡播放,可以用其他 App 打開。").to_string())? } }; let total = rodio::Source::total_duration(&dec); let player = rodio::Player::connect_new(sink.mixer()); player.append(dec); Ok(AudioPlay { _sink: sink, player, total }) } } fn clock(d: std::time::Duration) -> String { let s = d.as_secs(); if s >= 3600 { format!("{}:{:02}:{:02}", s / 3600, s / 60 % 60, s % 60) } else { format!("{}:{:02}", s / 60, s % 60) } } /// Draw a PDF's pages, or a video's first frame (with ffmpeg when it is installed), off the UI thread. fn start_job(kind: &str, path: &str) -> Option>> { let job = Arc::new(Mutex::new(PageJob::default())); let j = job.clone(); let path = std::path::PathBuf::from(path); match kind { "pdf" => { std::thread::spawn(move || { let r = preview::pdf_pages(&path, 1400.0, 300, |_, total, img| { let mut g = j.lock().unwrap(); g.total = total; g.ready.push(img); drop(g); // Stop when the viewer was closed. Arc::strong_count(&j) > 1 }); let mut g = j.lock().unwrap(); g.error = r.err(); g.done = true; }); } "image" => { // Decoding (a 6000×4000 photo, a big SVG) on the UI thread froze the window; // the viewer shows a spinner until this thread is done. std::thread::spawn(move || { let img = preview::decode_image(&path); let mut g = j.lock().unwrap(); if let Some(img) = img { g.ready.push(img); } g.done = true; }); } "video" => { std::thread::spawn(move || { let out = std::env::temp_dir().join(format!("anybot-poster-{}-{}.png", std::process::id(), crate::model::now_ms())); let ok = std::process::Command::new("ffmpeg") .env("PATH", crate::cloudbox::tool_path()) .args(["-loglevel", "error", "-y", "-ss", "0.5", "-i"]) .arg(&path) .args(["-frames:v", "1", "-vf", "scale='min(1280,iw)':-2"]) .arg(&out) .stdin(std::process::Stdio::null()) .output() .map(|o| o.status.success()) .unwrap_or(false); let mut g = j.lock().unwrap(); if ok { if let Ok(img) = image::open(&out) { g.ready.push(img.to_rgba8()); } } let _ = std::fs::remove_file(&out); g.done = true; }); } _ => return None, } Some(job) } fn texture(ctx: &egui::Context, name: &str, img: &image::RgbaImage) -> egui::TextureHandle { let size = [img.width() as usize, img.height() as usize]; ctx.load_texture(name, egui::ColorImage::from_rgba_unmultiplied(size, img.as_raw()), egui::TextureOptions::LINEAR) } const CARD_W: f32 = 320.0; const CARD_H: f32 = 64.0; /// The grey of the bot's bubbles (rgb 31 in dark mode like the original; a light grey in light). pub fn agent_fill(p: Palette) -> Color32 { if p.pane.r() < 128 { Color32::from_rgb(0x21, 0x21, 0x21) } else { Color32::from_rgb(0xf1, 0xf1, 0xf1) } } impl App { /// A file in the chat. Click: preview. Right side on hover: save a copy. pub(super) fn file_card(&mut self, ui: &mut egui::Ui, a: &crate::attach::Attachment) { let p = self.pal; let (rect, r) = ui.allocate_exact_size(vec2(CARD_W.min(ui.available_width()), CARD_H), egui::Sense::click()); let fill = agent_fill(p); ui.painter().rect_filled(rect, 16, if r.hovered() { fill.gamma_multiply(0.9).to_opaque() } else { fill }); let icon = egui::Rect::from_min_size(rect.left_top() + vec2(14.0, 12.0), vec2(34.0, 40.0)); big_file_icon(ui.painter(), icon, &a.name, p); let tx = icon.right() + 14.0; let max_w = rect.right() - tx - 12.0; let name = fit_name(ui, &a.name, max_w); ui.painter().text(egui::pos2(tx, rect.top() + 16.0), egui::Align2::LEFT_TOP, name, egui::FontId::proportional(14.0), p.text); let sub = if a.bytes > 0 { crate::attach::human(a.bytes) } else { preview::kind_label(&a.name) }; ui.painter().text(egui::pos2(tx, rect.top() + 36.0), egui::Align2::LEFT_TOP, sub, egui::FontId::proportional(12.0), p.text3); if r.on_hover_text(format!("{}({})", a.name, t("點一下預覽"))).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { self.open_file(&a.name, &a.path); } } pub(super) fn open_file(&mut self, name: &str, path: &str) { let preview = preview::load(std::path::Path::new(path), name); let job = start_job(preview::kind_of(name), path); self.file_view = Some(FileView { name: name.to_string(), path: path.to_string(), preview, sheet: 0, image: None, job, pages: vec![], audio: None, audio_err: None, audio_len: None }); if let Some(fv) = self.file_view.as_mut().filter(|_| preview::kind_of(name) == "audio") { fv.audio_len = std::fs::File::open(path).ok().and_then(|f| rodio::Decoder::try_from(f).ok()).and_then(|d| rodio::Source::total_duration(&d)); } } /// The large viewer. pub(super) fn file_viewer(&mut self, ctx: &egui::Context) { let Some(fv) = self.file_view.as_mut() else { return }; let p = self.pal; let screen = ctx.content_rect(); let w = (screen.width() - 80.0).clamp(320.0, 1100.0); let h = (screen.height() - 80.0).max(300.0); let dark = p.pane.r() < 128; let mut close = false; let mut save = false; let mut open_app = false; let resp = egui::Modal::new(egui::Id::unique("file-viewer")) .backdrop_color(Color32::from_black_alpha(if dark { 150 } else { 80 })) .frame(Frame::new().fill(p.pane).stroke(Stroke::new(1.0, p.border)).corner_radius(14).shadow(egui::epaint::Shadow { offset: [0, 12], blur: 40, spread: 0, color: Color32::from_black_alpha(90) })) .show(ctx, |ui| { ui.set_width(w); ui.set_height(h); ui.spacing_mut().item_spacing.y = 0.0; // Title, what it is, and download / close. Frame::new().inner_margin(Margin { left: 16, right: 10, top: 12, bottom: 10 }).show(ui, |ui| { ui.set_width(ui.available_width()); ui.horizontal(|ui| { ui.vertical(|ui| { ui.spacing_mut().item_spacing.y = 2.0; ui.add(egui::Label::new(RichText::new(&fv.name).size(14.0).strong().color(p.text)).truncate()); let sub = match &fv.preview { Preview::Table(s) => format!("{} {}", s.get(fv.sheet).map(|x| x.1.len().saturating_sub(1)).unwrap_or(0), t("列")), Preview::Pdf => match fv.job.as_ref().map(|j| j.lock().unwrap().total) { Some(n) if n > 0 => format!("PDF · {n} {}", t("頁")), _ => "PDF".into(), }, _ => preview::kind_label(&fv.name), }; ui.label(RichText::new(sub).size(12.0).color(p.text3)); }); ui.with_layout(Layout::right_to_left(Align::Center), |ui| { close = crate::icons::button(ui, crate::icons::Icon::Close, 32.0, p.text2, p.hover).on_hover_text(t("關閉預覽")).clicked(); save = crate::icons::button(ui, crate::icons::Icon::Download, 32.0, p.text2, p.hover).on_hover_text(t("下載檔案")).clicked(); open_app = small_button(ui, "用其他 App 打開", p).clicked(); }); }); }); let r = ui.min_rect(); ui.painter().hline(r.x_range(), r.bottom(), Stroke::new(1.0, p.border)); let body_h = ui.available_height(); match &fv.preview { Preview::Table(sheets) => { let tabs = sheets.len() > 1; let grid_h = body_h - if tabs { 40.0 } else { 0.0 }; if let Some((_, rows)) = sheets.get(fv.sheet) { Frame::new().inner_margin(Margin::same(12)).show(ui, |ui| sheet_grid(ui, rows, grid_h - 24.0, p)); } if tabs { ui.horizontal(|ui| { ui.add_space(12.0); for (i, (name, _)) in sheets.iter().enumerate() { if ui.selectable_label(fv.sheet == i, name).clicked() { fv.sheet = i; } } }); } } Preview::Markdown(md) => { egui::ScrollArea::vertical().max_height(body_h).auto_shrink([false, false]).show(ui, |ui| { Frame::new().inner_margin(Margin::symmetric(28, 20)).show(ui, |ui| { ui.set_max_width(760.0); doc_markdown(ui, md, p); }); }); } Preview::Html(text) => { Frame::new().inner_margin(Margin::symmetric(16, 10)).show(ui, |ui| { ui.horizontal(|ui| { if ui.add(primary_button("在瀏覽器開啟", p)).clicked() { open_app = true; } ui.label(RichText::new(t("下面是網頁的內容(版面以瀏覽器為準)")).size(12.0).color(p.text3)); }); }); egui::ScrollArea::vertical().max_height(body_h - 52.0).auto_shrink([false, false]).show(ui, |ui| { Frame::new().inner_margin(Margin::symmetric(28, 8)).show(ui, |ui| { ui.set_max_width(760.0); doc_markdown(ui, text, p); }); }); } Preview::Image => { // The picture is decoded on a worker thread (start_job "image"). if fv.image.is_none() { if let Some(job) = fv.job.clone() { let (imgs, done) = { let mut g = job.lock().unwrap(); (std::mem::take(&mut g.ready), g.done) }; match imgs.into_iter().next() { Some(img) => fv.image = Some(texture(ui.ctx(), "file-view", &img)), None => { ui.add_space(40.0); ui.vertical_centered(|ui| { if done { ui.label(RichText::new(t("這張圖片打不開")).color(p.text3)); } else { ui.spinner(); ui.ctx().request_repaint_after(std::time::Duration::from_millis(80)); } }); } } } else { fv.image = load_texture(ui.ctx(), &fv.path); } } match &fv.image { Some(tex) => { let avail = vec2(ui.available_width() - 24.0, body_h - 24.0); let size = tex.size_vec2(); let s = (avail.x / size.x).min(avail.y / size.y).min(1.0); ui.vertical_centered(|ui| { ui.add_space(12.0); ui.add(egui::Image::new((tex.id(), size * s)).corner_radius(8)); }); } None => { ui.add_space(20.0); ui.vertical_centered(|ui| ui.label(RichText::new(t("這張圖片打不開")).color(p.text3))); } } } Preview::Pdf => { let (mut done, mut err, mut total) = (true, None, 0); if let Some(job) = &fv.job { let mut g = job.lock().unwrap(); for (i, img) in g.ready.drain(..).enumerate() { let n = fv.pages.len() + i; fv.pages.push(texture(ui.ctx(), &format!("pdf-page-{n}"), &img)); } done = g.done; err = g.error.clone(); total = g.total; } if !done { ui.ctx().request_repaint_after(std::time::Duration::from_millis(80)); } if fv.pages.is_empty() { ui.add_space(40.0); ui.vertical_centered(|ui| match err { Some(e) if done => { ui.label(RichText::new(t(&e)).color(p.text2)); ui.add_space(10.0); if ui.add(secondary_button("用其他 App 打開")).clicked() { open_app = true; } } _ => { ui.add(egui::Spinner::new().size(22.0).color(p.text3)); ui.add_space(8.0); ui.label(RichText::new(t("正在打開 PDF…")).size(13.0).color(p.text3)); } }); } else { egui::ScrollArea::vertical().max_height(body_h).auto_shrink([false, false]).show(ui, |ui| { let page_w = (ui.available_width() - 48.0).min(900.0); ui.vertical_centered(|ui| { ui.spacing_mut().item_spacing.y = 14.0; ui.add_space(16.0); for (i, tex) in fv.pages.iter().enumerate() { let size = tex.size_vec2() * (page_w / tex.size_vec2().x); let r = ui.add(egui::Image::new((tex.id(), size)).corner_radius(4)); ui.painter().rect_stroke(r.rect, 4, Stroke::new(1.0, p.border), egui::StrokeKind::Outside); if total > 1 { r.on_hover_text(format!("{} {} / {total}", t("第"), i + 1)); } } if !done { ui.add(egui::Spinner::new().size(18.0).color(p.text3)); } else if total > fv.pages.len() { ui.label(RichText::new(format!("{} {} {}", t("只顯示前"), fv.pages.len(), t("頁,完整內容請用其他 App 打開"))).size(12.0).color(p.text3)); } ui.add_space(16.0); }); }); } } Preview::Audio => { ui.add_space((body_h * 0.28).max(30.0)); ui.vertical_centered(|ui| { let (tile, _) = ui.allocate_exact_size(vec2(88.0, 88.0), egui::Sense::hover()); let accent = Color32::from_rgb(0x0e, 0xa5, 0xe9); ui.painter().rect_filled(tile, 22, accent.gamma_multiply(0.16)); crate::icons::paint(ui.painter(), egui::Rect::from_center_size(tile.center(), vec2(40.0, 40.0)), crate::icons::Icon::FileAudio, accent); ui.add_space(18.0); let playing = fv.audio.as_ref().is_some_and(|a| !a.player.is_paused() && !a.player.empty()); let (pos, total) = fv.audio.as_ref().map(|a| (a.player.get_pos(), a.total.or(fv.audio_len))).unwrap_or((Default::default(), fv.audio_len)); let (btn, br) = ui.allocate_exact_size(vec2(56.0, 56.0), egui::Sense::click()); ui.painter().circle_filled(btn.center(), 28.0, if br.hovered() { p.text } else { p.text.gamma_multiply(0.92) }); let icon = if playing { crate::icons::Icon::Pause } else { crate::icons::Icon::Play }; let off = if playing { 0.0 } else { 2.0 }; crate::icons::paint(ui.painter(), egui::Rect::from_center_size(btn.center() + vec2(off, 0.0), vec2(22.0, 22.0)), icon, p.pane); if br.on_hover_text(if playing { t("暫停") } else { t("播放") }).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { match &fv.audio { Some(a) if a.player.empty() => fv.audio = None, Some(a) if playing => a.player.pause(), Some(a) => a.player.play(), None => {} } if fv.audio.is_none() { match AudioPlay::start(&fv.path) { Ok(a) => fv.audio = Some(a), Err(e) => fv.audio_err = Some(e), } } } ui.add_space(16.0); // Progress: drag to jump. let bar_w = (ui.available_width() - 120.0).min(420.0); let (bar, bresp) = ui.allocate_exact_size(vec2(bar_w, 18.0), egui::Sense::click_and_drag()); let line = egui::Rect::from_center_size(bar.center(), vec2(bar_w, 4.0)); ui.painter().rect_filled(line, 2, p.hover); let frac = match total { Some(t) if t.as_secs_f32() > 0.0 => (pos.as_secs_f32() / t.as_secs_f32()).clamp(0.0, 1.0), _ => 0.0, }; let mut done_part = line; done_part.set_right(line.left() + line.width() * frac); ui.painter().rect_filled(done_part, 2, p.text); ui.painter().circle_filled(egui::pos2(done_part.right(), line.center().y), 6.0, p.text); if let (Some(a), Some(tot), Some(at)) = (&fv.audio, total, bresp.interact_pointer_pos()) { let f = ((at.x - line.left()) / line.width()).clamp(0.0, 1.0); let _ = a.player.try_seek(tot.mul_f32(f)); } ui.add_space(6.0); let time = match total { Some(tot) => format!("{} / {}", clock(pos), clock(tot)), None => clock(pos), }; ui.label(RichText::new(time).size(12.0).color(p.text3)); if let Some(e) = &fv.audio_err { ui.add_space(10.0); ui.label(RichText::new(e).size(13.0).color(p.danger)); ui.add_space(6.0); if ui.add(secondary_button("用其他 App 打開")).clicked() { open_app = true; } } if playing { ui.ctx().request_repaint_after(std::time::Duration::from_millis(200)); } }); } Preview::Video => { let mut done = true; if let Some(job) = &fv.job { let mut g = job.lock().unwrap(); if let Some(img) = g.ready.pop() { fv.image = Some(texture(ui.ctx(), "video-poster", &img)); } done = g.done; } if !done { ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); } ui.vertical_centered(|ui| { ui.add_space(16.0); let avail = vec2(ui.available_width() - 48.0, body_h - 90.0); let (rect, resp) = match &fv.image { Some(tex) => { let size = tex.size_vec2(); let s = (avail.x / size.x).min(avail.y / size.y).min(1.5); let r = ui.add(egui::Image::new((tex.id(), size * s)).corner_radius(10).sense(egui::Sense::click())); (r.rect, r) } None => { let size = vec2(avail.x.min(640.0), (avail.x.min(640.0) * 9.0 / 16.0).min(avail.y)); let (r, resp) = ui.allocate_exact_size(size, egui::Sense::click()); ui.painter().rect_filled(r, 10, Color32::from_rgb(0x10, 0x10, 0x10)); if !done { egui::Spinner::new().size(20.0).color(Color32::from_gray(150)).paint_at(ui, egui::Rect::from_center_size(r.center() + vec2(0.0, 52.0), vec2(20.0, 20.0))); } (r, resp) } }; // The play button in the middle. ui.painter().circle_filled(rect.center(), 32.0, Color32::from_black_alpha(if resp.hovered() { 200 } else { 150 })); crate::icons::paint(ui.painter(), egui::Rect::from_center_size(rect.center() + vec2(2.0, 0.0), vec2(26.0, 26.0)), crate::icons::Icon::Play, Color32::WHITE); if resp.on_hover_text(t("播放影片")).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { open_app = true; } ui.add_space(12.0); ui.label(RichText::new(t("影片會用電腦的播放器播放")).size(12.0).color(p.text3)); }); } Preview::Text(text) => { egui::ScrollArea::both().max_height(body_h).auto_shrink([false, false]).show(ui, |ui| { Frame::new().inner_margin(Margin::symmetric(16, 12)).show(ui, |ui| { ui.add(egui::Label::new(RichText::new(text).monospace().size(12.5).color(p.text)).extend()); }); }); } Preview::Other(msg) => { ui.add_space(40.0); ui.vertical_centered(|ui| { ui.label(RichText::new(t(msg)).color(p.text2)); ui.add_space(10.0); if ui.add(secondary_button("用其他 App 打開")).clicked() { open_app = true; } }); } } }); let path = fv.path.clone(); let name = fv.name.clone(); if open_app { preview::open_with_system(&path); } if save { // 下載: save a copy where the person picks (Downloads by default). std::thread::spawn(move || { if let Some(to) = crate::share::choose_save(&name) { let _ = std::fs::copy(&path, to); } }); } if close || resp.should_close() { self.file_view = None; } } } fn load_texture(ctx: &egui::Context, path: &str) -> Option { let img = preview::decode_image(std::path::Path::new(path))?; Some(texture(ctx, "file-view", &img)) } /// A document in the viewer (Markdown, Word and web pages): headings, paragraphs with bold, /// italics and `code`, bulleted and numbered lists, quotes, rules, code blocks and tables. pub(super) fn doc_markdown(ui: &mut egui::Ui, md: &str, p: Palette) { ui.spacing_mut().item_spacing.y = 6.0; let lines: Vec<&str> = md.lines().collect(); let mut i = 0; while i < lines.len() { let line = lines[i].trim_end(); let t = line.trim_start(); if t.starts_with("```") { let mut code = String::new(); i += 1; while i < lines.len() && !lines[i].trim_start().starts_with("```") { code.push_str(lines[i]); code.push('\n'); i += 1; } Frame::new().fill(p.hover).corner_radius(8).inner_margin(Margin::same(12)).show(ui, |ui| { ui.set_width(ui.available_width()); ui.add(egui::Label::new(RichText::new(code.trim_end()).monospace().size(12.5).color(p.text)).wrap()); }); } else if t.starts_with('|') && i + 1 < lines.len() && is_rule_row(lines[i + 1]) { let mut rows = vec![table_cells(t)]; i += 2; while i < lines.len() && lines[i].trim_start().starts_with('|') { rows.push(table_cells(lines[i].trim())); i += 1; } doc_table(ui, &rows, p); continue; } else if t.is_empty() { ui.add_space(2.0); } else if t == "---" || t == "***" || t == "___" { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 12.0), egui::Sense::hover()); ui.painter().hline(r.x_range(), r.center().y, Stroke::new(1.0, p.border)); } else if let Some((level, h)) = heading(t) { ui.add_space(if level == 1 { 10.0 } else { 6.0 }); let size = [24.0, 20.0, 17.0][level - 1]; ui.add(egui::Label::new(inline_job(h, size, p.text, p, true)).wrap()); ui.add_space(2.0); } else if let Some(q) = t.strip_prefix("> ").or_else(|| t.strip_prefix('>')) { ui.horizontal_top(|ui| { let (bar, _) = ui.allocate_exact_size(vec2(3.0, 22.0), egui::Sense::hover()); ui.painter().rect_filled(bar, 2, p.border); ui.add_space(8.0); ui.add(egui::Label::new(inline_job(q, 15.0, p.text2, p, false)).wrap()); }); } else if let Some((marker, item)) = list_item(t) { let indent = (line.len() - t.len()) as f32 * 8.0; ui.horizontal_top(|ui| { ui.add_space(6.0 + indent); ui.add_sized(vec2(22.0, 22.0), egui::Label::new(RichText::new(marker).size(15.0).color(p.text2))); ui.add(egui::Label::new(inline_job(item, 15.0, p.text, p, false)).wrap()); }); } else { ui.add(egui::Label::new(inline_job(t, 15.0, p.text, p, false)).wrap()); } i += 1; } } fn heading(t: &str) -> Option<(usize, &str)> { let n = t.chars().take_while(|c| *c == '#').count(); (1..=6).contains(&n).then(|| t[n..].strip_prefix(' ')).flatten().map(|h| (n.min(3), h)) } fn list_item(t: &str) -> Option<(String, &str)> { for b in ["- ", "* ", "+ "] { if let Some(rest) = t.strip_prefix(b) { return Some(("•".into(), rest)); } } let digits = t.chars().take_while(|c| c.is_ascii_digit()).count(); if digits > 0 && digits < 4 { if let Some(rest) = t[digits..].strip_prefix(". ").or_else(|| t[digits..].strip_prefix(") ")) { return Some((format!("{}.", &t[..digits]), rest)); } } None } fn is_rule_row(l: &str) -> bool { let t = l.trim(); t.starts_with('|') && t.contains('-') && t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ')) } fn table_cells(l: &str) -> Vec { let inner = l.trim().trim_start_matches('|').trim_end_matches('|'); let mut cells = vec![]; let mut cur = String::new(); let mut esc = false; for c in inner.chars() { match c { '\\' if !esc => esc = true, '|' if !esc => cells.push(std::mem::take(&mut cur).trim().to_string()), c => { if esc && c != '|' { cur.push('\\'); } esc = false; cur.push(c); } } } cells.push(cur.trim().to_string()); cells } fn doc_table(ui: &mut egui::Ui, rows: &[Vec], p: Palette) { let n = rows.iter().map(|r| r.len()).max().unwrap_or(0); egui::ScrollArea::horizontal().id_salt(ui.next_auto_id()).auto_shrink([false, true]).show(ui, |ui| { Frame::new().stroke(Stroke::new(1.0, p.border)).corner_radius(8).show(ui, |ui| { egui::Grid::new(ui.next_auto_id()).spacing(vec2(0.0, 0.0)).show(ui, |ui| { for (ri, r) in rows.iter().enumerate() { for ci in 0..n { let cell = r.get(ci).map(String::as_str).unwrap_or(""); let fill = if ri == 0 { p.hover } else { Color32::TRANSPARENT }; Frame::new().fill(fill).inner_margin(Margin::symmetric(12, 8)).show(ui, |ui| { ui.set_min_width(60.0); ui.set_max_width(320.0); ui.add(egui::Label::new(inline_job(cell, 14.0, if ri == 0 { p.text } else { p.text2 }, p, ri == 0)).wrap()); }); } ui.end_row(); } }); }); }); ui.add_space(4.0); } /// One line of Markdown text with **bold**, *italics* and `code`. fn inline_job(s: &str, size: f32, col: Color32, p: Palette, strong: bool) -> egui::text::LayoutJob { use egui::text::{LayoutJob, TextFormat}; let mut job = LayoutJob::default(); let (mut bold, mut italic, mut code) = (strong, false, false); let mut buf = String::new(); let flush = |job: &mut LayoutJob, buf: &mut String, bold: bool, italic: bool, code: bool| { if buf.is_empty() { return; } let mut f = TextFormat { font_id: if code { egui::FontId::monospace(size - 1.5) } else { egui::FontId::proportional(size) }, color: if bold { p.text } else { col }, italics: italic, ..Default::default() }; if code { f.background = p.hover; } job.append(buf, 0.0, f); buf.clear(); }; let chars: Vec = s.chars().collect(); let mut i = 0; while i < chars.len() { let c = chars[i]; if c == '`' { flush(&mut job, &mut buf, bold, italic, code); code = !code; } else if !code && c == '*' && chars.get(i + 1) == Some(&'*') { flush(&mut job, &mut buf, bold, italic, code); bold = !bold; i += 1; } else if !code && c == '*' && (italic || chars.get(i + 1).is_some_and(|n| !n.is_whitespace())) { flush(&mut job, &mut buf, bold, italic, code); italic = !italic; } else { buf.push(c); } i += 1; } flush(&mut job, &mut buf, bold, italic, code); job } /// A spreadsheet grid like the original's: row numbers on the left, the first row as header, /// columns sized to their contents (60–260 px), long cells cut with "…" (full text on hover). fn sheet_grid(ui: &mut egui::Ui, rows: &[Vec], height: f32, p: Palette) { let font = egui::FontId::proportional(13.0); let ncols = rows.iter().map(|r| r.len()).max().unwrap_or(0); let mut widths = vec![60.0f32; ncols]; for r in rows.iter().take(60) { for (i, c) in r.iter().enumerate() { let w = ui.painter().layout_no_wrap(c.clone(), font.clone(), p.text).size().x + 20.0; widths[i] = widths[i].max(w.min(260.0)); } } let num_w = 44.0; let row_h = 28.0; let total_w = num_w + widths.iter().sum::(); let line = Stroke::new(1.0, p.border); egui::ScrollArea::both().max_height(height.max(100.0)).auto_shrink([false, false]).show_rows(ui, row_h, rows.len(), |ui, range| { for ri in range { let (rect, resp) = ui.allocate_exact_size(vec2(total_w, row_h), egui::Sense::hover()); let header = ri == 0; if header { ui.painter().rect_filled(rect, 0, p.hover); } // Row number. let nr = egui::Rect::from_min_size(rect.min, vec2(num_w, row_h)); ui.painter().rect_filled(nr, 0, p.hover); ui.painter().text(nr.right_center() - vec2(8.0, 0.0), egui::Align2::RIGHT_CENTER, format!("{}", ri + 1), egui::FontId::proportional(12.0), p.text3); let mut x = rect.left() + num_w; let mut hovered_cell: Option = None; for (ci, w) in widths.iter().enumerate() { let cell = egui::Rect::from_min_size(egui::pos2(x, rect.top()), vec2(*w, row_h)); let text = rows[ri].get(ci).cloned().unwrap_or_default(); let shown = fit_text(ui, &text, w - 16.0, &font); let col = if header { p.text } else { p.text2 }; ui.painter().with_clip_rect(cell).text(cell.left_center() + vec2(8.0, 0.0), egui::Align2::LEFT_CENTER, shown, font.clone(), col); ui.painter().vline(cell.right(), cell.y_range(), line); if resp.hovered() && cell.contains(ui.ctx().pointer_hover_pos().unwrap_or_default()) && !text.is_empty() { hovered_cell = Some(text); } x += w; } ui.painter().hline(rect.x_range(), rect.bottom(), line); ui.painter().vline(nr.right(), nr.y_range(), line); if let Some(full) = hovered_cell { resp.on_hover_text(full); } } }); } fn fit_text(ui: &egui::Ui, s: &str, max_w: f32, font: &egui::FontId) -> String { if ui.painter().layout_no_wrap(s.to_string(), font.clone(), Color32::WHITE).size().x <= max_w { return s.to_string(); } let chars: Vec = s.chars().collect(); let mut n = chars.len(); while n > 1 { n -= 1; let c: String = chars[..n].iter().collect::() + "…"; if ui.painter().layout_no_wrap(c.clone(), font.clone(), Color32::WHITE).size().x <= max_w { return c; } } "…".into() } /// "orders_20261102_packa… .xlsx": keep the extension like the original. fn fit_name(ui: &egui::Ui, name: &str, max_w: f32) -> String { let font = egui::FontId::proportional(14.0); if ui.painter().layout_no_wrap(name.to_string(), font.clone(), Color32::WHITE).size().x <= max_w { return name.to_string(); } let (stem, ext) = name.rsplit_once('.').map(|(a, b)| (a.to_string(), format!(" .{b}"))).unwrap_or((name.to_string(), String::new())); let chars: Vec = stem.chars().collect(); let mut n = chars.len(); while n > 1 { n -= 1; let c = chars[..n].iter().collect::() + "…" + &ext; if ui.painter().layout_no_wrap(c.clone(), font.clone(), Color32::WHITE).size().x <= max_w { return c; } } name.to_string() } /// The card's icon: the file kind's Lucide icon on a soft tile of the kind's colour /// (spreadsheets green, Markdown purple, web pages orange, PDF red, documents blue). fn big_file_icon(painter: &egui::Painter, r: egui::Rect, name: &str, p: Palette) { let col = match preview::kind_of(name) { "sheet" => Color32::from_rgb(0x1f, 0xb3, 0x5d), "markdown" => Color32::from_rgb(0x8b, 0x5c, 0xf6), "html" => Color32::from_rgb(0xf9, 0x73, 0x16), "pdf" => Color32::from_rgb(0xe5, 0x48, 0x4d), "doc" | "docx" => Color32::from_rgb(0x16, 0x80, 0xf3), "json" => Color32::from_rgb(0xea, 0xb3, 0x08), "image" | "video" | "audio" => Color32::from_rgb(0x0e, 0xa5, 0xe9), _ => p.text2, }; let tile = egui::Rect::from_center_size(r.center(), vec2(40.0, 40.0)); painter.rect_filled(tile, 10, col.gamma_multiply(0.16)); crate::icons::paint(painter, egui::Rect::from_center_size(tile.center(), vec2(22.0, 22.0)), crate::icons::for_file(name), col); }