AnyBot/src/app/files_ui.rs

793 lines
40 KiB
Rust
Raw Normal View History

// 檔案卡片和預覽 (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);
}