From fdcf9be24e0ad095e30022e4f80166e4319cc36b Mon Sep 17 00:00:00 2001 From: candle Date: Wed, 9 Jul 2025 18:45:45 -0400 Subject: [PATCH] initial commit --- .gitignore | 3 + Cargo.toml | 12 + LICENSE-MIT | 25 ++ README.md | 34 ++ mrmime.desktop | 8 + src/app.rs | 454 +++++++++++++++++++++++ src/main.rs | 23 ++ src/mime_parsing.rs | 801 ++++++++++++++++++++++++++++++++++++++++ src/theme.rs | 198 ++++++++++ src/types.rs | 45 +++ src/ui.rs | 4 + src/ui/edit_dialog.rs | 132 +++++++ src/ui/help_dialog.rs | 54 +++ src/ui/main_window.rs | 278 ++++++++++++++ src/ui/verify_dialog.rs | 253 +++++++++++++ 15 files changed, 2324 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 LICENSE-MIT create mode 100644 README.md create mode 100644 mrmime.desktop create mode 100644 src/app.rs create mode 100644 src/main.rs create mode 100644 src/mime_parsing.rs create mode 100644 src/theme.rs create mode 100644 src/types.rs create mode 100644 src/ui.rs create mode 100644 src/ui/edit_dialog.rs create mode 100644 src/ui/help_dialog.rs create mode 100644 src/ui/main_window.rs create mode 100644 src/ui/verify_dialog.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d634ba9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +Cargo.lock +/target +.c* diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ec78bac --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "mrmime" +version = "0.0.9" +edition = "2024" + +[dependencies] +eframe = "0.31" +egui = "0.31" +mime_guess = "2.0" +walkdir = "2.4" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..f29028a --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,25 @@ +Copyright (c) Filip Bicki + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4cbfa5e --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# Mr. MIME - Linux MIME Type Manager + +MIME (Multi-purpose Internet Mail Extensions) Types can get quite cluttered; if you have ever uninstalled an application, traces of it can be left over within these MIME files. Mr. MIME searches your system for all available types, and organizes them into a manageable way. You can manually edit each entry, adding and removing application associations as you please within a GUI. + +## Features + +* Support for alternative opener applications. In addition to `xdg-open`, `handlr`, `mimeo`, etc... will all get queried for their supporting MIME associations and integrated. +* Automatically verify which MIME associations have a non-existent application assigned to them and remove/replace with another. +* Respects system theme choice, utilizing `pywal` colors if available. + +## Build and Install +##### Requirements +`git`, `rust`/`rustup`/`cargo` +##### Arch Linux +`sudo pacman -S git rust` +##### Ubuntu/Debian +`sudo apt install git rust` + +#### Install +```bash +git clone https://code.lampnet.io/candle/mrmine +cd mrmime && cargo build --release +sudo mv target/release/mrmine /usr/local/bin/ +sudo install -Dm644 mrmime.desktop /usr/share/applications/mrmime.desktop +``` + +`mrmime` will now be available to your system and application launcher. You may delete the cloned directory. + +## Future Plans + +| Feature | Info | +| ------- | ---- | +| **Grouped Replacements** | Allows you to replace all instances of a missing application in a single action. | +| **Keyboard Navigation** | Moving around, initiating edits, etc... | diff --git a/mrmime.desktop b/mrmime.desktop new file mode 100644 index 0000000..4a49a55 --- /dev/null +++ b/mrmime.desktop @@ -0,0 +1,8 @@ +[Desktop Entry] +Name=Mr. MIME +Name[en_US]=Mr. MIME +Exec=/usr/bin/mrmime +Icon=editor +Terminal=false +Type=Application +Categories=Application;Graphical; diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..a8696e5 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,454 @@ +use crate::mime_parsing; +use crate::theme::{self, Theme}; +use crate::types::{DisplayEntry, MimeTypeEntry, VerificationEntry}; +use eframe::egui; +use std::sync::mpsc; +use std::thread; + +pub struct MimeExplorerApp { + pub(crate) mime_types: Vec, + pub(crate) display_entries: Vec, + pub(crate) filtered_entries: Vec, + pub(crate) search_filter: String, + pub(crate) last_search_filter: String, + pub(crate) loading: bool, + pub(crate) error_message: Option, + pub(crate) mime_receiver: Option, String>>>, + pub(crate) editing_mime_type: Option, + pub(crate) editing_applications: Vec, + pub(crate) new_application: String, + pub(crate) show_edit_dialog: bool, + pub(crate) selected_mime_type: Option, + pub(crate) available_applications: Vec, + pub(crate) selected_app_index: usize, + pub(crate) current_theme: Theme, + pub(crate) last_applied_theme: Option, + pub(crate) row_height: f32, + pub(crate) visible_start: usize, + pub(crate) visible_count: usize, + pub(crate) show_help_dialog: bool, + pub(crate) show_verify_dialog: bool, + pub(crate) verification_entries: Vec, + pub(crate) show_entries_with_no_applications: bool, +} + +impl Default for MimeExplorerApp { + fn default() -> Self { + Self { + mime_types: Vec::new(), + display_entries: Vec::new(), + filtered_entries: Vec::new(), + search_filter: String::new(), + last_search_filter: String::new(), + loading: false, + error_message: None, + mime_receiver: None, + editing_mime_type: None, + editing_applications: Vec::new(), + new_application: String::new(), + show_edit_dialog: false, + selected_mime_type: None, + available_applications: Vec::new(), + selected_app_index: 0, + current_theme: Theme::System, + last_applied_theme: None, + row_height: 0.0, + visible_start: 0, + visible_count: 0, + show_help_dialog: false, + show_verify_dialog: false, + verification_entries: Vec::new(), + show_entries_with_no_applications: true, + } + } +} + +impl MimeExplorerApp { + pub(crate) fn load_mime_types(&mut self) { + self.loading = true; + self.error_message = None; + + let (tx, rx) = mpsc::channel(); + self.mime_receiver = Some(rx); + + thread::spawn(move || { + let result = mime_parsing::discover_mime_types() + .map_err(|e| format!("Error loading MIME types: {e}")); + let _ = tx.send(result); + }); + + if self.available_applications.is_empty() { + self.load_applications(); + } + + self.last_search_filter = self.search_filter.clone(); + } + + fn load_applications(&mut self) { + match mime_parsing::discover_all_applications() { + Ok(apps) => { + self.available_applications = apps; + } + Err(e) => { + eprintln!("Warning: Error loading applications: {e}"); + self.available_applications = vec!["Failed to load applications".to_string()]; + } + } + } + + fn update_display_cache(&mut self) { + self.display_entries = self.mime_types.iter().map(DisplayEntry::from).collect(); + self.update_filtered_cache(); + } + + pub(crate) fn update_filtered_cache(&mut self) { + let search_term = if self.search_filter.is_empty() { + None + } else { + Some(self.search_filter.to_lowercase()) + }; + + self.filtered_entries = self + .display_entries + .iter() + .zip(self.mime_types.iter()) + .filter(|(display_entry, mime_entry)| { + if !self.show_entries_with_no_applications && mime_entry.applications.is_empty() { + return false; + } + + if let Some(ref search_term) = search_term { + display_entry.mime_type.to_lowercase().contains(search_term) + || display_entry + .extensions_text + .to_lowercase() + .contains(search_term) + || display_entry + .applications_text + .to_lowercase() + .contains(search_term) + || mime_entry + .applications + .iter() + .any(|app| app.to_lowercase().contains(search_term)) + || mime_entry + .extensions + .iter() + .any(|ext| ext.to_lowercase().contains(search_term)) + } else { + true + } + }) + .map(|(display_entry, _)| display_entry.clone()) + .collect(); + + self.last_search_filter = self.search_filter.clone(); + } + + pub(crate) fn calculate_visible_range(&mut self, scroll_offset: f32, viewport_height: f32) { + // Estimate row height if not set + if self.row_height == 0.0 { + self.row_height = 24.0; // Reasonable default + } + + self.visible_start = ((scroll_offset / self.row_height) as usize).saturating_sub(5); // 5 item buffer + self.visible_count = + ((viewport_height / self.row_height) as usize + 15).min(self.filtered_entries.len()); // 15 item buffer + } + + pub(crate) fn start_editing(&mut self, mime_type: &str) { + if let Some(entry) = self.mime_types.iter().find(|e| e.mime_type == mime_type) { + self.editing_mime_type = Some(mime_type.to_string()); + self.editing_applications = entry.applications.clone(); + self.new_application = String::new(); + self.selected_app_index = 0; + self.show_edit_dialog = true; + } + } + + pub(crate) fn save_applications(&mut self) { + if let Some(mime_type) = &self.editing_mime_type { + let mime_type_clone = mime_type.clone(); + let applications_clone = self.editing_applications.clone(); + + if let Some(entry) = self + .mime_types + .iter_mut() + .find(|e| e.mime_type == mime_type_clone) + { + entry.applications = applications_clone.clone(); + } + + self.update_display_cache(); + + if let Err(e) = + mime_parsing::save_mime_type_applications(&mime_type_clone, &applications_clone) + { + self.error_message = Some(format!("Error saving changes: {e}")); + } + } + self.cancel_editing(); + } + + pub(crate) fn cancel_editing(&mut self) { + self.editing_mime_type = None; + self.editing_applications.clear(); + self.new_application.clear(); + self.selected_app_index = 0; + self.show_edit_dialog = false; + } + + pub(crate) fn start_verification(&mut self) { + self.verification_entries.clear(); + + for entry in &self.mime_types { + let mut missing_applications = Vec::new(); + let mut duplicate_applications = Vec::new(); + + for app in &entry.applications { + if app == "No default application" || app == "System" { + continue; + } + + if !self.available_applications.contains(app) + && !mime_parsing::application_command_exists(app) + { + missing_applications.push(app.clone()); + } + } + + let mut seen_apps = std::collections::HashSet::new(); + let mut duplicates_set = std::collections::HashSet::new(); + + for app in &entry.applications { + if !seen_apps.insert(app.clone()) { + duplicates_set.insert(app.clone()); + } + } + + duplicate_applications.extend(duplicates_set.into_iter()); + duplicate_applications.sort(); + + if !missing_applications.is_empty() || !duplicate_applications.is_empty() { + let selected_for_removal = vec![true; missing_applications.len()]; + let replacement_applications = vec![None; missing_applications.len()]; + let replacement_indices = vec![0; missing_applications.len()]; + let selected_duplicates_for_removal = vec![true; duplicate_applications.len()]; + + self.verification_entries.push(VerificationEntry { + mime_type: entry.mime_type.clone(), + missing_applications, + selected_for_removal, + replacement_applications, + replacement_indices, + duplicate_applications, + selected_duplicates_for_removal, + }); + } + } + + self.show_verify_dialog = true; + } + + pub(crate) fn clean_missing_applications(&mut self) { + for verification_entry in &self.verification_entries { + if let Some(mime_entry) = self + .mime_types + .iter_mut() + .find(|e| e.mime_type == verification_entry.mime_type) + { + let mut replacements = std::collections::HashMap::new(); + let mut apps_to_remove = std::collections::HashSet::new(); + + for (i, missing_app) in verification_entry.missing_applications.iter().enumerate() { + if *verification_entry + .selected_for_removal + .get(i) + .unwrap_or(&false) + { + if let Some(replacement) = verification_entry + .replacement_applications + .get(i) + .and_then(|r| r.as_ref()) + { + if !mime_entry.applications.contains(replacement) { + replacements.insert(missing_app.clone(), replacement.clone()); + } + } else { + apps_to_remove.insert(missing_app.clone()); + } + } + } + + for (i, duplicate_app) in + verification_entry.duplicate_applications.iter().enumerate() + { + if *verification_entry + .selected_duplicates_for_removal + .get(i) + .unwrap_or(&false) + { + apps_to_remove.insert(duplicate_app.clone()); + } + } + + let mut new_applications = Vec::new(); + let mut seen_apps = std::collections::HashSet::new(); + + for app in &mime_entry.applications { + if let Some(replacement) = replacements.get(app) { + if !seen_apps.contains(replacement) { + new_applications.push(replacement.clone()); + seen_apps.insert(replacement.clone()); + } + } else if !apps_to_remove.contains(app) { + if !seen_apps.contains(app) { + new_applications.push(app.clone()); + seen_apps.insert(app.clone()); + } + } + } + + mime_entry.applications = new_applications; + + if let Err(e) = mime_parsing::save_mime_type_applications( + &mime_entry.mime_type, + &mime_entry.applications, + ) { + self.error_message = Some(format!( + "Error saving changes for {}: {}", + mime_entry.mime_type, e + )); + } + } + } + + self.update_display_cache(); + + self.cancel_verification(); + } + + pub(crate) fn cancel_verification(&mut self) { + self.show_verify_dialog = false; + self.verification_entries.clear(); + } + + pub(crate) fn render_truncated_text_with_tooltip( + &self, + ui: &mut egui::Ui, + rect: egui::Rect, + text: &str, + font_id: egui::FontId, + color: egui::Color32, + response: &egui::Response, + ) { + let full_text_size = ui + .painter() + .layout_no_wrap(text.to_string(), font_id.clone(), color) + .size(); + let available_width = rect.width() - 8.0; + + let (display_text, is_truncated) = if full_text_size.x > available_width { + let ellipsis = "..."; + + let mut left = 0; + let mut right = text.len(); + let mut best_len = 0; + + while left <= right { + let mid = (left + right) / 2; + if mid > text.len() { + break; + } + + let mut boundary = mid; + while boundary > 0 && !text.is_char_boundary(boundary) { + boundary -= 1; + } + + if boundary == 0 { + break; + } + + let prefix = &text[..boundary]; + let test_text = format!("{prefix}{ellipsis}"); + let test_width = ui + .painter() + .layout_no_wrap(test_text.clone(), font_id.clone(), color) + .size() + .x; + + if test_width <= available_width { + best_len = boundary; + left = mid + 1; + } else { + right = mid.saturating_sub(1); + } + } + + let truncated_text = if best_len > 0 { + format!("{}{}", &text[..best_len], ellipsis) + } else { + ellipsis.to_string() + }; + (truncated_text, true) + } else { + (text.to_string(), false) + }; + + ui.painter().text( + rect.left_center() + egui::vec2(4.0, 0.0), + egui::Align2::LEFT_CENTER, + &display_text, + font_id, + color, + ); + if response.hovered() && is_truncated { + response.clone().on_hover_text(text); + } + } +} + +impl eframe::App for MimeExplorerApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + if self.last_applied_theme != Some(self.current_theme) { + theme::apply(self.current_theme, ctx); + self.last_applied_theme = Some(self.current_theme); + } + + if let Some(receiver) = &self.mime_receiver { + if let Ok(result) = receiver.try_recv() { + match result { + Ok(mime_types) => { + self.mime_types = mime_types; + self.update_display_cache(); + self.loading = false; + } + Err(e) => { + self.error_message = Some(e); + self.loading = false; + } + } + self.mime_receiver = None; + } + } + + if self.mime_types.is_empty() && !self.loading && self.mime_receiver.is_none() { + self.load_mime_types(); + } + + if self.search_filter != self.last_search_filter { + self.update_filtered_cache(); + } + + self.show_edit_dialog(ctx); + + self.show_help_dialog(ctx); + + self.show_verify_dialog(ctx); + + self.show_top_panel(ctx); + + self.show_mime_list(ctx); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a302150 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,23 @@ +use crate::app::MimeExplorerApp; + +mod app; +mod mime_parsing; +mod theme; +mod types; +mod ui; + +fn main() -> Result<(), eframe::Error> { + let options = eframe::NativeOptions { + viewport: eframe::egui::ViewportBuilder::default() + .with_inner_size([1200.0, 800.0]) + .with_title("Mr. MIME") + .with_app_id("io.lampnet.mrmime"), + ..Default::default() + }; + + eframe::run_native( + "Mr. MIME", + options, + Box::new(|_cc| Ok(Box::new(MimeExplorerApp::default()))), + ) +} diff --git a/src/mime_parsing.rs b/src/mime_parsing.rs new file mode 100644 index 0000000..96c1002 --- /dev/null +++ b/src/mime_parsing.rs @@ -0,0 +1,801 @@ +// MIME type parsing and discovery for Mr. MIME +// +// This module discovers MIME types and their associated applications from multiple sources: +// 1. Standard FreeDesktop MIME database (/usr/share/mime/) +// 2. Traditional MIME type files (/etc/mime.types, etc.) +// 3. XDG MIME application cache (mimeinfo.cache) +// 4. Alternative MIME handlers: +// - handlr: Modern Rust-based handler with JSON output +// - mimeo: Python-based handler with regex support +// - exo-open: XFCE's file opener with custom configuration +// - mailcap: Traditional UNIX mail capability database +// - Other handlers: mimi, busking, rifle, etc. +// 5. Custom user overrides stored in ~/.config/mrmime/custom_mime_types.json +// +// The integration combines applications from all sources without duplicates, +// with user custom overrides taking complete precedence when defined. + +use crate::types::MimeTypeEntry; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::Path; +use std::process::Command; + +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct CustomMimeConfig { + pub overrides: HashMap>, // mime_type -> applications +} + +#[derive(Debug, Deserialize)] +struct HandlrMimeEntry { + mime: String, + handlers: Vec, +} + +fn get_config_file_path() -> Result> { + let home_dir = std::env::var("HOME")?; + let config_dir = std::path::Path::new(&home_dir) + .join(".config") + .join("mrmime"); + + if !config_dir.exists() { + std::fs::create_dir_all(&config_dir)?; + } + + Ok(config_dir.join("custom_mime_types.json")) +} + +fn load_custom_config() -> CustomMimeConfig { + match get_config_file_path() { + Ok(config_path) => { + if config_path.exists() { + match fs::read_to_string(&config_path) { + Ok(content) => match serde_json::from_str::(&content) { + Ok(config) => return config, + Err(e) => eprintln!("Error parsing custom config: {e}"), + }, + Err(e) => eprintln!("Error reading custom config: {e}"), + } + } + } + Err(e) => eprintln!("Error getting config path: {e}"), + } + CustomMimeConfig::default() +} + +pub fn save_custom_config(config: &CustomMimeConfig) -> Result<(), Box> { + let config_path = get_config_file_path()?; + let content = serde_json::to_string_pretty(config)?; + fs::write(&config_path, content)?; + Ok(()) +} + +pub fn save_mime_type_applications( + mime_type: &str, + applications: &[String], +) -> Result<(), Box> { + let mut config = load_custom_config(); + config + .overrides + .insert(mime_type.to_string(), applications.to_vec()); + save_custom_config(&config) +} + +pub fn discover_mime_types() -> Result, Box> { + let mut mime_types = Vec::new(); + let mut mime_map: HashMap = HashMap::new(); + + if let Err(e) = discover_inode_types(&mut mime_map) { + eprintln!("Warning: Error discovering inode types: {e}"); + } + + let mime_paths = vec![ + "/etc/mime.types", + "/usr/share/mime/types", + "/usr/local/share/mime/types", + ]; + + for path in mime_paths { + if let Ok(content) = fs::read_to_string(path) { + if parse_mime_types_file(&content, &mut mime_map).is_err() { + // Ignore errors from single files + } + } + } + + if parse_freedesktop_mime_database(&mut mime_map).is_err() { + // Ignore errors from freedesktop db + } + + integrate_alternative_handlers(&mut mime_map); + + let custom_config = load_custom_config(); + for (mime_type, applications) in &custom_config.overrides { + if let Some(entry) = mime_map.get_mut(mime_type) { + entry.applications = applications.clone(); + } + } + + mime_types.extend(mime_map.into_values()); + + mime_types.sort_by(|a, b| a.mime_type.cmp(&b.mime_type)); + + Ok(mime_types) +} + +fn discover_inode_types( + mime_map: &mut HashMap, +) -> Result<(), Box> { + let inode_dir = Path::new("/usr/share/mime/inode"); + + if !inode_dir.exists() { + return Err("FreeDesktop MIME inode directory not found".into()); + } + + let entries = fs::read_dir(inode_dir)?; + + for entry in entries { + let entry = entry?; + let path = entry.path(); + + if let Some(extension) = path.extension() { + if extension == "xml" { + if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) { + let mime_type = format!("inode/{filename}"); + + match parse_inode_xml(&path) { + Ok(mut inode_entry) => { + inode_entry.mime_type = mime_type.clone(); + + inode_entry.applications = get_applications_for_mime(&mime_type); + + mime_map.insert(mime_type, inode_entry); + } + Err(e) => { + eprintln!("Warning: Error parsing {}: {}", path.display(), e); + } + } + } + } + } + } + + Ok(()) +} + +fn parse_inode_xml(path: &Path) -> Result> { + let content = fs::read_to_string(path)?; + + let mime_type = extract_xml_attribute(&content, "mime-type", "type") + .unwrap_or_else(|| "unknown/unknown".to_string()); + + let _description = extract_xml_text(&content, "comment"); + + let icon = extract_xml_attribute(&content, "generic-icon", "name"); + + let _aliases = extract_xml_attributes(&content, "alias", "type"); + + Ok(MimeTypeEntry { + mime_type, + extensions: Vec::new(), + applications: Vec::new(), + icon, + }) +} + +fn extract_xml_attribute(content: &str, element: &str, attribute: &str) -> Option { + let element_start = format!("<{element}"); + let mut pos = 0; + + while let Some(start_pos) = content[pos..].find(&element_start) { + let absolute_start = pos + start_pos; + if let Some(end_pos) = content[absolute_start..].find('>') { + let tag_content = &content[absolute_start..absolute_start + end_pos]; + + let attr_pattern = format!(r#"{attribute}=""#); + if let Some(attr_pos) = tag_content.find(&attr_pattern) { + let value_start = attr_pos + attr_pattern.len(); + if let Some(quote_end) = tag_content[value_start..].find('"') { + let value = &tag_content[value_start..value_start + quote_end]; + return Some(value.to_string()); + } + } + } + pos = absolute_start + 1; + } + None +} + +fn extract_xml_attributes(content: &str, element: &str, attribute: &str) -> Vec { + let mut results = Vec::new(); + let element_start = format!("<{element}"); + let mut pos = 0; + + while let Some(start_pos) = content[pos..].find(&element_start) { + let absolute_start = pos + start_pos; + if let Some(end_pos) = content[absolute_start..].find('>') { + let tag_content = &content[absolute_start..absolute_start + end_pos]; + + let attr_pattern = format!(r#"{attribute}=""#); + if let Some(attr_pos) = tag_content.find(&attr_pattern) { + let value_start = attr_pos + attr_pattern.len(); + if let Some(quote_end) = tag_content[value_start..].find('"') { + let value = &tag_content[value_start..value_start + quote_end]; + results.push(value.to_string()); + } + } + } + pos = absolute_start + 1; + } + + results +} + +fn extract_xml_text(content: &str, element: &str) -> Option { + let start_tag = format!("<{element}"); + let end_tag = format!(""); + + if let Some(start_pos) = content.find(&start_tag) { + if let Some(tag_end) = content[start_pos..].find('>') { + let content_start = start_pos + tag_end + 1; + + if let Some(end_pos) = content[content_start..].find(&end_tag) { + let text_content = &content[content_start..content_start + end_pos]; + return Some(text_content.trim().to_string()); + } + } + } + None +} + +fn parse_mime_types_file( + content: &str, + mime_map: &mut HashMap, +) -> Result<(), Box> { + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + let mime_type = parts[0].to_string(); + let extensions: Vec = parts[1..].iter().map(|s| s.to_string()).collect(); + + let applications = get_applications_for_mime(&mime_type); + + let entry = MimeTypeEntry { + mime_type: mime_type.clone(), + extensions, + applications, + icon: None, + }; + + mime_map.insert(mime_type, entry); + } + } + Ok(()) +} + +fn parse_freedesktop_mime_database( + mime_map: &mut HashMap, +) -> Result<(), Box> { + let mime_dir = "/usr/share/mime"; + if !Path::new(mime_dir).exists() { + return Ok(()); + } + + let globs_path = format!("{mime_dir}/globs2"); + if let Ok(content) = fs::read_to_string(&globs_path) { + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() >= 3 { + let mime_type = parts[1].to_string(); + let pattern = parts[2].to_string(); + + let extension = if let Some(_stripped) = pattern.strip_prefix("*.") { + pattern[2..].to_string() + } else { + pattern + }; + + let entry = mime_map.entry(mime_type.clone()).or_insert_with(|| { + let applications = get_applications_for_mime(&mime_type); + MimeTypeEntry { + mime_type: mime_type.clone(), + extensions: Vec::new(), + applications, + icon: None, + } + }); + + if !entry.extensions.contains(&extension) { + entry.extensions.push(extension); + } + } + } + } + + Ok(()) +} + +fn get_applications_for_mime(mime_type: &str) -> Vec { + let mut applications = Vec::new(); + + let home_dir = std::env::var("HOME").unwrap_or_default(); + let mime_cache_paths = vec![ + format!("{}/.local/share/applications/mimeinfo.cache", home_dir), + "/usr/share/applications/mimeinfo.cache".to_string(), + ]; + + for cache_path in mime_cache_paths { + if let Ok(content) = fs::read_to_string(&cache_path) { + for line in content.lines() { + if line.starts_with(&format!("{mime_type}=")) { + if let Some(apps_part) = line.split('=').nth(1) { + let apps: Vec<&str> = apps_part.split(';').collect(); + for app in apps { + if !app.is_empty() { + let app_name = get_app_display_name(app); + if !applications.contains(&app_name) { + applications.push(app_name); + } + } + } + } + } + } + } + } + + applications +} + +fn get_app_display_name(desktop_file: &str) -> String { + let home_dir = std::env::var("HOME").unwrap_or_default(); + let desktop_paths = vec![ + format!("/usr/share/applications/{desktop_file}"), + format!("{home_dir}/.local/share/applications/{desktop_file}"), + ]; + + for path in desktop_paths { + if let Ok(content) = fs::read_to_string(&path) { + for line in content.lines() { + if let Some(_stripped) = line.strip_prefix("Name=") { + return line[5..].to_string(); + } + } + } + } + + desktop_file.replace(".desktop", "").replace('-', " ") +} + +pub fn discover_all_applications() -> Result, Box> { + let mut applications = Vec::new(); + let home_dir = std::env::var("HOME").unwrap_or_default(); + + let desktop_dirs = vec![ + "/usr/share/applications".to_string(), + format!("{}/.local/share/applications", home_dir), + "/usr/local/share/applications".to_string(), + ]; + + for dir_path in desktop_dirs { + if let Ok(entries) = fs::read_dir(&dir_path) { + for entry in entries { + let path = entry?.path(); + if let Some(extension) = path.extension() { + if extension == "desktop" { + if let Some(filename) = path.file_name().and_then(|n| n.to_str()) { + let display_name = get_app_display_name(filename); + // Only add if it's not a duplicate and has a reasonable name + if !applications.contains(&display_name) && !display_name.is_empty() { + applications.push(display_name); + } + } + } + } + } + } + } + + applications.sort(); + Ok(applications) +} + +fn merge_applications(existing: &mut Vec, new_apps: Vec) { + for app in new_apps { + if !existing.contains(&app) { + existing.push(app); + } + } +} + +fn integrate_alternative_handlers(mime_map: &mut HashMap) { + if let Ok(handlr_associations) = get_handlr_associations() { + for (mime_type, apps) in handlr_associations { + let entry = mime_map + .entry(mime_type.clone()) + .or_insert_with(|| MimeTypeEntry { + mime_type: mime_type.clone(), + extensions: Vec::new(), + applications: Vec::new(), + icon: None, + }); + + merge_applications(&mut entry.applications, apps); + } + } + + if let Ok(mimeo_associations) = get_mimeo_associations() { + for (mime_type, apps) in mimeo_associations { + let entry = mime_map + .entry(mime_type.clone()) + .or_insert_with(|| MimeTypeEntry { + mime_type: mime_type.clone(), + extensions: Vec::new(), + applications: Vec::new(), + icon: None, + }); + + merge_applications(&mut entry.applications, apps); + } + } + + if let Ok(other_associations) = get_other_handler_associations() { + for (mime_type, apps) in other_associations { + let entry = mime_map + .entry(mime_type.clone()) + .or_insert_with(|| MimeTypeEntry { + mime_type: mime_type.clone(), + extensions: Vec::new(), + applications: Vec::new(), + icon: None, + }); + + merge_applications(&mut entry.applications, apps); + } + } +} + +pub fn detect_available_handlers() -> Vec { + let mut handlers = Vec::new(); + + if Command::new("handlr") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + handlers.push("handlr".to_string()); + } + + if Command::new("mimeo") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + handlers.push("mimeo".to_string()); + } + + if Command::new("exo-open") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + handlers.push("exo-open".to_string()); + } + + if Command::new("run-mailcap") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + handlers.push("run-mailcap".to_string()); + } + + let other_handlers = ["mimi", "busking", "rifle"]; + for handler in &other_handlers { + if Command::new(handler) + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + handlers.push(handler.to_string()); + } + } + + handlers +} + +fn get_handlr_associations() -> Result>, Box> { + let mut associations = HashMap::new(); + + let output = Command::new("handlr").arg("list").arg("--json").output(); + + match output { + Ok(output) => { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + + if stdout.trim().is_empty() { + return Ok(associations); + } + + match serde_json::from_str::>(&stdout) { + Ok(entries) => { + for entry in entries { + let mut app_names = Vec::new(); + for handler in entry.handlers { + let app_name = get_app_display_name(&handler); + app_names.push(app_name); + } + + if !app_names.is_empty() { + associations.insert(entry.mime, app_names); + } + } + } + Err(e) => { + eprintln!("Warning: Failed to parse handlr JSON output: {e}"); + } + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!( + "Warning: handlr command failed with status {}: {stderr}", + output.status + ); + } + } + Err(e) => { + eprintln!("handlr not available: {e}"); + } + } + + Ok(associations) +} + +fn get_mimeo_associations() -> Result>, Box> { + let mut associations = HashMap::new(); + + let home_dir = std::env::var("HOME").unwrap_or_default(); + let mimeo_config = format!("{home_dir}/.config/mimeo/associations.txt"); + + if let Ok(content) = fs::read_to_string(&mimeo_config) { + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + if let Some((mime_type, app)) = line.split_once('=') { + let mime_type = mime_type.trim().to_string(); + let app = app.trim().to_string(); + associations + .entry(mime_type) + .or_insert_with(Vec::new) + .push(app); + } + } + } + + if let Ok(output) = Command::new("mimeo").arg("--mime2desk").output() { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + let line = line.trim(); + if let Some((mime_type, desktop_file)) = line.split_once(':') { + let mime_type = mime_type.trim().to_string(); + let app_name = get_app_display_name(desktop_file.trim()); + associations + .entry(mime_type) + .or_insert_with(Vec::new) + .push(app_name); + } + } + } + } + + Ok(associations) +} + +fn get_other_handler_associations() +-> Result>, Box> { + let mut associations = HashMap::new(); + + if let Ok(exo_associations) = get_exo_associations() { + for (mime_type, apps) in exo_associations { + associations + .entry(mime_type) + .or_insert_with(Vec::new) + .extend(apps); + } + } + + if let Ok(other_associations) = get_mailcap_associations() { + for (mime_type, apps) in other_associations { + associations + .entry(mime_type) + .or_insert_with(Vec::new) + .extend(apps); + } + } + + Ok(associations) +} + +fn get_exo_associations() -> Result>, Box> { + let mut associations = HashMap::new(); + + let home_dir = std::env::var("HOME").unwrap_or_default(); + let exo_config = format!("{home_dir}/.config/xfce4/helpers.rc"); + + if let Ok(content) = fs::read_to_string(&exo_config) { + for line in content.lines() { + let line = line.trim(); + if line.contains("=") { + if let Some((key, value)) = line.split_once('=') { + let key = key.trim(); + let value = value.trim(); + + match key { + "WebBrowser" => { + associations + .entry("text/html".to_string()) + .or_insert_with(Vec::new) + .push(value.to_string()); + associations + .entry("application/xhtml+xml".to_string()) + .or_insert_with(Vec::new) + .push(value.to_string()); + } + "MailReader" => { + associations + .entry("x-scheme-handler/mailto".to_string()) + .or_insert_with(Vec::new) + .push(value.to_string()); + } + "FileManager" => { + associations + .entry("inode/directory".to_string()) + .or_insert_with(Vec::new) + .push(value.to_string()); + } + "TerminalEmulator" => { + associations + .entry("x-scheme-handler/terminal".to_string()) + .or_insert_with(Vec::new) + .push(value.to_string()); + } + _ => {} + } + } + } + } + } + + Ok(associations) +} + +fn get_mailcap_associations() -> Result>, Box> { + let mut associations = HashMap::new(); + + let home_dir = std::env::var("HOME").unwrap_or_default(); + let mailcap_paths = vec![format!("{home_dir}/.mailcap"), "/etc/mailcap".to_string()]; + + for path in mailcap_paths { + if let Ok(content) = fs::read_to_string(&path) { + for line in content.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + if let Some(semicolon_pos) = line.find(';') { + let mime_type = line[..semicolon_pos].trim().to_string(); + let rest = &line[semicolon_pos + 1..]; + + let command = if let Some(next_semicolon) = rest.find(';') { + rest[..next_semicolon].trim() + } else { + rest.trim() + }; + + if let Some(app_name) = command.split_whitespace().next() { + let app_name = Path::new(app_name) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(app_name) + .to_string(); + + associations + .entry(mime_type) + .or_insert_with(Vec::new) + .push(app_name); + } + } + } + } + } + + Ok(associations) +} + +pub fn command_exists(command: &str) -> bool { + if let Ok(output) = Command::new("which").arg(command).output() { + if output.status.success() { + return true; + } + } + + if let Ok(path_var) = std::env::var("PATH") { + for path_dir in path_var.split(':') { + let full_path = Path::new(path_dir).join(command); + if full_path.exists() && full_path.is_file() { + if let Ok(metadata) = fs::metadata(&full_path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o111 != 0 { + return true; + } + } + #[cfg(not(unix))] + { + return true; + } + } + } + } + } + + false +} + +pub fn application_command_exists(app_name: &str) -> bool { + if app_name == "No default application" || app_name == "System" { + return true; + } + + if command_exists(app_name) { + return true; + } + + let potential_commands = vec![ + app_name.to_lowercase(), + app_name.replace(" ", "").to_lowercase(), + app_name + .split_whitespace() + .next() + .unwrap_or("") + .to_lowercase(), + app_name + .split('(') + .next() + .unwrap_or("") + .trim() + .to_lowercase(), + app_name.to_lowercase(), + ]; + + for cmd in potential_commands { + if !cmd.is_empty() && command_exists(&cmd) { + return true; + } + } + + false +} diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..bced236 --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,198 @@ +use eframe::egui; + +#[derive(Default, Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum Theme { + #[default] + System, + Light, + Dark, +} + +pub fn apply(theme: Theme, ctx: &egui::Context) { + match theme { + Theme::System => { + if let Some(system_visuals) = get_system_colors() { + ctx.set_visuals(system_visuals); + } else { + let is_dark = detect_system_dark_mode(); + if is_dark { + ctx.set_visuals(egui::Visuals::dark()); + } else { + ctx.set_visuals(egui::Visuals::light()); + } + } + } + Theme::Light => { + ctx.set_visuals(egui::Visuals::light()); + } + Theme::Dark => { + ctx.set_visuals(egui::Visuals::dark()); + } + } +} + +fn get_system_colors() -> Option { + if let Some(visuals) = get_pywal_colors() { + return Some(visuals); + } + + #[cfg(target_os = "linux")] + { + if let Some(visuals) = get_gtk_colors() { + return Some(visuals); + } + } + + None +} + +fn get_pywal_colors() -> Option { + use std::fs; + use std::path::Path; + + let home = std::env::var("HOME").ok()?; + let colors_path = Path::new(&home).join(".cache/wal/colors"); + + if !colors_path.exists() { + return None; + } + + let colors_content = fs::read_to_string(&colors_path).ok()?; + let colors: Vec<&str> = colors_content.lines().collect(); + + if colors.len() < 8 { + return None; + } + + let parse_color = |hex: &str| -> Option { + if hex.len() != 7 || !hex.starts_with('#') { + return None; + } + let r = u8::from_str_radix(&hex[1..3], 16).ok()?; + let g = u8::from_str_radix(&hex[3..5], 16).ok()?; + let b = u8::from_str_radix(&hex[5..7], 16).ok()?; + Some(egui::Color32::from_rgb(r, g, b)) + }; + + let bg = parse_color(colors[0])?; + let fg = parse_color(colors.get(7).unwrap_or(&colors[0]))?; + let bg_alt = parse_color(colors.get(8).unwrap_or(&colors[0]))?; + let accent = parse_color(colors.get(1).unwrap_or(&colors[0]))?; + let secondary = parse_color(colors.get(2).unwrap_or(&colors[0]))?; + + let mut visuals = if is_dark_color(bg) { + egui::Visuals::dark() + } else { + egui::Visuals::light() + }; + + visuals.window_fill = bg; + visuals.extreme_bg_color = bg; + visuals.code_bg_color = bg; + visuals.panel_fill = bg; + + visuals.faint_bg_color = blend_colors(bg, bg_alt, 0.15); + visuals.error_fg_color = parse_color(colors.get(1).unwrap_or(&colors[0]))?; + + visuals.override_text_color = Some(fg); + + visuals.hyperlink_color = accent; + visuals.selection.bg_fill = blend_colors(accent, bg, 0.3); + visuals.selection.stroke.color = accent; + + let separator_color = blend_colors(fg, bg, 0.3); + + visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, separator_color); + visuals.widgets.noninteractive.bg_fill = bg; + visuals.widgets.noninteractive.fg_stroke.color = fg; + + visuals.widgets.inactive.bg_fill = blend_colors(bg, accent, 0.2); + visuals.widgets.inactive.fg_stroke.color = fg; + visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, blend_colors(accent, bg, 0.4)); + visuals.widgets.inactive.weak_bg_fill = blend_colors(bg, accent, 0.1); + + visuals.widgets.hovered.bg_fill = blend_colors(bg, accent, 0.3); + visuals.widgets.hovered.fg_stroke.color = fg; + visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, accent); + visuals.widgets.hovered.weak_bg_fill = blend_colors(bg, accent, 0.15); + + visuals.widgets.active.bg_fill = blend_colors(bg, accent, 0.4); + visuals.widgets.active.fg_stroke.color = fg; + visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, accent); + visuals.widgets.active.weak_bg_fill = blend_colors(bg, accent, 0.2); + + visuals.window_stroke = egui::Stroke::new(1.0, separator_color); + + visuals.widgets.open.bg_fill = blend_colors(bg, accent, 0.25); + visuals.widgets.open.fg_stroke.color = fg; + visuals.widgets.open.bg_stroke = egui::Stroke::new(1.0, accent); + visuals.widgets.open.weak_bg_fill = blend_colors(bg, accent, 0.15); + + visuals.striped = true; + + visuals.button_frame = true; + visuals.collapsing_header_frame = false; + + Some(visuals) +} + +fn get_gtk_colors() -> Option { + // Try to read GTK theme colors + None +} + +fn is_dark_color(color: egui::Color32) -> bool { + let r = color.r() as f32 / 255.0; + let g = color.g() as f32 / 255.0; + let b = color.b() as f32 / 255.0; + + let r_lin = if r <= 0.04045 { + r / 12.92 + } else { + ((r + 0.055) / 1.055).powf(2.4) + }; + let g_lin = if g <= 0.04045 { + g / 12.92 + } else { + ((g + 0.055) / 1.055).powf(2.4) + }; + let b_lin = if b <= 0.04045 { + b / 12.92 + } else { + ((b + 0.055) / 1.055).powf(2.4) + }; + + let luminance = 0.2126 * r_lin + 0.7152 * g_lin + 0.0722 * b_lin; + + luminance < 0.5 +} + +fn blend_colors(base: egui::Color32, blend: egui::Color32, factor: f32) -> egui::Color32 { + let factor = factor.clamp(0.0, 1.0); + let inv_factor = 1.0 - factor; + + egui::Color32::from_rgb( + (base.r() as f32 * inv_factor + blend.r() as f32 * factor) as u8, + (base.g() as f32 * inv_factor + blend.g() as f32 * factor) as u8, + (base.b() as f32 * inv_factor + blend.b() as f32 * factor) as u8, + ) +} + +fn detect_system_dark_mode() -> bool { + #[cfg(target_os = "windows")] + { + true + } + #[cfg(target_os = "macos")] + { + true + } + #[cfg(target_os = "linux")] + { + true + } + #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + { + true + } +} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..6716d32 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MimeTypeEntry { + pub mime_type: String, + pub extensions: Vec, + pub applications: Vec, + pub icon: Option, +} + +#[derive(Clone)] +pub struct DisplayEntry { + pub mime_type: String, + pub extensions_text: String, + pub applications_text: String, +} + +impl From<&MimeTypeEntry> for DisplayEntry { + fn from(entry: &MimeTypeEntry) -> Self { + Self { + mime_type: entry.mime_type.clone(), + extensions_text: if entry.extensions.is_empty() { + "—".to_string() + } else { + entry.extensions.join(", ") + }, + applications_text: if entry.applications.is_empty() { + "No default application".to_string() + } else { + entry.applications.join(", ") + }, + } + } +} + +#[derive(Debug, Clone)] +pub struct VerificationEntry { + pub mime_type: String, + pub missing_applications: Vec, + pub selected_for_removal: Vec, + pub replacement_applications: Vec>, + pub replacement_indices: Vec, + pub duplicate_applications: Vec, + pub selected_duplicates_for_removal: Vec, +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..9b42519 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,4 @@ +pub mod edit_dialog; +pub mod help_dialog; +pub mod main_window; +pub mod verify_dialog; \ No newline at end of file diff --git a/src/ui/edit_dialog.rs b/src/ui/edit_dialog.rs new file mode 100644 index 0000000..b5d2913 --- /dev/null +++ b/src/ui/edit_dialog.rs @@ -0,0 +1,132 @@ +use crate::app::MimeExplorerApp; +use eframe::egui; + +impl MimeExplorerApp { + pub(crate) fn show_edit_dialog(&mut self, ctx: &egui::Context) { + if !self.show_edit_dialog { + return; + } + + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + let any_popup_open = ctx.memory(|mem| mem.any_popup_open()); + if !any_popup_open { + self.cancel_editing(); + return; + } + } + + let mime_type = self.editing_mime_type.clone().unwrap_or_default(); + let visuals = ctx.style().visuals.clone(); + + let extensions = + if let Some(entry) = self.mime_types.iter().find(|e| e.mime_type == mime_type) { + if entry.extensions.is_empty() { + "—".to_string() + } else { + entry.extensions.join(", ") + } + } else { + "—".to_string() + }; + + egui::Window::new(format!("Edit Applications for {mime_type}")) + .frame(egui::Frame { + fill: visuals.window_fill, + stroke: visuals.window_stroke, + corner_radius: egui::CornerRadius::same(8), + shadow: visuals.window_shadow, + inner_margin: egui::Margin::same(16), + outer_margin: egui::Margin::same(0), + }) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.screen_rect().center()) + .collapsible(false) + .resizable(false) + .default_width(500.0) + .default_height(450.0) + .show(ctx, |ui| { + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label("Extensions:"); + ui.label(extensions); + }); + ui.separator(); + + ui.label("Current applications:"); + + let mut to_remove = Vec::new(); + for (index, app) in self.editing_applications.iter().enumerate() { + ui.horizontal(|ui| { + if app != "No default application" && ui.button("❌").clicked() { + to_remove.push(index); + } + ui.label(app); + }); + } + + for &index in to_remove.iter().rev() { + self.editing_applications.remove(index); + } + + ui.separator(); + + ui.label("Add application from available:"); + ui.horizontal(|ui| { + if !self.available_applications.is_empty() { + let selected_app = + if self.selected_app_index < self.available_applications.len() { + &self.available_applications[self.selected_app_index] + } else { + &self.available_applications[0] + }; + + egui::ComboBox::from_id_salt("application_selector") + .selected_text(selected_app) + .show_ui(ui, |ui| { + for (i, app) in self.available_applications.iter().enumerate() { + ui.selectable_value(&mut self.selected_app_index, i, app); + } + }); + + if ui.button("➕ Add").clicked() { + let app_to_add = + self.available_applications[self.selected_app_index].clone(); + if !self.editing_applications.contains(&app_to_add) { + self.editing_applications.push(app_to_add); + } + } + } else { + ui.label("Loading applications..."); + } + }); + + ui.label("Or enter manually:"); + ui.horizontal(|ui| { + ui.text_edit_singleline(&mut self.new_application); + if ui.button("➕ Add Manual").clicked() + && !self.new_application.trim().is_empty() + { + let new_app = self.new_application.trim().to_string(); + if !self.editing_applications.contains(&new_app) { + self.editing_applications.push(new_app); + } + self.new_application.clear(); + } + }); + + ui.separator(); + + ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| { + ui.horizontal(|ui| { + if ui.button("💾 Save").clicked() { + self.save_applications(); + } + if ui.button("❌ Cancel").clicked() { + self.cancel_editing(); + } + }); + }); + }); + }); + } +} diff --git a/src/ui/help_dialog.rs b/src/ui/help_dialog.rs new file mode 100644 index 0000000..79729cc --- /dev/null +++ b/src/ui/help_dialog.rs @@ -0,0 +1,54 @@ +use crate::app::MimeExplorerApp; +use eframe::egui; + +impl MimeExplorerApp { + pub(crate) fn show_help_dialog(&mut self, ctx: &egui::Context) { + if !self.show_help_dialog { + return; + } + + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + self.show_help_dialog = false; + return; + } + + let visuals = ctx.style().visuals.clone(); + + egui::Window::new(format!("Mr. MIME ({})", env!("CARGO_PKG_VERSION"))) + .frame(egui::Frame { + fill: visuals.window_fill, + stroke: visuals.window_stroke, + corner_radius: egui::CornerRadius::same(8), + shadow: visuals.window_shadow, + inner_margin: egui::Margin::same(16), + outer_margin: egui::Margin::same(0), + }) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.screen_rect().center()) + .collapsible(false) + .resizable(false) + .default_width(600.0) + .default_height(500.0) + .show(ctx, |ui| { + ui.vertical(|ui| { + + ui.label("Mr. MIME is a Linux MIME (Multi-purpose Internet Mail Extensions) type viewer and default application manager that helps you edit file associations on your system."); + + ui.add_space(10.0); + + ui.label("Select an entry to edit the default application(s)."); + ui.label("Search for a MIME type or application to list relevant entries."); + ui.label("Click 'Verify' to run a check against entries for any missing or duplicate entries."); + + ui.add_space(15.0); + + // Close button + ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| { + if ui.button("Close").clicked() { + self.show_help_dialog = false; + } + }); + }); + }); + } +} diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs new file mode 100644 index 0000000..973fcf9 --- /dev/null +++ b/src/ui/main_window.rs @@ -0,0 +1,278 @@ +use crate::app::MimeExplorerApp; +use crate::mime_parsing; +use crate::theme::Theme; +use eframe::egui; + +impl MimeExplorerApp { + pub(crate) fn show_top_panel(&mut self, ctx: &egui::Context) { + egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { + ui.horizontal(|ui| { + ui.heading("🎭 Mr. MIME - Default Application Manager"); + ui.separator(); + ui.label(format!("Total MIME types: {}", self.mime_types.len())); + ui.separator(); + ui.label(format!( + "Showing {} of {} MIME types", + self.filtered_entries.len(), + self.mime_types.len() + )); + + ui.separator(); + let handlers = mime_parsing::detect_available_handlers(); + if !handlers.is_empty() { + ui.label(format!("🔧 Handlers: {}", handlers.join(", "))) + .on_hover_text("Alternative MIME handlers detected on your system"); + } else { + ui.label("🔧 Standard handlers only") + .on_hover_text("No alternative MIME handlers detected"); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.selectable_label(false, "Help ❓").clicked() { + self.show_help_dialog = true; + } + ui.separator(); + + egui::ComboBox::from_id_salt("theme_selector") + .selected_text(match self.current_theme { + Theme::System => "💻 System", + Theme::Light => "☉ Light", + Theme::Dark => "🌙 Dark", + }) + .show_ui(ui, |ui| { + ui.selectable_value( + &mut self.current_theme, + Theme::System, + "💻 System", + ); + ui.selectable_value(&mut self.current_theme, Theme::Light, "☉ Light"); + ui.selectable_value(&mut self.current_theme, Theme::Dark, "🌙 Dark"); + }); + ui.label("Theme:"); + }); + }); + }); + + egui::TopBottomPanel::top("search_panel").show(ctx, |ui| { + ui.horizontal(|ui| { + ui.label("🔍 Search:"); + ui.text_edit_singleline(&mut self.search_filter); + + ui.separator(); + + let mut filter_changed = false; + if ui + .checkbox(&mut self.show_entries_with_no_applications, "Empty entries") + .changed() + { + filter_changed = true; + } + + let visuals = ui.visuals().clone(); + let refresh_button = egui::Button::new("🔄 Refresh") + .fill(visuals.widgets.inactive.bg_fill) + .stroke(visuals.widgets.inactive.bg_stroke); + if ui.add(refresh_button).clicked() { + self.load_mime_types(); + } + + let verify_button = egui::Button::new("🔍 Verify") + .fill(visuals.widgets.inactive.bg_fill) + .stroke(visuals.widgets.inactive.bg_stroke); + if ui.add(verify_button).clicked() { + self.start_verification(); + } + + if filter_changed { + self.update_filtered_cache(); + } + }); + }); + } + + pub(crate) fn show_mime_list(&mut self, ctx: &egui::Context) { + let mut mime_to_edit: Option = None; + let mut mime_to_select: Option = None; + + egui::CentralPanel::default().show(ctx, |ui| { + if self.loading { + ui.centered_and_justified(|ui| { + ui.spinner(); + ui.label("Loading MIME types..."); + }); + return; + } + + if let Some(error) = &self.error_message { + ui.colored_label(egui::Color32::RED, format!("❌ {error}")); + return; + } + + let total_width = ui.available_width(); + let column_width = (total_width - 20.0) / 3.0; + + egui::Grid::new("mime_header_grid") + .num_columns(3) + .striped(false) + .min_col_width(column_width) + .max_col_width(column_width) + .show(ui, |ui| { + ui.allocate_ui(egui::vec2(column_width, self.row_height), |ui| { + ui.strong("MIME Type"); + }); + ui.allocate_ui(egui::vec2(column_width, self.row_height), |ui| { + ui.strong("Extensions"); + }); + ui.allocate_ui(egui::vec2(column_width, self.row_height), |ui| { + ui.strong("Applications"); + }); + ui.end_row(); + }); + + ui.separator(); + + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + let scroll_offset = ui.clip_rect().min.y - ui.max_rect().min.y; + let viewport_height = ui.available_height(); + self.calculate_visible_range(scroll_offset.abs(), viewport_height); + + let visible_end = + (self.visible_start + self.visible_count).min(self.filtered_entries.len()); + + if self.visible_start > 0 { + ui.add_space(self.visible_start as f32 * self.row_height); + } + + egui::Grid::new("mime_grid") + .num_columns(3) + .striped(false) + .min_col_width(column_width) + .max_col_width(column_width) + .show(ui, |ui| { + for (visible_index, display_entry) in self + .filtered_entries + .iter() + .skip(self.visible_start) + .take(self.visible_count) + .enumerate() + { + let logical_index = self.visible_start + visible_index; + let is_selected = self.selected_mime_type.as_ref() + == Some(&display_entry.mime_type); + + let is_odd_row = logical_index % 2 == 1; + + let row_start_y = ui.cursor().min.y; + + let (mime_rect, mime_response) = ui.allocate_exact_size( + egui::vec2(column_width, self.row_height), + egui::Sense::click(), + ); + + let (ext_rect, ext_response) = ui.allocate_exact_size( + egui::vec2(column_width, self.row_height), + egui::Sense::click(), + ); + + let (app_rect, app_response) = ui.allocate_exact_size( + egui::vec2(column_width, self.row_height), + egui::Sense::click(), + ); + + ui.end_row(); + + let full_row_rect = egui::Rect::from_min_max( + egui::pos2(mime_rect.min.x, row_start_y), + egui::pos2(app_rect.max.x, row_start_y + self.row_height), + ); + + let bg_color = if is_selected { + ui.visuals().selection.bg_fill + } else if is_odd_row { + ui.visuals().faint_bg_color + } else { + ui.visuals().window_fill + }; + ui.painter().rect_filled(full_row_rect, 0.0, bg_color); + + let text_color = ui.visuals().text_color(); + let font_id = egui::FontId::default(); + + self.render_truncated_text_with_tooltip( + ui, + mime_rect, + &display_entry.mime_type, + font_id.clone(), + text_color, + &mime_response, + ); + + self.render_truncated_text_with_tooltip( + ui, + ext_rect, + &display_entry.extensions_text, + font_id.clone(), + text_color, + &ext_response, + ); + + self.render_truncated_text_with_tooltip( + ui, + app_rect, + &display_entry.applications_text, + font_id, + text_color, + &app_response, + ); + + let any_clicked = mime_response.clicked() + || ext_response.clicked() + || app_response.clicked(); + let any_double_clicked = mime_response.double_clicked() + || ext_response.double_clicked() + || app_response.double_clicked(); + + if any_clicked { + mime_to_select = Some(display_entry.mime_type.clone()); + } + + if any_double_clicked { + mime_to_edit = Some(display_entry.mime_type.clone()); + } + + let mime_type_for_menu = display_entry.mime_type.clone(); + for response in [&mime_response, &ext_response, &app_response] { + response.context_menu(|ui| { + if ui.button("📝 Edit Applications").clicked() { + mime_to_edit = Some(mime_type_for_menu.clone()); + self.selected_mime_type = + Some(mime_type_for_menu.clone()); + ui.close_menu(); + } + if ui.button("📋 Copy MIME Type").clicked() { + ui.ctx().copy_text(mime_type_for_menu.clone()); + ui.close_menu(); + } + }); + } + } + }); + + let remaining_items = self.filtered_entries.len().saturating_sub(visible_end); + if remaining_items > 0 { + ui.add_space(remaining_items as f32 * self.row_height); + } + }); + }); + + if let Some(mime_type) = mime_to_select { + self.selected_mime_type = Some(mime_type); + } + + if let Some(mime_type) = mime_to_edit { + self.start_editing(&mime_type); + } + } +} diff --git a/src/ui/verify_dialog.rs b/src/ui/verify_dialog.rs new file mode 100644 index 0000000..e55b668 --- /dev/null +++ b/src/ui/verify_dialog.rs @@ -0,0 +1,253 @@ +use crate::app::MimeExplorerApp; +use eframe::egui; + +impl MimeExplorerApp { + pub(crate) fn show_verify_dialog(&mut self, ctx: &egui::Context) { + if !self.show_verify_dialog { + return; + } + + if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + let any_popup_open = ctx.memory(|mem| mem.any_popup_open()); + if !any_popup_open { + self.cancel_verification(); + return; + } + } + + let visuals = ctx.style().visuals.clone(); + + egui::Window::new("🔍 Verify Applications") + .frame(egui::Frame { + fill: visuals.window_fill, + stroke: visuals.window_stroke, + corner_radius: egui::CornerRadius::same(8), + shadow: visuals.window_shadow, + inner_margin: egui::Margin::same(16), + outer_margin: egui::Margin::same(0), + }) + .pivot(egui::Align2::CENTER_CENTER) + .default_pos(ctx.screen_rect().center()) + .collapsible(false) + .resizable(true) + .min_width(600.0) + .max_width(800.0) + .max_height(ctx.screen_rect().height() * 0.8) + .auto_sized() + .show(ctx, |ui| { + ui.vertical(|ui| { + if self.verification_entries.is_empty() { + ui.colored_label(egui::Color32::GREEN, "✅ All applications are valid!"); + ui.separator(); + + ui.horizontal(|ui| { + if ui.button("Close").clicked() { + self.cancel_verification(); + } + }); + return; + } + + let total_missing: usize = self.verification_entries.iter().map(|e| e.missing_applications.len()).sum(); + let total_duplicates: usize = self.verification_entries.iter().map(|e| e.duplicate_applications.len()).sum(); + + let message = match (total_missing > 0, total_duplicates > 0) { + (true, true) => format!( + "Found {} MIME type(s) with issues: {} missing applications, {} duplicate applications. Choose actions below:", + self.verification_entries.len(), total_missing, total_duplicates + ), + (true, false) => format!( + "Found {} MIME type(s) with {} missing applications. Choose to remove or replace each missing application:", + self.verification_entries.len(), total_missing + ), + (false, true) => format!( + "Found {} MIME type(s) with {} duplicate applications. Select which duplicates to remove:", + self.verification_entries.len(), total_duplicates + ), + (false, false) => "No issues found.".to_string(), + }; + + ui.label(message); + ui.separator(); + + egui::ScrollArea::vertical() + .auto_shrink([false; 2]) + .show(ui, |ui| { + for verification_entry in &mut self.verification_entries { + let group_frame = egui::Frame::group(ui.style()) + .fill(ui.visuals().faint_bg_color) + .inner_margin(egui::Margin::same(8)); + + group_frame.show(ui, |ui| { + ui.vertical(|ui| { + ui.strong(&verification_entry.mime_type); + ui.separator(); + + if !verification_entry.missing_applications.is_empty() { + ui.label("Missing Applications:"); + for (i, missing_app) in verification_entry.missing_applications.iter().enumerate() { + ui.horizontal(|ui| { + if let Some(selected) = verification_entry.selected_for_removal.get_mut(i) { + ui.checkbox(selected, ""); + } + + ui.colored_label( + egui::Color32::from_rgb( + visuals.error_fg_color.r(), + visuals.error_fg_color.g(), + visuals.error_fg_color.b() + ), + missing_app.to_string() + ); + + let is_selected = *verification_entry.selected_for_removal.get(i).unwrap_or(&false); + + if is_selected { + let is_remove = matches!(verification_entry.replacement_applications.get(i), None | Some(None)); + + ui.horizontal(|ui| { + if ui.radio(is_remove, "Remove").clicked() { + if let Some(replacement) = verification_entry.replacement_applications.get_mut(i) { + *replacement = None; + } + } + + if ui.radio(!is_remove, "Replace with:").clicked() { + if let Some(replacement) = verification_entry.replacement_applications.get_mut(i) { + if replacement.is_none() && !self.available_applications.is_empty() { + *replacement = Some(self.available_applications[0].clone()); + if let Some(idx) = verification_entry.replacement_indices.get_mut(i) { + *idx = 0; + } + } + } + } + + if !is_remove && !self.available_applications.is_empty() { + let current_idx = *verification_entry.replacement_indices.get(i).unwrap_or(&0); + let current_idx = current_idx.min(self.available_applications.len().saturating_sub(1)); + + let selected_app = &self.available_applications[current_idx]; + + egui::ComboBox::from_id_salt(format!("replace_{}_{}", verification_entry.mime_type, i)) + .selected_text(selected_app) + .width(200.0) + .show_ui(ui, |ui| { + for (app_idx, app) in self.available_applications.iter().enumerate() { + if ui.selectable_value(&mut verification_entry.replacement_indices[i], app_idx, app).clicked() { + verification_entry.replacement_applications[i] = Some(app.clone()); + } + } + }); + } + }); + } else { + ui.label("(no action)"); + } + }); + ui.add_space(4.0); + } + } + + if !verification_entry.duplicate_applications.is_empty() { + if !verification_entry.missing_applications.is_empty() { + ui.separator(); + } + ui.label("Duplicate Applications:"); + for (i, duplicate_app) in verification_entry.duplicate_applications.iter().enumerate() { + ui.horizontal(|ui| { + if let Some(selected) = verification_entry.selected_duplicates_for_removal.get_mut(i) { + ui.checkbox(selected, ""); + } + + ui.colored_label( + egui::Color32::from_rgb(255, 165, 0), + format!("{duplicate_app} (duplicate)") + ); + + let is_selected = *verification_entry.selected_duplicates_for_removal.get(i).unwrap_or(&false); + if is_selected { + ui.label("→ Remove duplicates"); + } else { + ui.label("(keep duplicates)"); + } + }); + ui.add_space(4.0); + } + } + }); + }); + ui.add_space(8.0); + } + }); + + ui.separator(); + + ui.horizontal(|ui| { + let total_selected: usize = self.verification_entries + .iter() + .map(|entry| entry.selected_for_removal.iter().filter(|&&selected| selected).count()) + .sum(); + + let total_duplicates_selected: usize = self.verification_entries + .iter() + .map(|entry| entry.selected_duplicates_for_removal.iter().filter(|&&selected| selected).count()) + .sum(); + + let total_replacements: usize = self.verification_entries + .iter() + .map(|entry| { + entry.selected_for_removal.iter().zip(entry.replacement_applications.iter()) + .filter(|(selected, replacement)| **selected && replacement.is_some()) + .count() + }) + .sum(); + + let total_removals = total_selected - total_replacements + total_duplicates_selected; + + let button_text = if total_replacements > 0 && total_removals > 0 { + format!("Apply Changes ({total_replacements} replacements, {total_removals} removals)") + } else if total_replacements > 0 { + format!("Replace Applications ({total_replacements})") + } else if total_removals > 0 { + format!("Remove Applications ({total_removals})") + } else { + "Apply Changes (0 selected)".to_string() + }; + + if ui.button(button_text).clicked() { + self.clean_missing_applications(); + } + + if ui.button("Cancel").clicked() { + self.cancel_verification(); + } + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Select All").clicked() { + for entry in &mut self.verification_entries { + for selected in &mut entry.selected_for_removal { + *selected = true; + } + for selected in &mut entry.selected_duplicates_for_removal { + *selected = true; + } + } + } + + if ui.button("Select None").clicked() { + for entry in &mut self.verification_entries { + for selected in &mut entry.selected_for_removal { + *selected = false; + } + for selected in &mut entry.selected_duplicates_for_removal { + *selected = false; + } + } + } + }); + }); + }); + }); + } +}