use crate::core::{Color, HorizontalAlignment, Rect, Size};
use crate::event::key_codes;
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::{GenericSignal, Signal1};
use crate::tr;
use crate::impl_widget_property_hooks;
use crate::property_names_of;
use crate::widget::capability::coercion::{expect_bool, expect_string};
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::metrics::{dimensions, effective_font, ControlMetrics};
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileDialogMode {
OpenFile,
OpenFiles,
SaveFile,
SelectDirectory,
}
#[derive(Debug, Clone)]
pub struct FileFilter {
pub description: String,
pub extensions: Vec<String>,
}
impl FileFilter {
pub fn new(description: impl Into<String>, extensions: Vec<impl Into<String>>) -> Self {
Self {
description: description.into(),
extensions: extensions.into_iter().map(|e| e.into()).collect(),
}
}
pub fn all_files() -> Self {
Self::new(tr!("dialog.file_dialog.all_files_filter"), vec!["*"])
}
}
impl std::fmt::Display for FileFilter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let exts: Vec<String> = self.extensions.iter().map(|e| format!("*.{e}")).collect();
write!(f, "{} ({})", self.description, exts.join(" "))
}
}
const LIST_TOP_GAP: i32 = 10;
const SEL_H: i32 = 22;
const SELECTED_STRIP_GAP: i32 = 10;
pub struct FileDialog {
base: BaseWidget,
mode: FileDialogMode,
title: String,
directory: String,
selected_files: Vec<String>,
name_filters: Vec<FileFilter>,
current_filter: usize,
modal: bool,
pub files_selected: Signal1<Vec<String>>,
pub file_selected: Signal1<String>,
pub current_changed: Signal1<String>,
pub accepted: GenericSignal,
pub rejected: GenericSignal,
}
impl FileDialog {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::FileDialog, geometry, "FileDialog"),
mode: FileDialogMode::OpenFile,
title: tr!("dialog.file_dialog.open_file"),
directory: String::new(),
selected_files: Vec::new(),
name_filters: vec![FileFilter::all_files()],
current_filter: 0,
modal: true,
files_selected: Signal1::new(),
file_selected: Signal1::new(),
current_changed: Signal1::new(),
accepted: GenericSignal::new(),
rejected: GenericSignal::new(),
}
}
pub fn is_modal(&self) -> bool {
self.modal
}
pub fn set_modal(&mut self, modal: bool) {
self.modal = modal;
self.base.request_redraw();
}
pub fn open_file(geometry: Rect) -> Self {
let mut d = Self::new(geometry);
d.mode = FileDialogMode::OpenFile;
d.title = tr!("dialog.file_dialog.open_file");
d
}
pub fn save_file(geometry: Rect) -> Self {
let mut d = Self::new(geometry);
d.mode = FileDialogMode::SaveFile;
d.title = tr!("dialog.file_dialog.save_file");
d
}
pub fn mode(&self) -> FileDialogMode {
self.mode
}
pub fn title(&self) -> &str {
&self.title
}
pub fn directory(&self) -> &str {
&self.directory
}
pub fn selected_files(&self) -> &[String] {
&self.selected_files
}
pub fn selected_file(&self) -> Option<&str> {
self.selected_files.first().map(|s| s.as_str())
}
pub fn name_filters(&self) -> &[FileFilter] {
&self.name_filters
}
pub fn current_filter(&self) -> Option<&FileFilter> {
self.name_filters.get(self.current_filter)
}
pub fn set_mode(&mut self, mode: FileDialogMode) {
self.mode = mode;
self.title = tr!(match mode {
FileDialogMode::OpenFile | FileDialogMode::OpenFiles => "dialog.file_dialog.open_file",
FileDialogMode::SaveFile => "dialog.file_dialog.save_file",
FileDialogMode::SelectDirectory => "dialog.file_dialog.select_directory",
});
self.base.request_redraw();
}
pub fn set_title(&mut self, title: impl Into<String>) {
self.title = title.into();
self.base.request_redraw();
}
pub fn set_directory(&mut self, dir: impl Into<String>) {
self.directory = dir.into();
self.base.request_redraw();
}
pub fn set_name_filters(&mut self, filters: Vec<FileFilter>) {
self.name_filters = filters;
self.current_filter = 0;
self.base.request_redraw();
}
pub fn select_file(&mut self, path: impl Into<String>) {
let path = path.into();
self.selected_files = vec![path.clone()];
self.file_selected.emit(path);
}
pub fn clear_selection(&mut self) {
if self.selected_files.is_empty() {
return;
}
self.selected_files.clear();
self.base.request_redraw();
}
pub fn set_current_file(&mut self, path: impl Into<String>) {
self.current_changed.emit(path.into());
}
pub fn accept(&mut self) {
if !self.selected_files.is_empty() {
self.files_selected.emit(self.selected_files.clone());
}
self.accepted.emit();
self.hide();
}
pub fn reject(&mut self) {
self.selected_files.clear();
self.rejected.emit();
self.hide();
}
}
impl Widget for FileDialog {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> Size {
crate::core::Size::new(500, 400)
}
fn as_draw_mut(&mut self) -> Option<&mut dyn crate::widget::Draw> {
Some(self)
}
impl_widget_property_hooks!();
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"file_selected" => {
Some(EventSignalRef::mapped("file_selected", &self.file_selected, |v| {
CapabilityValue::String(v.clone())
}))
}
"files_selected" => {
Some(EventSignalRef::mapped("files_selected", &self.files_selected, |v| {
let mut items = crate::compat::Vec::with_capacity(v.len());
for path in v.iter() {
items.push(CapabilityValue::String(path.clone()));
}
CapabilityValue::Tuple(items)
}))
}
"accepted" => Some(EventSignalRef::unit("accepted", &self.accepted)),
"rejected" => Some(EventSignalRef::unit("rejected", &self.rejected)),
"current_changed" => {
Some(EventSignalRef::mapped("current_changed", &self.current_changed, |v| {
CapabilityValue::String(v.clone())
}))
}
_ => None,
}
}
}
impl WidgetProperties for FileDialog {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"title" => Ok(CapabilityValue::String(self.title().to_string())),
"modal" => Ok(CapabilityValue::Bool(self.is_modal())),
"mode" => Ok(CapabilityValue::String(file_dialog_mode_token(self.mode()).into())),
"directory" => Ok(CapabilityValue::String(self.directory().to_string())),
"selected_file" => {
Ok(CapabilityValue::String(self.selected_file().unwrap_or_default().to_string()))
}
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"title" => {
self.set_title(expect_string(value)?);
Ok(())
}
"modal" => {
self.set_modal(expect_bool(value)?);
Ok(())
}
"mode" => {
let token = expect_string(value)?;
let mode =
file_dialog_mode_from_token(&token).ok_or(CapabilityAccessError::OutOfRange)?;
self.set_mode(mode);
Ok(())
}
"directory" => {
self.set_directory(expect_string(value)?);
Ok(())
}
"selected_file" => match value {
CapabilityValue::Null => {
self.clear_selection();
Ok(())
}
other => {
self.select_file(expect_string(other)?);
self.base.request_redraw();
Ok(())
}
},
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"title",
"modal",
"mode",
"directory",
"selected_file",
BASE_PROPERTY_NAMES
]
}
fn property_tokens(&self, name: &str) -> &'static [&'static str] {
match name {
"mode" => FILE_DIALOG_MODE_TOKENS,
_ => &[],
}
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"open" => {
self.accept();
Ok(())
}
"set_mode" | "set_directory" => Err(CapabilityAccessError::OutOfRange),
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
impl EventHandler for FileDialog {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
if let Event::KeyPress { key, .. } = event {
if *key == key_codes::ENTER {
self.accept();
} else if *key == key_codes::ESCAPE {
self.reject();
}
}
}
}
const FILE_DIALOG_MODE_TOKENS: &[&str] =
&["open_file", "open_files", "save_file", "select_directory"];
fn file_dialog_mode_token(mode: FileDialogMode) -> &'static str {
match mode {
FileDialogMode::OpenFile => "open_file",
FileDialogMode::OpenFiles => "open_files",
FileDialogMode::SaveFile => "save_file",
FileDialogMode::SelectDirectory => "select_directory",
}
}
fn file_dialog_mode_from_token(token: &str) -> Option<FileDialogMode> {
match token {
"open_file" => Some(FileDialogMode::OpenFile),
"open_files" => Some(FileDialogMode::OpenFiles),
"save_file" => Some(FileDialogMode::SaveFile),
"select_directory" => Some(FileDialogMode::SelectDirectory),
_ => None,
}
}
impl FileDialog {
fn frame_rect(&self) -> Rect {
ControlMetrics::painted_box(
self.base.geometry(),
Size::new(dimensions::DIALOG_MIN_WIDTH, dimensions::DIALOG_MIN_HEIGHT),
)
}
}
impl Draw for FileDialog {
fn draw(&mut self, context: &mut RenderContext) {
if self.modal {
super::draw_modal_scrim(context, self.geometry());
}
let rect = self.frame_rect();
let style = self.style().clone();
let font = effective_font(&style);
let theme = crate::style::resolved_theme_style("file_dialog");
let (window_fill, accent) = {
let manager = crate::style::theme_manager();
match manager.current_theme() {
Some(active) => (active.colors.background, active.colors.primary),
None => (Color::WHITE, Color::rgb(0, 120, 215)),
}
};
let accent_ink = accent.contrast_color();
let ink = style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(Color::BLACK);
let surface = match style
.background_color
.or_else(|| theme.as_ref().and_then(|t| t.background_color))
{
Some(resolved) if resolved != window_fill => resolved,
_ => window_fill.blend(&ink, 0.06),
};
let border = style
.border_color
.or_else(|| theme.as_ref().and_then(|t| t.border_color))
.unwrap_or(Color::rgb(160, 160, 160));
let field = if surface.is_dark() {
surface.blend(&Color::WHITE, 0.08)
} else {
surface.blend(&Color::BLACK, 0.06)
};
let radius = dimensions::DIALOG_RADIUS.min(rect.width / 2).min(rect.height / 2);
if radius > 0 {
context.fill_rounded_rect(rect, radius, surface);
context.draw_rounded_rect_stroke(rect, radius, border, 1);
} else {
context.face_with_gradient(
rect,
surface,
self.style().background_gradient.as_ref(),
self.style().surface.unwrap_or_default(),
self.style().border_radius.unwrap_or(0),
Color::BLACK,
);
context.draw_rect(rect, border);
}
let title_bar_band = ControlMetrics::top_band(rect, dimensions::DIALOG_TITLE_BAR_HEIGHT);
context.fill_rect(title_bar_band, accent);
if !self.title.is_empty() {
let title_font = font;
let title_line = context.text_line(title_bar_band, title_font);
context.draw_text_fitted(
Rect::new(
rect.x + 8,
title_line.y,
rect.width.saturating_sub(16),
title_line.height.max(1),
),
&self.title,
title_font,
accent_ink,
HorizontalAlignment::Left,
);
}
let button_band = ControlMetrics::bottom_band(rect, dimensions::DIALOG_BUTTON_HEIGHT);
let button_top = button_band.y;
let list_y = rect.y + dimensions::DIALOG_TITLE_BAR_HEIGHT as i32 + LIST_TOP_GAP;
let placeholder_line_h = context.measure_text("M", font).height.max(1) as i32;
let list_min_h = placeholder_line_h + 4;
let reserved_below = SEL_H + SELECTED_STRIP_GAP;
let list_h = if button_top - list_y >= list_min_h {
(button_top - reserved_below - list_y).max(list_min_h) as u32
} else {
(button_top - list_y).max(0) as u32
};
let list_rect = Rect::new(rect.x + 10, list_y, rect.width.saturating_sub(20), list_h);
if list_rect.height > 0 {
context.fill_rect(list_rect, field);
context.draw_rect(list_rect, border);
}
let placeholder = tr!("dialog.file_dialog.file_list_placeholder");
let placeholder_font = font;
if list_rect.height > 0 && !placeholder.is_empty() {
let placeholder_band = Rect::new(
list_rect.x + 6,
list_rect.y,
list_rect.width.saturating_sub(12),
list_rect.height,
);
let placeholder_line = context.text_line(placeholder_band, placeholder_font);
context.draw_text_fitted(
Rect::new(
placeholder_band.x,
placeholder_line.y,
placeholder_band.width.max(1),
placeholder_line.height.max(1),
),
&placeholder,
placeholder_font,
ink.blend(&field, 0.5).legible_on(field, 4.5),
HorizontalAlignment::Left,
);
}
let sel_h = SEL_H;
let sel_y = button_top - sel_h - SELECTED_STRIP_GAP;
let sel_fits = sel_y >= list_y + list_h as i32 + 4;
if sel_fits {
let sel_label = tr!("dialog.file_dialog.file_name");
let sel_label_font = font;
let sel_band = Rect::new(rect.x + 10, sel_y, 66, sel_h as u32);
let sel_line = context.text_line(sel_band, sel_label_font);
context.draw_text_fitted(
Rect::new(sel_band.x, sel_line.y, sel_band.width, sel_line.height.max(1)),
&sel_label,
sel_label_font,
ink,
HorizontalAlignment::Left,
);
let fname = self.selected_file().unwrap_or("");
let fname_rect =
Rect::new(rect.x + 80, sel_y, rect.width.saturating_sub(90), sel_h as u32);
context.fill_rect(fname_rect, field);
context.draw_rect(fname_rect, border);
if !fname.is_empty() {
let fname_font = font;
let fname_band = Rect::new(
fname_rect.x + 4,
fname_rect.y,
fname_rect.width.saturating_sub(8),
fname_rect.height,
);
let fname_line = context.text_line(fname_band, fname_font);
context.draw_text_fitted(
fname_line,
fname,
fname_font,
ink,
HorizontalAlignment::Left,
);
}
} else {
let fname = self.selected_file().unwrap_or("");
let fname_font = font;
let band = Rect::new(
list_rect.x + 6,
list_rect.y,
list_rect.width.saturating_sub(12),
list_rect.height,
);
if !fname.is_empty() && band.height > 0 {
let line = context.text_line(band, fname_font);
context.draw_text_fitted(line, fname, fname_font, ink, HorizontalAlignment::Left);
}
}
let ok_label = if self.mode == FileDialogMode::SaveFile {
tr!("common.button.save")
} else {
tr!("common.button.open")
};
let labels = vec![ok_label, tr!("common.button.cancel")];
let row =
super::message_box::action_row_geometry(context, font, &labels, button_band, true);
let ok_rect = row.buttons[0];
context.fill_rect(ok_rect, accent);
context.draw_text_line(ok_rect, &labels[0], font, accent_ink, HorizontalAlignment::Center);
let cancel_rect = row.buttons[1];
context.fill_rect(cancel_rect, surface.blend(&ink, 0.1));
context.draw_rect(cancel_rect, border);
context.draw_text_line(cancel_rect, &labels[1], font, ink, HorizontalAlignment::Center);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::Event;
use std::sync::{Arc, Mutex};
#[test]
fn select_file_updates_selection_and_emits_signal() {
let mut dialog = FileDialog::new(Rect::new(0, 0, 420, 280));
let selected = Arc::new(Mutex::new(String::new()));
let selected_clone = Arc::clone(&selected);
dialog.file_selected.connect(move |path| {
if let Ok(mut v) = selected_clone.lock() {
*v = (*path).clone();
}
});
dialog.select_file("/tmp/demo.txt");
assert_eq!(dialog.selected_file(), Some("/tmp/demo.txt"));
assert_eq!(*selected.lock().expect("selected lock"), "/tmp/demo.txt");
}
#[test]
fn a_mounted_dialog_accepts_a_selection_written_through_the_property_route() {
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
let mut dialog = FileDialog::new(Rect::new(0, 0, 420, 280));
assert_eq!(dialog.selected_file(), None);
let seen = Arc::new(Mutex::new(Vec::<String>::new()));
let seen_clone = Arc::clone(&seen);
dialog.file_selected.connect(move |path| {
if let Ok(mut v) = seen_clone.lock() {
v.push((*path).clone());
}
});
dialog
.set("selected_file", CapabilityValue::String("/tmp/picked.txt".to_string()))
.expect("a host-owned list must be able to write the chosen path");
assert_eq!(dialog.selected_file(), Some("/tmp/picked.txt"));
assert_eq!(
*seen.lock().expect("selected lock"),
vec!["/tmp/picked.txt".to_string()],
"the write must announce itself the same way the setter does"
);
dialog.set("selected_file", CapabilityValue::Null).expect("clearing is a valid write");
assert_eq!(dialog.selected_file(), None);
assert!(dialog.is_visible(), "clearing the selection must not dismiss the dialog");
assert_eq!(
dialog.set("selected_file", CapabilityValue::UInt(3)),
Err(CapabilityAccessError::TypeMismatch)
);
}
#[test]
fn enter_accepts_and_escape_rejects() {
let mut dialog = FileDialog::new(Rect::new(0, 0, 420, 280));
let accepted = Arc::new(Mutex::new(0usize));
let rejected = Arc::new(Mutex::new(0usize));
let a = Arc::clone(&accepted);
dialog.accepted.connect(move || {
if let Ok(mut n) = a.lock() {
*n += 1;
}
});
let r = Arc::clone(&rejected);
dialog.rejected.connect(move || {
if let Ok(mut n) = r.lock() {
*n += 1;
}
});
dialog.select_file("/tmp/a.txt");
dialog.show();
dialog.handle_event(&Event::key_press(13, 0));
assert_eq!(*accepted.lock().expect("accepted lock"), 1);
assert!(!dialog.is_visible());
dialog.show();
dialog.select_file("/tmp/b.txt");
dialog.handle_event(&Event::key_press(27, 0));
assert_eq!(*rejected.lock().expect("rejected lock"), 1);
assert!(dialog.selected_files().is_empty());
assert!(!dialog.is_visible());
}
#[test]
fn set_current_file_emits_current_changed_without_touching_the_selection() {
let mut dialog = FileDialog::new(Rect::new(0, 0, 420, 280));
let seen = Arc::new(Mutex::new(Vec::<String>::new()));
let sink = Arc::clone(&seen);
dialog.current_changed.connect(move |path| {
if let Ok(mut v) = sink.lock() {
v.push((*path).clone());
}
});
dialog.set_current_file("/tmp/hovered.txt");
dialog.set_current_file("/tmp/other.txt");
assert_eq!(
*seen.lock().expect("seen lock"),
vec!["/tmp/hovered.txt".to_string(), "/tmp/other.txt".to_string()]
);
assert_eq!(dialog.selected_file(), None);
}
}