Files
wayfinder/src/main.rs.backup-20261018T134521Z
T
2026-10-18 14:25:13 +00:00

930 lines
31 KiB
Plaintext

use gtk::gio;
use gtk::glib;
use gtk::prelude::*;
use std::cell::Cell;
use std::cell::RefCell;
use std::fs;
use std::io::Read;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::rc::Rc;
use std::time::{Duration, Instant};
#[derive(Clone)]
struct Entry {
path: PathBuf,
name: String,
directory: bool,
size: u64,
modified: String,
}
#[derive(Clone)]
struct Browser {
location: Rc<RefCell<PathBuf>>,
show_hidden: Rc<RefCell<bool>>,
list: gtk::ListBox,
location_entry: gtk::Entry,
status: gtk::Label,
filter: gtk::SearchEntry,
preview_title: gtk::Label,
preview_details: gtk::Label,
preview_panel: gtk::Box,
preview_stack: gtk::Stack,
preview_text: gtk::TextView,
preview_picture: gtk::Picture,
preview_message: gtk::Label,
preview_request: Rc<Cell<u64>>,
preview_job: Rc<RefCell<Option<PreviewJob>>>,
preview_file: Rc<RefCell<Option<PathBuf>>>,
}
struct PreviewJob {
request: u64,
child: Child,
output: PathBuf,
started: Instant,
}
fn main() {
let app = gtk::Application::builder()
.application_id("dev.wfalkowski.Wayfinder")
.flags(gio::ApplicationFlags::NON_UNIQUE)
.build();
app.connect_activate(build_ui);
app.run();
}
fn build_ui(app: &gtk::Application) {
install_style();
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/"));
let window = gtk::ApplicationWindow::builder()
.application(app)
.title("Wayfinder")
.default_width(1080)
.default_height(700)
.build();
let root = gtk::Box::new(gtk::Orientation::Vertical, 0);
root.add_css_class("root");
let toolbar = gtk::Box::new(gtk::Orientation::Horizontal, 6);
toolbar.add_css_class("toolbar");
let back = tool_button("", "Go to parent folder");
let back_icon = gtk::Image::from_icon_name("go-previous");
back_icon.set_pixel_size(16);
back_icon.set_halign(gtk::Align::Center);
back_icon.set_valign(gtk::Align::Center);
back.set_child(Some(&back_icon));
back.add_css_class("back-button");
let refresh = tool_button("↻", "Reload this folder");
let location_entry = gtk::Entry::builder()
.hexpand(true)
.placeholder_text("Enter a folder path")
.build();
location_entry.add_css_class("location");
let filter = gtk::SearchEntry::builder()
.placeholder_text("Filter")
.width_chars(18)
.build();
toolbar.append(&back);
toolbar.append(&refresh);
toolbar.append(&location_entry);
toolbar.append(&filter);
root.append(&toolbar);
let body = gtk::Box::new(gtk::Orientation::Horizontal, 0);
body.set_vexpand(true);
let sidebar = gtk::Box::new(gtk::Orientation::Vertical, 2);
sidebar.set_width_request(168);
sidebar.set_hexpand(false);
sidebar.add_css_class("sidebar");
let places = [
("Desktop", home.join("Desktop")),
("Documents", home.join("Documents")),
("Downloads", home.join("Downloads")),
("Pictures", home.join("Pictures")),
];
for (label, path) in places {
let button = gtk::Button::with_label(label);
button.set_halign(gtk::Align::Fill);
if let Some(label) = button
.child()
.and_then(|child| child.downcast::<gtk::Label>().ok())
{
label.set_xalign(0.0);
label.set_hexpand(true);
}
button.add_css_class("place");
// Place actions are wired after the browser exists below.
button.set_tooltip_text(Some(&path.display().to_string()));
button.set_widget_name(&format!("place:{}", path.display()));
sidebar.append(&button);
}
body.append(&sidebar);
let content = gtk::Box::new(gtk::Orientation::Vertical, 0);
content.set_hexpand(true);
content.set_vexpand(true);
let headings = gtk::Box::new(gtk::Orientation::Horizontal, 8);
headings.add_css_class("headings");
headings.append(&heading("Name", true));
headings.append(&heading("Size", false));
headings.append(&heading("Modified", false));
content.append(&headings);
let list = gtk::ListBox::new();
list.set_selection_mode(gtk::SelectionMode::Single);
list.set_activate_on_single_click(false);
list.add_css_class("file-list");
let scroll = gtk::ScrolledWindow::builder()
.child(&list)
.hexpand(true)
.vexpand(true)
.build();
content.append(&scroll);
let preview_heading = gtk::Label::new(Some("Preview"));
preview_heading.set_xalign(0.0);
preview_heading.add_css_class("preview-heading");
let preview_title = gtk::Label::new(Some("Select a file"));
preview_title.set_xalign(0.0);
preview_title.set_ellipsize(gtk::pango::EllipsizeMode::Middle);
preview_title.add_css_class("preview-title");
let preview_details = gtk::Label::new(Some("Select a file to preview"));
preview_details.set_xalign(0.0);
preview_details.set_wrap(true);
preview_details.add_css_class("preview-details");
let preview_stack = gtk::Stack::new();
preview_stack.set_hexpand(true);
preview_stack.set_vexpand(true);
preview_stack.set_visible_child_name("empty");
let preview_empty = gtk::Label::new(Some("Select a file to preview"));
preview_empty.set_wrap(true);
preview_empty.add_css_class("preview-message");
preview_stack.add_named(&preview_empty, Some("empty"));
let preview_text = gtk::TextView::new();
preview_text.set_editable(false);
preview_text.set_cursor_visible(false);
preview_text.set_monospace(true);
preview_text.set_wrap_mode(gtk::WrapMode::None);
preview_text.add_css_class("preview-text");
let text_scroll = gtk::ScrolledWindow::builder()
.child(&preview_text)
.hexpand(true)
.vexpand(true)
.build();
preview_stack.add_named(&text_scroll, Some("text"));
let preview_picture = gtk::Picture::new();
preview_picture.set_can_shrink(true);
preview_picture.set_keep_aspect_ratio(true);
preview_picture.set_halign(gtk::Align::Center);
preview_picture.set_valign(gtk::Align::Center);
preview_picture.set_hexpand(true);
preview_picture.set_vexpand(true);
preview_picture.add_css_class("preview-picture");
preview_stack.add_named(&preview_picture, Some("image"));
let preview_message = gtk::Label::new(None);
preview_message.set_wrap(true);
preview_message.set_justify(gtk::Justification::Center);
preview_message.add_css_class("preview-message");
preview_stack.add_named(&preview_message, Some("message"));
let preview = gtk::Box::new(gtk::Orientation::Vertical, 10);
preview.set_width_request(320);
preview.set_hexpand(false);
preview.set_vexpand(true);
preview.set_visible(false);
preview.add_css_class("preview-pane");
preview.append(&preview_heading);
preview.append(&preview_title);
preview.append(&preview_details);
preview.append(&preview_stack);
let workspace = gtk::Paned::new(gtk::Orientation::Horizontal);
workspace.set_start_child(Some(&content));
workspace.set_end_child(Some(&preview));
workspace.set_position(620);
workspace.set_hexpand(true);
workspace.set_vexpand(true);
workspace.set_resize_start_child(true);
workspace.set_resize_end_child(false);
workspace.set_shrink_start_child(false);
workspace.set_shrink_end_child(false);
workspace.add_css_class("workspace-split");
body.append(&workspace);
root.append(&body);
let status = gtk::Label::new(None);
status.set_xalign(0.0);
status.add_css_class("status");
status.set_margin_start(12);
status.set_margin_end(12);
status.set_margin_top(6);
status.set_margin_bottom(6);
root.append(&status);
window.set_child(Some(&root));
let browser = Browser {
location: Rc::new(RefCell::new(home)),
show_hidden: Rc::new(RefCell::new(false)),
list: list.clone(),
location_entry: location_entry.clone(),
status: status.clone(),
filter: filter.clone(),
preview_title,
preview_details,
preview_panel: preview,
preview_stack,
preview_text,
preview_picture,
preview_message,
preview_request: Rc::new(Cell::new(0)),
preview_job: Rc::new(RefCell::new(None)),
preview_file: Rc::new(RefCell::new(None)),
};
reload(&browser);
{
let b = browser.clone();
location_entry.connect_activate(move |entry| {
let path = PathBuf::from(entry.text().as_str());
navigate(&b, path);
});
}
{
let b = browser.clone();
back.connect_clicked(move |_| {
let parent = b.location.borrow().parent().map(Path::to_path_buf);
if let Some(path) = parent {
navigate(&b, path);
}
});
}
{
let b = browser.clone();
refresh.connect_clicked(move |_| reload(&b));
}
{
let b = browser.clone();
filter.connect_search_changed(move |_| apply_filter(&b));
}
{
let b = browser.clone();
list.connect_row_selected(move |_, row| {
if let Some(path) = row
.and_then(|row| row.tooltip_text())
.map(|path| PathBuf::from(path.as_str()))
{
show_preview(&b, path);
} else {
clear_preview(&b);
}
});
}
{
let b = browser.clone();
list.connect_row_activated(move |_, row| {
if let Some(path) = row.tooltip_text() {
navigate(&b, PathBuf::from(path.as_str()));
}
});
}
// Sidebar uses the path stored on each button; this avoids hard-coded home assumptions.
let mut child = sidebar.first_child();
while let Some(widget) = child {
child = widget.next_sibling();
if let Ok(button) = widget.clone().downcast::<gtk::Button>() {
let b = browser.clone();
let path = button
.widget_name()
.strip_prefix("place:")
.map(PathBuf::from);
if let Some(path) = path {
button.connect_clicked(move |_| navigate(&b, path.clone()));
}
}
}
{
let b = browser.clone();
let keys = gtk::EventControllerKey::new();
keys.connect_key_pressed(move |_, key, _, state| {
if state.contains(gtk::gdk::ModifierType::CONTROL_MASK) && key == gtk::gdk::Key::l {
b.location_entry.grab_focus();
b.location_entry.select_region(0, -1);
return glib::Propagation::Stop;
}
if key == gtk::gdk::Key::F5 {
reload(&b);
return glib::Propagation::Stop;
}
if state.contains(gtk::gdk::ModifierType::CONTROL_MASK) && key == gtk::gdk::Key::h {
let visible = !*b.show_hidden.borrow();
*b.show_hidden.borrow_mut() = visible;
reload(&b);
return glib::Propagation::Stop;
}
if key == gtk::gdk::Key::BackSpace {
if let Some(parent) = b.location.borrow().parent().map(Path::to_path_buf) {
navigate(&b, parent);
}
return glib::Propagation::Stop;
}
glib::Propagation::Proceed
});
window.add_controller(keys);
}
{
let b = browser.clone();
window.connect_close_request(move |_| {
clear_preview(&b);
glib::Propagation::Proceed
});
}
window.present();
}
fn navigate(browser: &Browser, path: PathBuf) {
if path.is_dir() {
*browser.location.borrow_mut() = path;
reload(browser);
} else if path.is_file() {
match open_file(&path) {
Ok(()) => browser.status.set_text(&format!(
"Opened {}",
path.file_name().unwrap_or_default().to_string_lossy()
)),
Err(error) => browser
.status
.set_text(&format!("Cannot open {}: {error}", path.display())),
}
} else {
browser
.status
.set_text("That path does not exist or cannot be opened.");
}
}
fn clear_preview(browser: &Browser) {
cancel_preview_work(browser);
browser.preview_panel.set_visible(false);
browser.preview_title.set_text("Select a file");
browser.preview_details.set_text("Select a file to preview");
browser.preview_stack.set_visible_child_name("empty");
}
fn cancel_preview_work(browser: &Browser) -> u64 {
let request = browser.preview_request.get().wrapping_add(1);
browser.preview_request.set(request);
if let Some(mut job) = browser.preview_job.borrow_mut().take() {
let _ = job.child.kill();
let _ = job.child.wait();
let _ = fs::remove_file(job.output);
}
browser.preview_picture.set_filename(None::<&Path>);
if let Some(path) = browser.preview_file.borrow_mut().take() {
let _ = fs::remove_file(path);
}
request
}
fn show_preview(browser: &Browser, path: PathBuf) {
if !supports_preview(&path) {
clear_preview(browser);
return;
}
let request = cancel_preview_work(browser);
browser.preview_panel.set_visible(true);
let name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string());
browser.preview_title.set_text(&name);
let details = fs::metadata(&path)
.map(|metadata| {
let modified = metadata
.modified()
.ok()
.map(format_time)
.unwrap_or_else(|| "Unknown date".into());
format!("{} · {modified}", format_size(metadata.len()))
})
.unwrap_or_else(|_| path.display().to_string());
browser.preview_details.set_text(&details);
let extension = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
if is_preview_text(&extension) {
show_text_preview(browser, &path);
} else if is_preview_image(&extension) {
start_image_preview(browser, path, request, details);
}
}
fn supports_preview(path: &Path) -> bool {
if path.is_dir() {
return false;
}
let extension = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
is_preview_text(&extension) || is_preview_image(&extension)
}
fn set_preview_message(browser: &Browser, message: &str) {
browser.preview_message.set_text(message);
browser.preview_stack.set_visible_child_name("message");
}
fn is_preview_text(extension: &str) -> bool {
matches!(
extension,
"txt"
| "md"
| "markdown"
| "rs"
| "toml"
| "json"
| "yaml"
| "yml"
| "ini"
| "conf"
| "log"
| "sh"
| "bash"
| "zsh"
| "fish"
| "c"
| "h"
| "cc"
| "cpp"
| "hpp"
| "py"
| "html"
| "css"
| "js"
| "ts"
| "xml"
| "csv"
| "lock"
| "desktop"
| "service"
| "lua"
| "nix"
)
}
fn is_preview_image(extension: &str) -> bool {
matches!(
extension,
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" | "svg" | "avif" | "ico"
)
}
fn show_text_preview(browser: &Browser, path: &Path) {
const PREVIEW_LIMIT: usize = 64 * 1024;
let mut bytes = Vec::with_capacity(PREVIEW_LIMIT + 1);
let result = fs::File::open(path).and_then(|file| {
file.take((PREVIEW_LIMIT + 1) as u64)
.read_to_end(&mut bytes)
});
if let Err(error) = result {
set_preview_message(browser, &format!("Cannot read this file: {error}"));
return;
}
if bytes.contains(&0) {
set_preview_message(
browser,
"This file contains binary data and cannot be previewed as text.",
);
return;
}
let truncated = bytes.len() > PREVIEW_LIMIT;
bytes.truncate(PREVIEW_LIMIT);
let mut text = String::from_utf8_lossy(&bytes).into_owned();
if truncated {
text.push_str("\n\n[Preview limited to the first 64 KiB]");
}
browser.preview_text.buffer().set_text(&text);
browser.preview_stack.set_visible_child_name("text");
}
fn start_image_preview(browser: &Browser, path: PathBuf, request: u64, details: String) {
let source = match fs::canonicalize(&path) {
Ok(source) => source,
Err(error) => {
set_preview_message(browser, &format!("Cannot open this image: {error}"));
return;
}
};
let output_dir = std::env::temp_dir().join(format!("wayfinder-preview-{}", std::process::id()));
if let Err(error) = fs::create_dir_all(&output_dir) {
set_preview_message(browser, &format!("Cannot create preview cache: {error}"));
return;
}
if let Err(error) = fs::set_permissions(&output_dir, fs::Permissions::from_mode(0o700)) {
set_preview_message(browser, &format!("Cannot secure preview cache: {error}"));
return;
}
let output = output_dir.join(format!("preview-{request}.png"));
let _ = fs::remove_file(&output);
let extension = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default();
let sandbox_input = format!("/wayfinder-input/preview.{extension}");
let mut command = Command::new("bwrap");
command
.args([
"--unshare-user",
"--unshare-pid",
"--unshare-ipc",
"--unshare-uts",
"--die-with-parent",
"--new-session",
"--ro-bind",
"/usr",
"/usr",
"--ro-bind",
"/etc",
"/etc",
"--ro-bind-try",
"/lib",
"/lib",
"--ro-bind-try",
"/lib64",
"/lib64",
"--dev",
"/dev",
"--proc",
"/proc",
"--tmpfs",
"/tmp",
"--dir",
"/wayfinder-input",
"--ro-bind",
])
.arg(&source)
.arg(&sandbox_input)
.arg("--bind")
.arg(&output_dir)
.args([
"/wayfinder-output",
"--chdir",
"/",
"--setenv",
"HOME",
"/tmp",
"--",
"/usr/bin/gdk-pixbuf-thumbnailer",
"--size",
"512",
])
.arg(&sandbox_input)
.arg(format!("/wayfinder-output/preview-{request}.png"));
command.stdout(std::process::Stdio::null());
command.stderr(std::process::Stdio::null());
match command.spawn() {
Ok(child) => {
browser
.preview_details
.set_text("Generating image preview…");
*browser.preview_job.borrow_mut() = Some(PreviewJob {
request,
child,
output: output.clone(),
started: Instant::now(),
});
let browser = browser.clone();
glib::timeout_add_local(Duration::from_millis(80), move || {
if browser.preview_request.get() != request {
return glib::ControlFlow::Break;
}
let completion = {
let mut pending = browser.preview_job.borrow_mut();
let Some(job) = pending.as_mut() else {
return glib::ControlFlow::Break;
};
if job.request != request {
return glib::ControlFlow::Break;
}
match job.child.try_wait() {
Ok(None) if job.started.elapsed() < Duration::from_secs(8) => None,
Ok(None) => {
let _ = job.child.kill();
let _ = job.child.wait();
Some(false)
}
Ok(Some(status)) => Some(status.success()),
Err(_) => Some(false),
}
.map(|success| {
let job = pending.take().expect("preview job exists");
(success, job.output)
})
};
let Some((success, output)) = completion else {
return glib::ControlFlow::Continue;
};
if success && output.is_file() {
browser.preview_picture.set_filename(Some(&output));
browser.preview_details.set_text(&details);
*browser.preview_file.borrow_mut() = Some(output);
browser.preview_stack.set_visible_child_name("image");
} else {
let _ = fs::remove_file(output);
set_preview_message(
&browser,
"Image preview failed. The format may be unsupported, or the sandboxed thumbnailer may be unavailable.",
);
}
glib::ControlFlow::Break
});
}
Err(error) => set_preview_message(
browser,
&format!("Could not start the sandboxed image preview: {error}"),
),
}
}
fn open_file(path: &Path) -> Result<(), String> {
let extension = path
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
let text_extensions = [
"txt", "md", "markdown", "rs", "toml", "json", "yaml", "yml", "ini", "conf", "log", "sh",
"bash", "zsh", "fish", "c", "h", "cc", "cpp", "hpp", "py", "html", "css", "js", "ts",
"xml", "csv", "lock", "desktop", "service", "lua", "nix",
];
let image_extensions = [
"png", "jpg", "jpeg", "gif", "webp", "bmp", "tif", "tiff", "svg", "avif",
];
let mut command = if text_extensions.contains(&extension.as_str()) {
let mut command = Command::new("foot");
command.args(["-e", "nvim", "--"]).arg(path);
command
} else if image_extensions.contains(&extension.as_str()) {
let mut command = Command::new("imv");
command.arg(path);
command
} else if fs::metadata(path)
.map(|metadata| metadata.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
{
let mut command = Command::new("foot");
command.arg("-e").arg(path);
command
} else {
let uri = gio::File::for_path(path).uri();
return gio::AppInfo::launch_default_for_uri(&uri, None::<&gio::AppLaunchContext>)
.map_err(|error| error.to_string());
};
command
.spawn()
.map(|_| ())
.map_err(|error| format!("Could not start the requested application: {error}"))
}
fn reload(browser: &Browser) {
clear_preview(browser);
while let Some(row) = browser.list.first_child() {
browser.list.remove(&row);
}
let path = browser.location.borrow().clone();
browser.location_entry.set_text(&path.display().to_string());
let entries = match fs::read_dir(&path) {
Ok(items) => items,
Err(error) => {
browser
.status
.set_text(&format!("Cannot read {}: {error}", path.display()));
return;
}
};
let mut rows = Vec::new();
for item in entries.flatten() {
let path = item.path();
let name = item.file_name().to_string_lossy().into_owned();
if !*browser.show_hidden.borrow() && name.starts_with('.') {
continue;
}
let Ok(meta) = item.metadata() else { continue };
let directory = meta.is_dir();
let modified = meta
.modified()
.ok()
.map(format_time)
.unwrap_or_else(|| "—".into());
rows.push(Entry {
path,
name,
directory,
size: meta.len(),
modified,
});
}
rows.sort_by(|a, b| {
b.directory
.cmp(&a.directory)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
for entry in rows {
let row = make_row(&entry);
row.set_selectable(true);
row.set_activatable(true);
row.set_tooltip_text(Some(&entry.path.to_string_lossy()));
browser.list.append(&row);
}
let count = browser.list.observe_children().n_items();
browser
.status
.set_text(&format!("{count} items · {}", path.display()));
apply_filter(browser);
}
fn make_row(entry: &Entry) -> gtk::ListBoxRow {
let row = gtk::ListBoxRow::new();
let line = gtk::Box::new(gtk::Orientation::Horizontal, 8);
line.set_margin_start(8);
line.set_margin_end(8);
line.set_margin_top(5);
line.set_margin_bottom(5);
let name = gtk::Label::new(None);
let display_name: String = entry
.name
.chars()
.map(|character| {
if character.is_control() {
'�'
} else {
character
}
})
.collect();
name.set_text(&if entry.directory {
format!("{display_name}/")
} else {
display_name
});
name.set_xalign(0.0);
name.set_wrap(true);
name.set_wrap_mode(gtk::pango::WrapMode::WordChar);
name.set_valign(gtk::Align::Center);
name.set_hexpand(true);
name.add_css_class(if entry.directory {
"directory"
} else {
"filename"
});
let size_text = if entry.directory {
"Folder".to_string()
} else {
format_size(entry.size)
};
let size = metadata_value(&size_text, 72);
let modified = metadata_value(&entry.modified, 126);
line.append(&name);
line.append(&size);
line.append(&modified);
row.set_child(Some(&line));
row
}
fn heading(text: &str, expand: bool) -> gtk::Label {
let label = gtk::Label::new(Some(text));
label.set_xalign(0.0);
if expand {
label.set_hexpand(true);
} else {
label.set_size_request(if text == "Size" { 72 } else { 126 }, -1);
}
label.add_css_class("column-heading");
label
}
fn metadata_value(text: &str, width: i32) -> gtk::Label {
let value = gtk::Label::new(Some(text));
value.set_size_request(width, 24);
value.set_xalign(0.0);
value.set_valign(gtk::Align::Center);
value.set_ellipsize(gtk::pango::EllipsizeMode::End);
value.add_css_class("metadata");
value
}
fn tool_button(label: &str, tip: &str) -> gtk::Button {
let button = gtk::Button::with_label(label);
button.set_tooltip_text(Some(tip));
button.set_size_request(34, 34);
button.add_css_class("tool-button");
button
}
fn apply_filter(browser: &Browser) {
let needle = browser.filter.text().to_lowercase();
let mut child = browser.list.first_child();
while let Some(widget) = child {
child = widget.next_sibling();
if let Ok(row) = widget.clone().downcast::<gtk::ListBoxRow>() {
let name = row
.child()
.and_then(|line| line.first_child())
.and_then(|label| label.downcast::<gtk::Label>().ok())
.map(|label| label.text().to_string().to_lowercase())
.unwrap_or_default();
row.set_visible(name.contains(&needle));
}
}
}
fn format_size(bytes: u64) -> String {
const UNITS: [&str; 4] = ["B", "KiB", "MiB", "GiB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} B")
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
fn format_time(time: std::time::SystemTime) -> String {
// Avoid adding a date/time dependency; present a stable age hint when local formatting is unavailable.
match time.elapsed() {
Ok(age) if age.as_secs() < 60 => "Just now".into(),
Ok(age) if age.as_secs() < 3600 => format!("{} min ago", age.as_secs() / 60),
Ok(age) if age.as_secs() < 86400 => format!("{} hr ago", age.as_secs() / 3600),
Ok(age) => format!("{} days ago", age.as_secs() / 86400),
Err(_) => "Just now".into(),
}
}
fn install_style() {
let provider = gtk::CssProvider::new();
provider.load_from_data(r#"
* { font-family: "Maple Mono", "JetBrainsMono Nerd Font", monospace; font-size: 10pt; }
window, .root { background: #151515; color: #D0D0D0; }
.toolbar { background: #181818; padding: 8px 10px; }
entry, searchentry { color: #D0D0D0; background: #202020; border: 1px solid #2F2F2F; border-radius: 2px; padding: 7px 9px; min-height: 18px; }
entry:focus, searchentry:focus { border-color: #6C99BB; }
.tool-button { color: #D0D0D0; background: #202020; border: 1px solid #2F2F2F; border-radius: 2px; padding: 0; min-width: 34px; min-height: 34px; }
.back-button { min-width: 34px; min-height: 34px; }
.tool-button:hover { color: #F5F5F5; background: #303030; border-color: #454545; }
.tool-button:focus-visible, .place:focus-visible, row:focus-visible { outline: 1px solid #6C99BB; outline-offset: -2px; }
.sidebar { background: #181818; border-top: 2px solid #2F2F2F; border-right: 1px solid #2F2F2F; padding: 8px 6px; min-width: 168px; max-width: 168px; }
.place { color: #D0D0D0; background: transparent; border: 1px solid transparent; border-radius: 2px; padding: 7px 9px; min-height: 20px; }
.place:hover { color: #F5F5F5; background: #202020; border-color: #2F2F2F; }
.place:active { background: #303030; }
.headings { background: #181818; border-top: 2px solid #2F2F2F; border-bottom: 1px solid #2F2F2F; padding: 9px 8px; }
.preview-pane { background: #181818; border-left: 1px solid #2F2F2F; padding: 12px; min-width: 320px; max-width: 320px; }
.preview-heading { color: #F5F5F5; font-size: 11pt; }
.preview-title { color: #D0D0D0; font-size: 10pt; }
.preview-details { color: #757575; font-size: 9pt; }
.preview-message { color: #858585; padding: 12px; }
.preview-text { background: #151515; color: #D0D0D0; padding: 10px; }
.preview-picture { background: #151515; }
.workspace-split > separator { background: #2F2F2F; min-width: 1px; }
.column-heading { color: #757575; font-size: 9pt; }
.file-list { background: #151515; }
.file-list row { border-bottom: 1px solid #202020; min-height: 36px; }
.file-list row:hover { background: #202020; }
.file-list row:selected { background: #303030; outline: 1px solid #6C99BB; outline-offset: -1px; }
.directory { color: #8DB3D0; }
.filename { color: #D0D0D0; }
.metadata, .status { color: #858585; font-size: 10pt; }
.metadata { padding: 3px 0 5px; }
scrolledwindow { border: 0; }
scrollbar slider { background: #454545; min-width: 7px; min-height: 24px; border-radius: 2px; }
"#);
if let Some(display) = gtk::gdk::Display::default() {
gtk::style_context_add_provider_for_display(
&display,
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}
}