#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
#[cfg(feature = "gui")]
fn main() {
use eframe::egui;
let args: Vec<String> = std::env::args().collect();
let mut initial_path: Option<String> = None;
let mut forced_lang: Option<String> = None;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"-lang" => {
if i + 1 < args.len() {
i += 1;
forced_lang = Some(args[i].clone());
}
}
arg if !arg.starts_with('-') => {
initial_path = Some(arg.to_string());
}
_ => {}
}
i += 1;
}
let icon_data = include_bytes!("../assets/icon.png");
let icon = image::load_from_memory(icon_data)
.map(|img| {
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
egui::IconData {
rgba: rgba.into_raw(),
width: w,
height: h,
}
})
.ok();
let mut viewport = egui::ViewportBuilder::default()
.with_inner_size([600.0, 700.0])
.with_min_inner_size([400.0, 400.0])
.with_drag_and_drop(true);
if let Some(icon) = icon {
viewport = viewport.with_icon(std::sync::Arc::new(icon));
}
let options = eframe::NativeOptions {
viewport,
..Default::default()
};
let _ = eframe::run_native(
"exiftool-rs",
options,
Box::new(move |_cc| Ok(Box::new(App::new(initial_path, forced_lang)))),
);
}
#[cfg(not(feature = "gui"))]
fn main() {
eprintln!("GUI not available. Build with: cargo build --release --features gui --bin exiftool-rs-gui");
std::process::exit(1);
}
#[cfg(feature = "gui")]
use eframe::egui;
#[cfg(feature = "gui")]
use exiftool_rs::{ExifTool, Tag};
#[cfg(feature = "gui")]
struct App {
et: ExifTool,
files: Vec<std::path::PathBuf>,
current: usize,
tags: Vec<Tag>,
groups: Vec<(String, Vec<Tag>)>,
collapsed: std::collections::HashSet<String>,
icon_texture: Option<egui::TextureHandle>,
show_about: bool,
pending_edits: Vec<(String, String)>,
editing: Option<EditState>,
lang: String,
languages: Vec<(&'static str, &'static str)>,
translations: Option<std::collections::HashMap<&'static str, &'static str>>,
status: String,
thumbnail: Option<egui::TextureHandle>,
fonts_configured: bool,
writable_tags: Option<Option<std::collections::HashSet<&'static str>>>,
needs_resize: bool,
flash_until: Option<std::time::Instant>,
}
#[cfg(feature = "gui")]
struct EditState {
tag_name: String,
original_value: String,
new_value: String,
}
#[cfg(feature = "gui")]
impl App {
fn new(initial_path: Option<String>, forced_lang: Option<String>) -> Self {
let mut app = Self {
et: ExifTool::new(),
files: Vec::new(),
current: 0,
tags: Vec::new(),
groups: Vec::new(),
collapsed: std::collections::HashSet::new(),
icon_texture: None,
show_about: false,
pending_edits: Vec::new(),
editing: None,
lang: forced_lang.unwrap_or_else(|| exiftool_rs::i18n::detect_system_language()),
languages: exiftool_rs::i18n::AVAILABLE_LANGUAGES.to_vec(),
translations: None,
status: String::new(),
thumbnail: None,
fonts_configured: false,
writable_tags: None,
needs_resize: false,
flash_until: None,
};
app.status = exiftool_rs::i18n::ui_text(&app.lang, "drop_start").to_string();
if app.lang != "en" {
app.translations = exiftool_rs::i18n::get_translations(&app.lang);
}
if let Some(path) = initial_path {
let p = std::path::Path::new(&path);
if p.is_dir() {
app.open_folder(p);
} else if p.is_file() {
app.open_file(p);
}
}
app
}
fn open_file(&mut self, path: &std::path::Path) {
self.files = vec![path.to_path_buf()];
self.current = 0;
self.load_current();
}
fn open_folder(&mut self, path: &std::path::Path) {
let mut files: Vec<std::path::PathBuf> = std::fs::read_dir(path)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_file())
.filter(|p| {
if let Some(ext) = p.extension().and_then(|e| e.to_str()) {
let ext_lower = ext.to_lowercase();
matches!(ext_lower.as_str(),
"jpg" | "jpeg" | "tif" | "tiff" | "png" | "gif" | "bmp" | "webp" |
"cr2" | "cr3" | "crw" | "nef" | "dng" | "arw" | "orf" | "raf" |
"rw2" | "pef" | "x3f" | "iiq" | "mrw" | "sr2" | "srf" |
"mp4" | "mov" | "avi" | "mkv" | "mts" | "m2ts" |
"mp3" | "flac" | "wav" | "ogg" | "aac" |
"pdf" | "psd" | "heif" | "heic" | "avif" |
"xmp" | "mie" | "exv"
)
} else {
false
}
})
.collect();
files.sort();
self.files = files;
self.current = 0;
if !self.files.is_empty() {
self.load_current();
} else {
self.status = exiftool_rs::i18n::ui_text(&self.lang, "no_files").to_string();
}
}
fn load_current(&mut self) {
if self.current >= self.files.len() { return; }
let path = self.files[self.current].clone();
self.pending_edits.clear();
self.editing = None;
self.thumbnail = None;
let ext = path.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
self.writable_tags = if let Some(ft) = exiftool_rs::file_type::detect_from_extension(ext) {
Some(ExifTool::writable_tags(ft))
} else {
Some(Some(std::collections::HashSet::new()))
};
match self.et.extract_info(path.to_str().unwrap_or("")) {
Ok(tags) => {
self.tags = tags;
self.build_groups();
self.needs_resize = true;
let name = path.file_name().unwrap_or_default().to_string_lossy();
let count = self.tags.len();
self.status = format!("{} tags | {}", count, name);
}
Err(e) => {
self.tags.clear();
self.groups.clear();
self.status = format!("{}: {}", exiftool_rs::i18n::ui_text(&self.lang, "error"), e);
}
}
}
fn build_groups(&mut self) {
let mut groups: Vec<(String, Vec<Tag>)> = Vec::new();
let mut current_group = String::new();
for tag in &self.tags {
let grp = &tag.group.family0;
if grp != ¤t_group {
current_group = grp.clone();
groups.push((grp.clone(), Vec::new()));
}
if let Some(last) = groups.last_mut() {
last.1.push(tag.clone());
}
}
self.groups = groups;
}
fn navigate(&mut self, delta: isize) {
if self.files.is_empty() { return; }
let new = self.current as isize + delta;
if new >= 0 && (new as usize) < self.files.len() {
self.current = new as usize;
self.load_current();
}
}
fn set_language(&mut self, lang: &str) {
self.lang = lang.to_string();
self.translations = if lang == "en" {
None
} else {
exiftool_rs::i18n::get_translations(lang)
};
}
fn translate<'a>(&'a self, tag_name: &str, description: &'a str) -> &'a str {
if let Some(ref tr) = self.translations {
if let Some(translated) = tr.get(tag_name) {
return translated;
}
}
description
}
fn is_tag_writable(&self, tag_name: &str) -> bool {
match &self.writable_tags {
None => false, Some(None) => true, Some(Some(set)) => set.contains(tag_name.to_lowercase().as_str()),
}
}
}
#[cfg(feature = "gui")]
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if !self.fonts_configured {
self.fonts_configured = true;
let mut fonts = egui::FontDefinitions::default();
for (name, data) in noto_fonts_dl::load_fonts() {
fonts.font_data.insert(name.clone(),
std::sync::Arc::new(egui::FontData::from_owned(data.clone())));
fonts.families
.entry(egui::FontFamily::Proportional)
.or_default()
.push(name.clone());
fonts.families
.entry(egui::FontFamily::Monospace)
.or_default()
.push(name.clone());
}
ctx.set_fonts(fonts);
}
ctx.input(|i| {
if !i.raw.dropped_files.is_empty() {
if let Some(path) = i.raw.dropped_files[0].path.as_ref() {
if path.is_dir() {
self.open_folder(path);
} else {
if let Some(parent) = path.parent() {
self.open_folder(parent);
if let Some(idx) = self.files.iter().position(|f| f == path) {
self.current = idx;
self.load_current();
}
} else {
self.open_file(path);
}
}
}
}
});
ctx.input(|i| {
if i.key_pressed(egui::Key::ArrowLeft) { self.navigate(-1); }
if i.key_pressed(egui::Key::ArrowRight) { self.navigate(1); }
if i.key_pressed(egui::Key::Home) {
self.current = 0;
self.load_current();
}
if i.key_pressed(egui::Key::End) {
if !self.files.is_empty() {
self.current = self.files.len() - 1;
self.load_current();
}
}
});
egui::TopBottomPanel::top("toolbar").show(ctx, |ui| {
ui.horizontal(|ui| {
if ui.button(exiftool_rs::i18n::ui_text(&self.lang, "open"))
.on_hover_text(exiftool_rs::i18n::ui_text(&self.lang, "tooltip_open"))
.clicked() {
if let Some(path) = rfd::FileDialog::new().pick_file() {
if let Some(parent) = path.parent() {
self.open_folder(parent);
if let Some(idx) = self.files.iter().position(|f| f == &path) {
self.current = idx;
self.load_current();
}
}
}
}
if ui.button(exiftool_rs::i18n::ui_text(&self.lang, "folder"))
.on_hover_text(exiftool_rs::i18n::ui_text(&self.lang, "tooltip_folder"))
.clicked() {
if let Some(path) = rfd::FileDialog::new().pick_folder() {
self.open_folder(&path);
}
}
let is_flashing = self.flash_until.map_or(false, |t| t > std::time::Instant::now());
if is_flashing {
ctx.request_repaint();
}
if ui.button(exiftool_rs::i18n::ui_text(&self.lang, "copy"))
.on_hover_text(exiftool_rs::i18n::ui_text(&self.lang, "tooltip_copy"))
.clicked() {
let text: String = self.tags.iter()
.map(|t| format!("{}: {}", t.name, t.print_value))
.collect::<Vec<_>>()
.join("\n");
ctx.copy_text(text);
self.status = exiftool_rs::i18n::ui_text(&self.lang, "copied").to_string();
self.flash_until = Some(std::time::Instant::now() + std::time::Duration::from_millis(800));
}
if ui.button(exiftool_rs::i18n::ui_text(&self.lang, "save"))
.on_hover_text(exiftool_rs::i18n::ui_text(&self.lang, "tooltip_save"))
.clicked() {
if !self.pending_edits.is_empty() {
if let Some(path) = self.files.get(self.current) {
let path_str = path.to_string_lossy().to_string();
let mut et = ExifTool::new();
for (tag, value) in &self.pending_edits {
et.set_new_value(tag, Some(value));
}
match et.write_info(&path_str, &path_str) {
Ok(_) => {
self.status = format!("{} {}", self.pending_edits.len(), exiftool_rs::i18n::ui_text(&self.lang, "saved"));
self.pending_edits.clear();
self.load_current();
self.flash_until = Some(std::time::Instant::now() + std::time::Duration::from_millis(800));
}
Err(e) => {
self.status = format!("{}: {}", exiftool_rs::i18n::ui_text(&self.lang, "save_error"), e);
}
}
}
}
}
ui.separator();
let langs: Vec<(&str, &str)> = self.languages.clone();
let current_lang = self.lang.clone();
let current_lang_name = langs.iter()
.find(|(c, _)| *c == current_lang)
.map(|(_, n)| *n)
.unwrap_or("English");
let mut new_lang: Option<String> = None;
egui::ComboBox::from_label("")
.selected_text(format!("🌐 {}", current_lang_name))
.show_ui(ui, |ui| {
for (code, name) in &langs {
if ui.selectable_label(current_lang == *code, *name).clicked() {
new_lang = Some(code.to_string());
}
}
});
if let Some(l) = new_lang {
self.set_language(&l);
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.small_button(exiftool_rs::i18n::ui_text(&self.lang, "about")).clicked() {
self.show_about = true;
}
});
});
});
if !self.files.is_empty() {
egui::TopBottomPanel::top("navigation").show(ctx, |ui| {
ui.horizontal(|ui| {
ui.add_space(ui.available_width() / 2.0 - 150.0);
let can_prev = self.current > 0;
let can_next = self.current + 1 < self.files.len();
if ui.add_enabled(can_prev, egui::Button::new("◀")).clicked() {
self.navigate(-1);
}
let filename = self.files.get(self.current)
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
ui.label(egui::RichText::new(
format!(" {} ({}/{}) ", filename, self.current + 1, self.files.len())
).strong());
if ui.add_enabled(can_next, egui::Button::new("▶")).clicked() {
self.navigate(1);
}
});
});
egui::TopBottomPanel::top("editable_hint").show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label(egui::RichText::new(
exiftool_rs::i18n::ui_text(&self.lang, "editable_hint")
).color(egui::Color32::from_rgb(100, 200, 100)));
});
});
}
let is_flashing = self.flash_until.map_or(false, |t| t > std::time::Instant::now());
egui::TopBottomPanel::bottom("status").show(ctx, |ui| {
ui.horizontal(|ui| {
if is_flashing {
ui.label(egui::RichText::new(&self.status).color(egui::Color32::GREEN).strong());
} else {
ui.label(&self.status);
}
if !self.pending_edits.is_empty() {
ui.separator();
ui.label(egui::RichText::new(
format!("{} {}", self.pending_edits.len(), exiftool_rs::i18n::ui_text(&self.lang, "pending"))
).color(egui::Color32::YELLOW));
}
});
});
if self.icon_texture.is_none() {
let icon_data = include_bytes!("../assets/icon.png");
if let Ok(img) = image::load_from_memory(icon_data) {
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
let color_image = egui::ColorImage::from_rgba_unmultiplied(
[w as usize, h as usize],
&rgba.into_raw(),
);
self.icon_texture = Some(ctx.load_texture("icon", color_image, egui::TextureOptions::LINEAR));
}
}
egui::CentralPanel::default().show(ctx, |ui| {
if let Some(ref tex) = self.icon_texture {
let panel_rect = ui.available_rect_before_wrap();
let logo_size = panel_rect.height().min(panel_rect.width()) * 0.5;
let center = panel_rect.center();
let logo_rect = egui::Rect::from_center_size(center, egui::vec2(logo_size, logo_size));
ui.painter().image(
tex.id(),
logo_rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::from_white_alpha(128),
);
}
if self.tags.is_empty() && self.files.is_empty() {
return;
}
ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
let groups = self.groups.clone();
for (group_name, group_tags) in &groups {
let is_collapsed = self.collapsed.contains(group_name);
let header = if is_collapsed {
format!("▶ {} ({})", group_name, group_tags.len())
} else {
format!("▼ {} ({})", group_name, group_tags.len())
};
if ui.selectable_label(false, egui::RichText::new(&header).strong().size(14.0)).clicked() {
if is_collapsed {
self.collapsed.remove(group_name);
} else {
self.collapsed.insert(group_name.clone());
}
}
if !is_collapsed {
egui::Grid::new(format!("grid_{}", group_name))
.num_columns(2)
.spacing([20.0, 4.0])
.striped(true)
.show(ui, |ui| {
for tag in group_tags {
let desc = self.translate(&tag.name, &tag.description);
let display_value = self.pending_edits.iter()
.find(|(name, _)| name == &tag.name)
.map(|(_, v)| v.as_str())
.unwrap_or(&tag.print_value);
let is_edited = self.pending_edits.iter().any(|(n, _)| n == &tag.name);
ui.label(egui::RichText::new(format!("{} :", desc)).color(egui::Color32::LIGHT_BLUE).strong());
let writable = self.is_tag_writable(&tag.name);
let value_text = if is_edited {
egui::RichText::new(display_value).color(egui::Color32::YELLOW).strong()
} else if writable {
egui::RichText::new(display_value).color(egui::Color32::from_rgb(100, 200, 100)).strong()
} else {
egui::RichText::new(display_value).color(egui::Color32::from_rgb(200, 100, 100)).strong()
};
let response = ui.label(value_text);
if response.double_clicked() && self.is_tag_writable(&tag.name) {
self.editing = Some(EditState {
tag_name: tag.name.clone(),
original_value: tag.print_value.clone(),
new_value: tag.print_value.clone(),
});
}
if response.hovered() && !self.is_tag_writable(&tag.name) {
response.on_hover_text(exiftool_rs::i18n::ui_text(&self.lang, "read_only"));
}
ui.end_row();
}
});
ui.add_space(8.0);
}
}
});
});
});
let mut close_edit = false;
if let Some(ref mut edit) = self.editing {
let mut open = true;
egui::Window::new(format!("{}: {}", exiftool_rs::i18n::ui_text(&self.lang, "edit"), edit.tag_name))
.open(&mut open)
.collapsible(false)
.resizable(false)
.show(ctx, |ui| {
ui.label(format!("{}: {}", exiftool_rs::i18n::ui_text(&self.lang, "original"), edit.original_value));
ui.add_space(4.0);
let response = ui.text_edit_singleline(&mut edit.new_value);
ui.add_space(8.0);
ui.horizontal(|ui| {
if ui.button(exiftool_rs::i18n::ui_text(&self.lang, "ok")).clicked() || (response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) {
if edit.new_value != edit.original_value {
self.pending_edits.retain(|(n, _)| n != &edit.tag_name);
self.pending_edits.push((edit.tag_name.clone(), edit.new_value.clone()));
}
close_edit = true;
}
if ui.button(exiftool_rs::i18n::ui_text(&self.lang, "cancel")).clicked() {
close_edit = true;
}
});
});
if !open { close_edit = true; }
}
if close_edit {
self.editing = None;
}
if self.show_about {
let mut open = true;
egui::Window::new("About exiftool-rs")
.open(&mut open)
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.vertical_centered(|ui| {
if let Some(ref tex) = self.icon_texture {
ui.image((tex.id(), egui::vec2(64.0, 64.0)));
}
ui.add_space(8.0);
ui.label(egui::RichText::new("exiftool-rs").size(20.0).strong());
ui.label(format!("v{}", exiftool_rs::VERSION));
ui.add_space(8.0);
ui.label("A pure Rust reimplementation of ExifTool");
ui.label("194/194 test files — 100% ISO-functional parity");
ui.add_space(8.0);
ui.label("Based on ExifTool by Phil Harvey");
ui.hyperlink_to("exiftool.org", "https://exiftool.org/");
ui.add_space(4.0);
ui.hyperlink_to("GitHub", "https://github.com/Le-Syl21/exiftool-rs");
ui.add_space(8.0);
ui.label(egui::RichText::new("Sylvain (project creator) + Claude (implementation)")
.small().color(egui::Color32::GRAY));
});
});
if !open { self.show_about = false; }
}
}
}