use iced::widget::{
Column, Space, button, column, container, row, scrollable, text, text_input,
};
use iced::{
Alignment, Background, Border, Color, Element, Font, Length, Padding, Point,
Shadow, Task, Theme, Vector, font, window,
};
use rfd::FileDialog;
use std::path::{Path, PathBuf};
use std::process::Command;
use univert::conversion_map;
const BG: Color = rgb(0xff, 0xff, 0xff);
const SURFACE1: Color = rgb(0xf5, 0xf5, 0xf3);
const SURFACE2: Color = rgb(0xfa, 0xfa, 0xf9);
const SURFACE3: Color = rgb(0xff, 0xff, 0xff);
const BORDER: Color = rgb(0xd8, 0xd6, 0xd1);
const BORDER_SOFT: Color = rgb(0xec, 0xec, 0xea);
const TEXT_MUTE: Color = rgb(0x9a, 0x9a, 0x94);
const TEXT_SOFT: Color = rgb(0x6b, 0x6b, 0x66);
const INK: Color = rgb(0x1a, 0x1a, 0x1a);
const ACCENT: Color = rgb(0x3a, 0x5b, 0xff);
const fn rgb(r: u8, g: u8, b: u8) -> Color {
Color {
r: r as f32 / 255.0,
g: g as f32 / 255.0,
b: b as f32 / 255.0,
a: 1.0,
}
}
const fn with_alpha(color: Color, a: f32) -> Color {
Color { a, ..color }
}
const fn pad(top: f32, right: f32, bottom: f32, left: f32) -> Padding {
Padding {
top,
right,
bottom,
left,
}
}
fn main() -> iced::Result {
fn theme_fn(_: &App) -> Theme {
Theme::Light
}
fn style_fn(_: &App, _: &Theme) -> iced::theme::Style {
iced::theme::Style {
background_color: BG,
text_color: INK,
}
}
fn boot() -> (App, Task<Message>) {
(App::default(), center_current_window())
}
iced::application(boot, App::update, App::view)
.title("Univert")
.theme(theme_fn)
.style(style_fn)
.default_font(Font {
weight: font::Weight::Semibold,
..Font::DEFAULT
})
.window_size(iced::Size::new(880.0, 720.0))
.centered()
.run()
}
fn center_current_window() -> Task<Message> {
window::latest().then(|id_opt| match id_opt {
None => Task::none(),
Some(id) => window::monitor_size(id).then(move |mon_opt| match mon_opt {
None => Task::none(),
Some(mon) => window::size(id).then(move |win| {
window::move_to(
id,
Point::new(
((mon.width - win.width) / 2.0).max(0.0),
((mon.height - win.height) / 2.0).max(0.0),
),
)
}),
}),
})
}
#[derive(Default)]
struct App {
input: Option<PathBuf>,
available_targets: Vec<String>,
target_ext: Option<String>,
output_path: String,
status: Status,
}
#[derive(Default)]
enum Status {
#[default]
Idle,
Success(PathBuf),
Error(String),
}
#[derive(Debug, Clone)]
enum Message {
PickInput,
TargetSelected(String),
OutputPathChanged(String),
BrowseOutput,
Convert,
Reset,
OpenOutput,
RevealOutput,
}
impl App {
fn update(&mut self, message: Message) {
match message {
Message::PickInput => {
if let Some(path) = FileDialog::new().pick_file() {
self.available_targets = targets_for(&path);
self.target_ext = self.available_targets.first().cloned();
self.output_path = match self.target_ext.as_deref() {
Some(ext) => {
path.with_extension(ext).to_string_lossy().into_owned()
}
None => String::new(),
};
self.input = Some(path);
self.status = Status::Idle;
}
}
Message::TargetSelected(ext) => {
if let Some(input) = &self.input {
self.output_path =
input.with_extension(&ext).to_string_lossy().into_owned();
}
self.target_ext = Some(ext);
self.status = Status::Idle;
}
Message::OutputPathChanged(path) => {
self.output_path = path;
self.status = Status::Idle;
}
Message::BrowseOutput => {
let mut dialog = FileDialog::new();
if let Some(input) = &self.input {
if let Some(dir) = input.parent() {
dialog = dialog.set_directory(dir);
}
if let Some(name) = input.file_name().and_then(|n| n.to_str()) {
let stem = Path::new(name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(name);
let default = match self.target_ext.as_deref() {
Some(ext) => format!("{stem}.{ext}"),
None => stem.to_string(),
};
dialog = dialog.set_file_name(&default);
}
}
if let Some(path) = dialog.save_file() {
self.output_path = path.to_string_lossy().into_owned();
self.status = Status::Idle;
}
}
Message::Convert => {
let Some(input) = self.input.clone() else {
self.status = Status::Error("No file selected".into());
return;
};
if self.output_path.is_empty() {
self.status = Status::Error("Output path is empty".into());
return;
}
let output = PathBuf::from(&self.output_path);
let opts = univert::Options {
force: true,
split: false,
};
match univert::convert(&input, &output, &opts) {
Ok(()) => self.status = Status::Success(output),
Err(e) => self.status = Status::Error(e.to_string()),
}
}
Message::Reset => {
self.status = Status::Idle;
}
Message::OpenOutput => {
if let Status::Success(path) = &self.status {
let _ = open_path(path);
}
}
Message::RevealOutput => {
if let Status::Success(path) = &self.status {
let _ = reveal_path(path);
}
}
}
}
fn view(&self) -> Element<'_, Message> {
let top_bar = container(
row![
row![
text("Univert").size(18).color(INK),
text(env!("UNIVERT_VERSION")).size(12).color(TEXT_MUTE),
]
.spacing(10)
.align_y(Alignment::Center),
Space::new().width(Length::Fill),
text(format_counts()).size(12).color(TEXT_MUTE),
]
.align_y(Alignment::Center)
.padding(Padding::from([0, 18])),
)
.height(Length::Fixed(44.0))
.width(Length::Fill)
.align_y(iced::alignment::Vertical::Center)
.style(|_| container::Style {
background: Some(Background::Color(SURFACE1)),
border: Border {
color: BORDER_SOFT,
width: 0.0,
radius: 0.0.into(),
},
text_color: Some(INK),
shadow: Shadow {
color: BORDER_SOFT,
offset: Vector::new(0.0, 1.0),
blur_radius: 0.0,
},
..Default::default()
});
let title_block = column![
container(text("Univert").size(32).color(INK)).center_x(Length::Fill),
container(text("Universal File Converter").size(13).color(TEXT_MUTE))
.center_x(Length::Fill)
.padding(pad(4.0, 0.0, 0.0, 0.0)),
container(
container(
Space::new()
.width(Length::Fixed(72.0))
.height(Length::Fixed(2.0))
)
.style(|_| container::Style {
background: Some(Background::Color(ACCENT)),
border: Border {
color: ACCENT,
width: 0.0,
radius: 1.0.into(),
},
..Default::default()
})
)
.center_x(Length::Fill)
.padding(pad(14.0, 0.0, 0.0, 0.0)),
];
let step_input = step_row(
"01",
"Input",
if self.input.is_some() {
StepState::Done
} else {
StepState::Active
},
self.view_step_input().into(),
);
let step_format_state = if self.input.is_none() {
StepState::Pending
} else if matches!(self.status, Status::Success(_)) {
StepState::Done
} else {
StepState::Active
};
let step_format = step_row(
"02",
"Output format",
step_format_state,
self.view_step_format().into(),
);
let step_path_state = if self.input.is_none() {
StepState::Pending
} else if matches!(self.status, Status::Success(_)) {
StepState::Done
} else {
StepState::Active
};
let step_path = step_row(
"03",
"Output path",
step_path_state,
self.view_step_path().into(),
);
let action: Element<'_, Message> = match &self.status {
Status::Success(_) => done_pill(),
_ => convert_button(
self.input.is_some()
&& self.target_ext.is_some()
&& !self.output_path.is_empty(),
),
};
let action_row = container(action)
.center_x(Length::Fill)
.padding(pad(16.0, 0.0, 0.0, 0.0));
let tail: Element<'_, Message> = match &self.status {
Status::Success(path) => container(result_panel(path))
.padding(pad(24.0, 0.0, 0.0, 0.0))
.into(),
Status::Error(msg) => container(error_panel(msg))
.padding(pad(24.0, 0.0, 0.0, 0.0))
.into(),
Status::Idle => Space::new().height(Length::Fixed(0.0)).into(),
};
let column_inner = column![
title_block,
Space::new().height(Length::Fixed(24.0)),
step_input,
step_format,
step_path,
action_row,
tail,
]
.width(Length::Fill);
let centered =
container(container(column_inner).max_width(640.0).width(Length::Fill))
.padding(pad(32.0, 40.0, 20.0, 40.0))
.center_x(Length::Fill);
let body = scrollable(centered).height(Length::Fill);
column![top_bar, body].width(Length::Fill).into()
}
fn view_step_input(&self) -> Column<'_, Message> {
let select_label = if self.input.is_some() {
"Change File"
} else {
"Select File"
};
let is_selected = self.input.is_some();
let select_btn = button(
container(text(select_label).size(15).color(if is_selected {
INK
} else {
BG
}))
.center_x(Length::Shrink)
.center_y(Length::Shrink),
)
.padding(Padding::from([9, 14]))
.on_press(Message::PickInput)
.style(move |_, status| {
let base_bg = if is_selected { SURFACE3 } else { INK };
let base_border = if is_selected { BORDER } else { INK };
let fg = if is_selected { INK } else { BG };
let bg = match status {
button::Status::Hovered | button::Status::Pressed => {
if is_selected {
SURFACE1
} else {
with_alpha(INK, 0.88)
}
}
_ => base_bg,
};
button::Style {
background: Some(Background::Color(bg)),
text_color: fg,
border: Border {
color: base_border,
width: 1.0,
radius: 8.0.into(),
},
shadow: Shadow::default(),
snap: false,
}
});
let rhs: Element<'_, Message> = if let Some(path) = &self.input {
file_tag(path)
} else {
text("or drop one anywhere in the window")
.size(15)
.color(TEXT_SOFT)
.into()
};
column![
row![
container(select_btn).width(Length::Shrink),
container(rhs).width(Length::Fill),
]
.spacing(12)
.align_y(Alignment::Center)
]
}
fn view_step_format(&self) -> Column<'_, Message> {
if self.input.is_none() {
return column![
text("Select a file to see available formats.")
.size(15)
.color(TEXT_MUTE)
];
}
if self.available_targets.is_empty() {
return column![
text("No conversions available for this file type.")
.size(15)
.color(TEXT_MUTE)
];
}
let mut pills = Vec::new();
for ext in &self.available_targets {
let selected = self.target_ext.as_deref() == Some(ext.as_str());
pills.push(format_pill(ext.clone(), selected).into());
}
let pill_row = wrap_row(pills, 6.0, 6.0);
column![
pill_row,
Space::new().height(Length::Fixed(10.0)),
text(format!(
"{} available formats",
self.available_targets.len()
))
.size(12)
.color(TEXT_MUTE),
]
}
fn view_step_path(&self) -> Column<'_, Message> {
if self.input.is_none() {
return column![
text("Set automatically once a file and format are chosen.")
.size(15)
.color(TEXT_MUTE)
];
}
column![path_field(&self.output_path)]
}
}
#[derive(Clone, Copy)]
enum StepState {
Pending,
Active,
Done,
}
fn step_row<'a>(
index: &'static str,
label: &'static str,
state: StepState,
body: Element<'a, Message>,
) -> Element<'a, Message> {
let (dot_bg, dot_border, dot_fg, label_color) = match state {
StepState::Done => (ACCENT, ACCENT, Color::WHITE, INK),
StepState::Active => (ACCENT, ACCENT, Color::WHITE, INK),
StepState::Pending => (SURFACE3, BORDER, TEXT_MUTE, TEXT_MUTE),
};
let glyph: Element<'_, Message> = match state {
StepState::Done => text("✓").size(14).color(dot_fg).into(),
_ => text(index).size(12).color(dot_fg).into(),
};
let dot = container(container(glyph).center_x(32.0).center_y(32.0))
.width(Length::Fixed(32.0))
.height(Length::Fixed(32.0))
.style(move |_| container::Style {
background: Some(Background::Color(dot_bg)),
border: Border {
color: dot_border,
width: 1.0,
radius: 14.0.into(),
},
text_color: Some(dot_fg),
..Default::default()
});
let right = column![
text(label).size(12).color(TEXT_MUTE),
Space::new().height(Length::Fixed(10.0)),
container(body).width(Length::Fill),
];
let content = row![
container(column![dot])
.width(Length::Fixed(48.0))
.padding(pad(2.0, 0.0, 0.0, 0.0)),
container(right).width(Length::Fill),
]
.spacing(16);
container(content)
.padding(pad(18.0, 0.0, 18.0, 0.0))
.width(Length::Fill)
.style(move |_| container::Style {
background: None,
border: Border {
color: BORDER_SOFT,
width: 0.0,
radius: 0.0.into(),
},
text_color: Some(label_color),
shadow: Shadow {
color: BORDER_SOFT,
offset: Vector::new(0.0, 1.0),
blur_radius: 0.0,
},
..Default::default()
})
.into()
}
fn file_tag(path: &Path) -> Element<'_, Message> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("?")
.to_uppercase();
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let dir = path
.parent()
.and_then(|p| p.to_str())
.unwrap_or("")
.to_string();
let size = std::fs::metadata(path).ok().map(|m| m.len());
let badge = container(text(ext).size(12).color(TEXT_SOFT))
.width(Length::Fixed(38.0))
.height(Length::Fixed(38.0))
.center_x(Length::Fixed(38.0))
.center_y(Length::Fixed(38.0))
.style(|_| container::Style {
background: Some(Background::Color(BG)),
border: Border {
color: BORDER,
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
});
let sub = match size {
Some(n) => format!("{dir} · {}", format_bytes(n)),
None => dir.clone(),
};
let info = column![
text(name).size(15).color(INK),
text(sub).size(12).color(TEXT_MUTE),
]
.spacing(2);
container(
row![badge, container(info).width(Length::Fill),]
.spacing(10)
.align_y(Alignment::Center),
)
.padding(pad(6.0, 10.0, 6.0, 8.0))
.style(|_| container::Style {
background: Some(Background::Color(SURFACE3)),
border: Border {
color: BORDER_SOFT,
width: 1.0,
radius: 8.0.into(),
},
..Default::default()
})
.into()
}
fn format_pill<'a>(
ext: String,
selected: bool,
) -> iced::widget::Button<'a, Message> {
let label_color = if selected { BG } else { TEXT_SOFT };
let label = format!(".{ext}");
let msg_ext = ext.clone();
button(text(label).size(14).color(label_color))
.padding(Padding::from([7, 11]))
.on_press(Message::TargetSelected(msg_ext))
.style(move |_, status| {
let (bg, border_color) = if selected {
(INK, INK)
} else {
match status {
button::Status::Hovered => (SURFACE1, INK),
_ => (SURFACE3, BORDER),
}
};
button::Style {
background: Some(Background::Color(bg)),
text_color: label_color,
border: Border {
color: border_color,
width: 1.0,
radius: 7.0.into(),
},
shadow: Shadow::default(),
snap: false,
}
})
}
fn wrap_row<'a>(
items: Vec<Element<'a, Message>>,
col_gap: f32,
row_gap: f32,
) -> Element<'a, Message> {
let chunk_size = 8;
let mut rows: Vec<Element<'a, Message>> = Vec::new();
let mut buffer: Vec<Element<'a, Message>> = Vec::new();
for item in items.into_iter() {
buffer.push(item);
if buffer.len() == chunk_size {
let taken = std::mem::take(&mut buffer);
rows.push(
row(taken)
.spacing(col_gap)
.align_y(Alignment::Center)
.into(),
);
}
}
if !buffer.is_empty() {
rows.push(
row(buffer)
.spacing(col_gap)
.align_y(Alignment::Center)
.into(),
);
}
column(rows).spacing(row_gap).into()
}
fn path_field(value: &str) -> Element<'_, Message> {
let input = text_input("", value)
.on_input(Message::OutputPathChanged)
.size(14)
.padding(Padding::from([9, 0]))
.style(|_, _| text_input::Style {
background: Background::Color(Color::TRANSPARENT),
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 0.0.into(),
},
icon: TEXT_MUTE,
placeholder: TEXT_MUTE,
value: INK,
selection: with_alpha(ACCENT, 0.25),
});
let browse = button(text("Browse…").size(13).color(TEXT_SOFT))
.padding(Padding::from([6, 10]))
.on_press(Message::BrowseOutput)
.style(|_, status| {
let bg = match status {
button::Status::Hovered => SURFACE1,
_ => Color::TRANSPARENT,
};
button::Style {
background: Some(Background::Color(bg)),
text_color: TEXT_SOFT,
border: Border {
color: BORDER_SOFT,
width: 1.0,
radius: 6.0.into(),
},
shadow: Shadow::default(),
snap: false,
}
});
container(
row![
container(input).width(Length::Fill),
container(browse).padding(pad(0.0, 0.0, 0.0, 6.0)),
]
.align_y(Alignment::Center),
)
.padding(pad(2.0, 4.0, 2.0, 10.0))
.style(|_| container::Style {
background: Some(Background::Color(SURFACE3)),
border: Border {
color: BORDER,
width: 1.0,
radius: 8.0.into(),
},
..Default::default()
})
.into()
}
fn convert_button<'a>(enabled: bool) -> Element<'a, Message> {
let fg = if enabled { Color::WHITE } else { TEXT_MUTE };
let content = row![
text("Convert").size(16).color(fg),
text("→").size(16).color(with_alpha(fg, 0.7)),
]
.spacing(8)
.align_y(Alignment::Center);
let mut btn = button(
container(content)
.center_x(Length::Shrink)
.center_y(Length::Shrink),
)
.padding(Padding::from([0, 24]))
.width(Length::Fixed(220.0))
.height(Length::Fixed(50.0))
.style(move |_, status| {
let (bg, border_color) = if !enabled {
(SURFACE2, BORDER)
} else {
match status {
button::Status::Hovered => (with_alpha(ACCENT, 0.92), ACCENT),
button::Status::Pressed => (with_alpha(ACCENT, 0.85), ACCENT),
_ => (ACCENT, ACCENT),
}
};
button::Style {
background: Some(Background::Color(bg)),
text_color: fg,
border: Border {
color: border_color,
width: 1.0,
radius: 10.0.into(),
},
shadow: if enabled {
Shadow {
color: with_alpha(Color::BLACK, 0.12),
offset: Vector::new(0.0, 1.0),
blur_radius: 0.0,
}
} else {
Shadow::default()
},
snap: false,
}
});
if enabled {
btn = btn.on_press(Message::Convert);
}
btn.into()
}
fn done_pill<'a>() -> Element<'a, Message> {
let content = row![
text("✓").size(15).color(ACCENT),
text("Converted").size(15).color(ACCENT),
]
.spacing(10)
.align_y(Alignment::Center);
container(content)
.padding(Padding::from([12, 18]))
.style(|_| container::Style {
background: Some(Background::Color(with_alpha(ACCENT, 0.08))),
border: Border {
color: ACCENT,
width: 1.0,
radius: 10.0.into(),
},
..Default::default()
})
.into()
}
fn result_panel(path: &Path) -> Element<'_, Message> {
let path_str = path.to_string_lossy().into_owned();
let header = column![
text("Converted to").size(12).color(TEXT_MUTE),
Space::new().height(Length::Fixed(6.0)),
text(path_str).size(14).color(INK),
];
let open_btn = button(text("Open file").size(14).color(Color::WHITE))
.padding(Padding::from([8, 14]))
.on_press(Message::OpenOutput)
.style(|_, status| {
let bg = match status {
button::Status::Hovered => with_alpha(ACCENT, 0.92),
button::Status::Pressed => with_alpha(ACCENT, 0.85),
_ => ACCENT,
};
button::Style {
background: Some(Background::Color(bg)),
text_color: Color::WHITE,
border: Border {
color: ACCENT,
width: 1.0,
radius: 8.0.into(),
},
shadow: Shadow {
color: with_alpha(Color::BLACK, 0.12),
offset: Vector::new(0.0, 1.0),
blur_radius: 0.0,
},
snap: false,
}
});
let reveal_btn = ghost_button(reveal_button_label(), Message::RevealOutput);
let again_btn = ghost_button("Convert again", Message::Reset);
let actions = row![
open_btn,
reveal_btn,
Space::new().width(Length::Fill),
again_btn,
]
.spacing(8)
.align_y(Alignment::Center);
let divider =
container(Space::new().width(Length::Fill).height(Length::Fixed(1.0)))
.style(|_| container::Style {
background: Some(Background::Color(BORDER)),
..Default::default()
});
container(
column![
header,
Space::new().height(Length::Fixed(14.0)),
divider,
Space::new().height(Length::Fixed(10.0)),
actions,
]
.width(Length::Fill),
)
.padding(pad(18.0, 18.0, 16.0, 18.0))
.width(Length::Fill)
.style(|_| container::Style {
background: Some(Background::Color(SURFACE2)),
border: Border {
color: BORDER_SOFT,
width: 1.0,
radius: 12.0.into(),
},
..Default::default()
})
.into()
}
fn error_panel(msg: &str) -> Element<'_, Message> {
container(
column![
text("Error").size(12).color(rgb(0xc8, 0x5d, 0x4a)),
Space::new().height(Length::Fixed(6.0)),
text(msg.to_string()).size(15).color(INK),
]
.width(Length::Fill),
)
.padding(Padding::from([14, 16]))
.width(Length::Fill)
.style(|_| container::Style {
background: Some(Background::Color(with_alpha(
rgb(0xc8, 0x5d, 0x4a),
0.06,
))),
border: Border {
color: rgb(0xc8, 0x5d, 0x4a),
width: 1.0,
radius: 10.0.into(),
},
..Default::default()
})
.into()
}
fn ghost_button<'a>(
label: &'a str,
msg: Message,
) -> iced::widget::Button<'a, Message> {
button(text(label).size(14).color(TEXT_SOFT))
.padding(Padding::from([8, 14]))
.on_press(msg)
.style(|_, status| {
let bg = match status {
button::Status::Hovered => SURFACE1,
_ => SURFACE3,
};
button::Style {
background: Some(Background::Color(bg)),
text_color: TEXT_SOFT,
border: Border {
color: BORDER,
width: 1.0,
radius: 8.0.into(),
},
shadow: Shadow::default(),
snap: false,
}
})
}
fn format_counts() -> String {
use std::collections::HashSet;
let mut inputs: HashSet<&'static str> = HashSet::new();
let mut outputs: HashSet<&'static str> = HashSet::new();
for (f, t) in conversion_map::list_all() {
inputs.insert(f);
outputs.insert(t);
}
format!(
"{} Input Formats • {} Output Formats",
inputs.len(),
outputs.len()
)
}
fn targets_for(input: &Path) -> Vec<String> {
let Some(from) = input.extension().and_then(|e| e.to_str()) else {
return Vec::new();
};
let from = univert::normalize_extension(from);
let mut targets: Vec<String> = conversion_map::list_all()
.into_iter()
.filter(|(f, _)| *f == from)
.map(|(_, t)| t.to_string())
.collect();
targets.sort();
targets.dedup();
targets
}
fn format_bytes(n: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = 1024 * KB;
const GB: u64 = 1024 * MB;
if n < KB {
format!("{n} B")
} else if n < MB {
format!("{:.1} KB", n as f64 / KB as f64)
} else if n < GB {
format!("{:.1} MB", n as f64 / MB as f64)
} else {
format!("{:.2} GB", n as f64 / GB as f64)
}
}
fn reveal_button_label() -> &'static str {
#[cfg(target_os = "macos")]
{
"Show in Finder"
}
#[cfg(target_os = "windows")]
{
"Show in Explorer"
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
{
"Open Containing Folder"
}
}
fn open_path(path: &Path) -> std::io::Result<()> {
#[cfg(target_os = "macos")]
{
Command::new("open").arg(path).status().map(|_| ())
}
#[cfg(target_os = "windows")]
{
Command::new("cmd")
.args(["/C", "start", ""])
.arg(path)
.status()
.map(|_| ())
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
{
Command::new("xdg-open").arg(path).status().map(|_| ())
}
}
fn reveal_path(path: &Path) -> std::io::Result<()> {
#[cfg(target_os = "macos")]
{
Command::new("open")
.args(["-R"])
.arg(path)
.status()
.map(|_| ())
}
#[cfg(target_os = "windows")]
{
Command::new("explorer")
.arg(format!("/select,{}", path.display()))
.status()
.map(|_| ())
}
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
{
let dir = path.parent().unwrap_or(path);
Command::new("xdg-open").arg(dir).status().map(|_| ())
}
}