AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
// 檔案卡片和預覽 (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;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-11-05 07:48:22 +00:00
|
|
|
|
"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;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
"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 => {
|
2026-11-05 07:48:22 +00:00
|
|
|
|
// The picture is decoded on a worker thread (start_job "image").
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
if fv.image.is_none() {
|
2026-11-05 07:48:22 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
AnyBot: first commit of the working desktop app
Rust/egui rebuild of Grok Bot as of 2026-11-04: chat with streaming
providers (xAI, OpenAI, Claude, Gemini, OpenRouter, OpenCode Go, Ollama,
custom), tools and approvals, the bot's Docker computer, computer use on
macOS / Linux X11 / Wayland, AnyBot's browser with a full DevTools-based
toolset, the encrypted vault, pinned grid and sidebar sections, Markdown
with Mermaid diagrams, phone access through the relay, and the Linux
build and test scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uirha3oEtWeHh58zA7ErHa
2026-11-04 08:51:51 +00:00
|
|
|
|
}
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|