AnyBot/src/app/files_ui.rs

793 lines
40 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 檔案卡片和預覽 (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<egui::TextureHandle>,
/// PDF pages (or a video's first frame) drawn on a worker thread.
job: Option<Arc<Mutex<PageJob>>>,
pages: Vec<egui::TextureHandle>,
audio: Option<AudioPlay>,
audio_err: Option<String>,
/// The audio's length, known before it plays.
audio_len: Option<std::time::Duration>,
}
/// Pictures made on a worker thread for the viewer.
#[derive(Default)]
pub struct PageJob {
ready: Vec<image::RgbaImage>,
total: usize,
done: bool,
error: Option<String>,
}
/// 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<std::time::Duration>,
}
impl AudioPlay {
fn start(path: &str) -> Result<Self, String> {
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<Arc<Mutex<PageJob>>> {
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<egui::TextureHandle> {
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<String> {
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<String>], 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<char> = 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<String>], 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::<f32>();
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<String> = 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<char> = s.chars().collect();
let mut n = chars.len();
while n > 1 {
n -= 1;
let c: String = chars[..n].iter().collect::<String>() + "…";
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<char> = stem.chars().collect();
let mut n = chars.len();
while n > 1 {
n -= 1;
let c = chars[..n].iter().collect::<String>() + "…" + &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);
}