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>, show_hidden: Rc>, 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>, preview_job: Rc>>, preview_file: Rc>>, } 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: >k::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::().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::() { 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::() { let name = row .child() .and_then(|line| line.first_child()) .and_then(|label| label.downcast::().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, ); } }