initial commit
This commit is contained in:
commit
fdcf9be24e
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
Cargo.lock
|
||||
/target
|
||||
.c*
|
||||
12
Cargo.toml
Normal file
12
Cargo.toml
Normal 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
25
LICENSE-MIT
Normal 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
34
README.md
Normal 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
8
mrmime.desktop
Normal 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
454
src/app.rs
Normal 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
23
src/main.rs
Normal 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
801
src/mime_parsing.rs
Normal 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
198
src/theme.rs
Normal 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
45
src/types.rs
Normal 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
4
src/ui.rs
Normal 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
132
src/ui/edit_dialog.rs
Normal 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
54
src/ui/help_dialog.rs
Normal 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
278
src/ui/main_window.rs
Normal 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
253
src/ui/verify_dialog.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user