initial commit

This commit is contained in:
candle 2025-07-09 18:45:45 -04:00
commit fdcf9be24e
15 changed files with 2324 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
Cargo.lock
/target
.c*

12
Cargo.toml Normal file
View File

@ -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"

25
LICENSE-MIT Normal file
View File

@ -0,0 +1,25 @@
Copyright (c) Filip Bicki <candle@lampnet.io>
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.

34
README.md Normal file
View File

@ -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... |

8
mrmime.desktop Normal file
View File

@ -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;

454
src/app.rs Normal file
View File

@ -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<MimeTypeEntry>,
pub(crate) display_entries: Vec<DisplayEntry>,
pub(crate) filtered_entries: Vec<DisplayEntry>,
pub(crate) search_filter: String,
pub(crate) last_search_filter: String,
pub(crate) loading: bool,
pub(crate) error_message: Option<String>,
pub(crate) mime_receiver: Option<mpsc::Receiver<Result<Vec<MimeTypeEntry>, String>>>,
pub(crate) editing_mime_type: Option<String>,
pub(crate) editing_applications: Vec<String>,
pub(crate) new_application: String,
pub(crate) show_edit_dialog: bool,
pub(crate) selected_mime_type: Option<String>,
pub(crate) available_applications: Vec<String>,
pub(crate) selected_app_index: usize,
pub(crate) current_theme: Theme,
pub(crate) last_applied_theme: Option<Theme>,
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<VerificationEntry>,
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);
}
}

23
src/main.rs Normal file
View File

@ -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()))),
)
}

801
src/mime_parsing.rs Normal file
View File

@ -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<String, Vec<String>>, // mime_type -> applications
}
#[derive(Debug, Deserialize)]
struct HandlrMimeEntry {
mime: String,
handlers: Vec<String>,
}
fn get_config_file_path() -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
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::<CustomMimeConfig>(&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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<Vec<MimeTypeEntry>, Box<dyn std::error::Error>> {
let mut mime_types = Vec::new();
let mut mime_map: HashMap<String, MimeTypeEntry> = 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<String, MimeTypeEntry>,
) -> Result<(), Box<dyn std::error::Error>> {
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<MimeTypeEntry, Box<dyn std::error::Error>> {
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<String> {
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<String> {
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<String> {
let start_tag = format!("<{element}");
let end_tag = format!("</{element}>");
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<String, MimeTypeEntry>,
) -> Result<(), Box<dyn std::error::Error>> {
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<String> = 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<String, MimeTypeEntry>,
) -> Result<(), Box<dyn std::error::Error>> {
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<String> {
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<Vec<String>, Box<dyn std::error::Error>> {
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<String>, new_apps: Vec<String>) {
for app in new_apps {
if !existing.contains(&app) {
existing.push(app);
}
}
}
fn integrate_alternative_handlers(mime_map: &mut HashMap<String, MimeTypeEntry>) {
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<String> {
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<HashMap<String, Vec<String>>, Box<dyn std::error::Error>> {
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::<Vec<HandlrMimeEntry>>(&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<HashMap<String, Vec<String>>, Box<dyn std::error::Error>> {
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<HashMap<String, Vec<String>>, Box<dyn std::error::Error>> {
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<HashMap<String, Vec<String>>, Box<dyn std::error::Error>> {
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<HashMap<String, Vec<String>>, Box<dyn std::error::Error>> {
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
}

198
src/theme.rs Normal file
View File

@ -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<egui::Visuals> {
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<egui::Visuals> {
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<egui::Color32> {
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<egui::Visuals> {
// 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
}
}

45
src/types.rs Normal file
View File

@ -0,0 +1,45 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MimeTypeEntry {
pub mime_type: String,
pub extensions: Vec<String>,
pub applications: Vec<String>,
pub icon: Option<String>,
}
#[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<String>,
pub selected_for_removal: Vec<bool>,
pub replacement_applications: Vec<Option<String>>,
pub replacement_indices: Vec<usize>,
pub duplicate_applications: Vec<String>,
pub selected_duplicates_for_removal: Vec<bool>,
}

4
src/ui.rs Normal file
View File

@ -0,0 +1,4 @@
pub mod edit_dialog;
pub mod help_dialog;
pub mod main_window;
pub mod verify_dialog;

132
src/ui/edit_dialog.rs Normal file
View File

@ -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();
}
});
});
});
});
}
}

54
src/ui/help_dialog.rs Normal file
View File

@ -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;
}
});
});
});
}
}

278
src/ui/main_window.rs Normal file
View File

@ -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<String> = None;
let mut mime_to_select: Option<String> = 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);
}
}
}

253
src/ui/verify_dialog.rs Normal file
View File

@ -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;
}
}
}
});
});
});
});
}
}