use std::cell::RefCell;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::time::SystemTime;
use rust_i18n::t;
use crate::{ button, column, container, flex, icon_button, list_item, row, scroll, separator, text, text_edit };
use crate::{ ButtonVariant, Color, Element, ImageData, Length };
use super::places::{ self, Place, PlaceKind };
use super::{ FileChooser, FileChooserMsg, Item, Purpose, SortKey, FOLDER_NAME, NAME };
const ROW_H: f32 = 44.0;
const ROW_H_SUB: f32 = 56.0;
const TOOL: f32 = 40.0;
const TOOL_ICON: f32 = 22.0;
const RASTER_PX: u32 = 48;
const UP: &str = "general/up";
const CLOSE: &str = "navigation/close";
const NEW_FOLDER: &str = "actions/add";
const PLACES: &str = "navigation/menu-burger";
const FOLDER: &str = "archives/folder-files";
const ASCENDING: &str = "general/up-simple";
const DESCENDING: &str = "general/down-simple";
thread_local!
{
static ICONS: RefCell<HashMap<( &'static str, [ u8; 4 ] ), Option<ImageData>>> = RefCell::new( HashMap::new() );
}
pub( crate ) fn icon( name: &'static str, tint: Color ) -> Option<ImageData>
{
let channel = |v: f32| ( v.clamp( 0.0, 1.0 ) * 255.0 ) as u8;
let key = ( name, [ channel( tint.r ), channel( tint.g ), channel( tint.b ), channel( tint.a ) ] );
ICONS.with( |icons| icons.borrow_mut().entry( key ).or_insert_with( || crate::theme_icon_tinted( name, RASTER_PX, tint ) ).clone() )
}
pub( super ) fn view<Msg: Clone + Send + 'static>( chooser: &FileChooser<Msg> ) -> Element<Msg>
{
let palette = crate::theme::palette();
let mut page = column()
.padding( 0.0 )
.spacing( 0.0 )
.push( container( header( chooser ) ).background( palette.surface_alt ) )
.push( separator() );
if chooser.sort_open { page = page.push( sort_bar( chooser ) ).push( separator() ); }
page = page.push( flex( body( chooser ) ) );
if let Some( notice ) = &chooser.notice { page = page.push( notice_bar( chooser, notice ) ); }
if let Some( name ) = &chooser.new_folder { page = page.push( folder_bar( chooser, name ) ); }
if let Some( target ) = &chooser.replacing
{
page = page.push( replace_bar( chooser, target ) );
} else if chooser.purpose == Purpose::Save {
page = page.push( save_bar( chooser ) );
}
container( page ).background( palette.bg ).into()
}
fn header<Msg: Clone + Send + 'static>( chooser: &FileChooser<Msg> ) -> Element<Msg>
{
let palette = crate::theme::palette();
let title = match chooser.purpose
{
Purpose::Open => t!( "file_chooser.open" ),
Purpose::Save => t!( "file_chooser.save" ),
};
let mut bar = row().padding( Length::widget( 4.0 ) ).spacing( Length::widget( 4.0 ) );
if chooser.compact
{
bar = bar.push( tool( chooser, PLACES, t!( "file_chooser.places" ).to_string(), Some( FileChooserMsg::TogglePlaces ) ) );
}
let titles = column()
.padding( 0.0 )
.spacing( 0.0 )
.align_center_x( false )
.push( text( title.to_string() ).size( 15.0 ).weight( 600 ).color( palette.text_primary ) )
.push( text( chooser.dir.display().to_string() ).size( 12.0 ).color( palette.text_secondary ) );
bar = bar
.push( tool( chooser, UP, t!( "file_chooser.up" ).to_string(), chooser.parent().map( |_| FileChooserMsg::Up ) ) )
.push( flex( titles ) )
.push( tool( chooser, sort_icon( chooser.sort.key ), t!( "file_chooser.sort" ).to_string(), Some( FileChooserMsg::ToggleSort ) ) );
if chooser.purpose == Purpose::Save
{
bar = bar.push( tool( chooser, NEW_FOLDER, t!( "file_chooser.new_folder" ).to_string(), Some( FileChooserMsg::NewFolder ) ) );
}
bar.push( tool( chooser, CLOSE, t!( "file_chooser.cancel" ).to_string(), Some( FileChooserMsg::Cancel ) ) ).into()
}
fn body<Msg: Clone + Send + 'static>( chooser: &FileChooser<Msg> ) -> Element<Msg>
{
if chooser.compact
{
return if chooser.places_open { place_list( chooser ) } else { listing( chooser ) };
}
let side = container( place_list( chooser ) ).background( crate::theme::palette().surface_alt );
row()
.spacing( 0.0 )
.fill_height()
.push( flex( side ).weight( 1 ) )
.push( flex( listing( chooser ) ).weight( 3 ) )
.into()
}
fn place_list<Msg: Clone + Send + 'static>( chooser: &FileChooser<Msg> ) -> Element<Msg>
{
let palette = crate::theme::palette();
let mut list = column().padding( Length::widget( 4.0 ) ).spacing( 0.0 );
for place in places::standard()
{
let selected = chooser.dir == place.path;
let label = place_label( &place );
let glyph = place_icon( place.kind );
list = list.push( entry( chooser, label, glyph, palette.icon, selected, None, None, FileChooserMsg::Enter( place.path ) ) );
}
if crate::files::mounts().is_some()
{
list = list.push( entry( chooser, t!( "file_chooser.mount" ).to_string(), NEW_FOLDER, palette.icon, false, None, None, FileChooserMsg::Mount ) );
}
scroll( list ).into()
}
fn listing<Msg: Clone + Send + 'static>( chooser: &FileChooser<Msg> ) -> Element<Msg>
{
let palette = crate::theme::palette();
let mut list = column().padding( Length::widget( 4.0 ) ).spacing( 0.0 );
if chooser.loading
{
list = list.push( note( "…".to_string() ) );
} else if let Some( error ) = &chooser.error {
list = list.push( note( error.clone() ) );
} else if chooser.items.is_empty() {
list = list.push( note( t!( "file_chooser.empty" ).to_string() ) );
}
let chosen = chooser.name.trim();
for item in &chooser.items
{
let ( msg, tint ) = if item.is_dir
{
( FileChooserMsg::Enter( item.path.clone() ), palette.accent )
} else {
( FileChooserMsg::Pick( item.path.clone() ), palette.icon )
};
let time = if chooser.sort.key == SortKey::Accessed { item.accessed } else { item.modified };
let when = time.map( date );
let weight = ( !item.is_dir ).then( || size( item.size ) );
let ( below, beside ) = if chooser.compact
{
let both = [ weight, when ].into_iter().flatten().collect::<Vec<_>>().join( " · " );
( ( !both.is_empty() ).then_some( both ), None )
} else {
( when, weight )
};
let selected = chooser.purpose == Purpose::Save && !item.is_dir && item.name == chosen;
list = list.push( entry( chooser, item.name.clone(), item_icon( item ), tint, selected, below, beside, msg ) );
}
scroll( list ).into()
}
#[ allow( clippy::too_many_arguments ) ]
fn entry<Msg: Clone + Send + 'static>( chooser: &FileChooser<Msg>, label: String, glyph: &'static str, tint: Color, selected: bool, below: Option<String>, beside: Option<String>, msg: FileChooserMsg ) -> Element<Msg>
{
let palette = crate::theme::palette();
let mut item = list_item( label )
.height( Length::widget( if below.is_some() { ROW_H_SUB } else { ROW_H } ) )
.ink( palette.text_primary, palette.text_secondary )
.selected( selected )
.on_press( chooser.msg( msg ) );
if let Some( below ) = below { item = item.subtitle( below ); }
if let Some( beside ) = beside { item = item.trailing( beside ); }
if let Some( ( rgba, w, h ) ) = icon( glyph, tint ) { item = item.icon( rgba, w, h ); }
item.into()
}
fn tool<Msg: Clone + Send + 'static>( chooser: &FileChooser<Msg>, glyph: &'static str, label: String, msg: Option<FileChooserMsg> ) -> Element<Msg>
{
let palette = crate::theme::palette();
let tint = if msg.is_some() { palette.icon } else { palette.divider };
tinted_tool( chooser, glyph, label, msg, tint )
}
fn tinted_tool<Msg: Clone + Send + 'static>( chooser: &FileChooser<Msg>, glyph: &'static str, label: String, msg: Option<FileChooserMsg>, tint: Color ) -> Element<Msg>
{
let base = match icon( glyph, tint )
{
Some( ( rgba, w, h ) ) => icon_button( rgba, w, h ).icon_size( Length::widget( TOOL_ICON ) ),
None => button( label.clone() ),
};
base.variant( ButtonVariant::Tertiary )
.tooltip( label )
.width( Length::widget( TOOL ) )
.height( Length::widget( TOOL ) )
.on_press_maybe( msg.map( |msg| chooser.msg( msg ) ) )
.into()
}
fn sort_bar<Msg: Clone + Send + 'static>( chooser: &FileChooser<Msg> ) -> Element<Msg>
{
let palette = crate::theme::palette();
let sort = chooser.sort;
let mut list = column().padding( Length::widget( 4.0 ) ).spacing( 0.0 );
for key in [ SortKey::Name, SortKey::Accessed, SortKey::Size ]
{
let descending = sort.directions.descending( key );
let label = match key
{
SortKey::Name => t!( "file_chooser.sort_name" ),
SortKey::Accessed => t!( "file_chooser.sort_accessed" ),
SortKey::Size => t!( "file_chooser.sort_size" ),
}.to_string();
let arrow = |glyph: &'static str, down: bool, tooltip: String|
{
let tint = if descending == down { palette.accent } else { palette.icon };
tinted_tool( chooser, glyph, tooltip, Some( FileChooserMsg::SetSort( sort.with_direction( key, down ) ) ), tint )
};
let choice = entry( chooser, label, sort_icon( key ), palette.icon, sort.key == key, None, None, FileChooserMsg::SetSort( sort.select( key ) ) );
list = list.push( row()
.spacing( 0.0 )
.push( flex( choice ) )
.push( arrow( ASCENDING, false, t!( "file_chooser.ascending" ).to_string() ) )
.push( arrow( DESCENDING, true, t!( "file_chooser.descending" ).to_string() ) ) );
}
list.into()
}
fn save_bar<Msg: Clone + Send + 'static>( chooser: &FileChooser<Msg> ) -> Element<Msg>
{
let wrap = Arc::clone( &chooser.wrap );
let name = text_edit( t!( "file_chooser.name" ).to_string(), chooser.name.clone() )
.id( NAME )
.on_change( move |name| wrap( FileChooserMsg::Name( name ) ) )
.on_submit( chooser.msg( FileChooserMsg::Confirm ) );
let save = button( t!( "file_chooser.save_button" ).to_string() )
.variant( ButtonVariant::Primary )
.on_press_maybe( chooser.target().map( |_| chooser.msg( FileChooserMsg::Confirm ) ) );
row().padding( Length::widget( 8.0 ) ).spacing( Length::widget( 8.0 ) ).push( flex( name ) ).push( save ).into()
}
fn folder_bar<Msg: Clone + Send + 'static>( chooser: &FileChooser<Msg>, name: &str ) -> Element<Msg>
{
let wrap = Arc::clone( &chooser.wrap );
let field = text_edit( t!( "file_chooser.folder_name" ).to_string(), name.to_string() )
.id( FOLDER_NAME )
.on_change( move |name| wrap( FileChooserMsg::FolderName( name ) ) )
.on_submit( chooser.msg( FileChooserMsg::CreateFolder ) );
let create = button( t!( "file_chooser.create" ).to_string() )
.variant( ButtonVariant::Secondary )
.on_press_maybe( chooser.new_folder_path().map( |_| chooser.msg( FileChooserMsg::CreateFolder ) ) );
row()
.padding( Length::widget( 8.0 ) )
.spacing( Length::widget( 8.0 ) )
.push( flex( field ) )
.push( create )
.push( tool( chooser, CLOSE, t!( "file_chooser.cancel" ).to_string(), Some( FileChooserMsg::CancelFolder ) ) )
.into()
}
fn replace_bar<Msg: Clone + Send + 'static>( chooser: &FileChooser<Msg>, target: &Path ) -> Element<Msg>
{
let palette = crate::theme::palette();
let name = target.file_name().map( |name| name.to_string_lossy().into_owned() ).unwrap_or_default();
let question = text( t!( "file_chooser.replace_question", name = name ).to_string() ).size( 14.0 ).color( palette.text_primary ).wrap( true );
let keep = button( t!( "file_chooser.keep" ).to_string() ).variant( ButtonVariant::Secondary ).on_press( chooser.msg( FileChooserMsg::KeepFile ) );
let replace = button( t!( "file_chooser.replace" ).to_string() ).variant( ButtonVariant::Primary ).on_press( chooser.msg( FileChooserMsg::Replace ) );
row().padding( Length::widget( 8.0 ) ).spacing( Length::widget( 8.0 ) ).push( flex( question ) ).push( keep ).push( replace ).into()
}
fn notice_bar<Msg: Clone + Send + 'static>( chooser: &FileChooser<Msg>, notice: &str ) -> Element<Msg>
{
let palette = crate::theme::palette();
let bar = row()
.padding( Length::widget( 4.0 ) )
.push( flex( text( notice.to_string() ).size( 13.0 ).color( palette.text_primary ).wrap( true ) ) )
.push( tool( chooser, CLOSE, t!( "file_chooser.close" ).to_string(), Some( FileChooserMsg::CloseNotice ) ) );
container( bar ).background( palette.surface_alt ).into()
}
fn note<Msg: Clone + 'static>( message: String ) -> Element<Msg>
{
container( text( message ).size( 13.0 ).color( crate::theme::palette().text_secondary ).wrap( true ) ).padding( Length::widget( 12.0 ) ).into()
}
fn sort_icon( key: SortKey ) -> &'static str
{
match key
{
SortKey::Name => "archives/document-text",
SortKey::Accessed => "features/clock",
SortKey::Size => "hardware/harddisk",
}
}
fn date( time: SystemTime ) -> String
{
chrono::DateTime::<chrono::Local>::from( time ).format( "%Y-%m-%d %H:%M" ).to_string()
}
fn place_label( place: &Place ) -> String
{
if let Some( name ) = &place.name { return name.clone(); }
match place.kind
{
PlaceKind::Home => t!( "file_chooser.home" ),
PlaceKind::Desktop => t!( "file_chooser.desktop" ),
PlaceKind::Documents => t!( "file_chooser.documents" ),
PlaceKind::Downloads => t!( "file_chooser.downloads" ),
PlaceKind::Music => t!( "file_chooser.music" ),
PlaceKind::Pictures => t!( "file_chooser.pictures" ),
PlaceKind::Videos => t!( "file_chooser.videos" ),
PlaceKind::Root => t!( "file_chooser.root" ),
PlaceKind::Mount => t!( "file_chooser.folder" ),
}.to_string()
}
fn place_icon( kind: PlaceKind ) -> &'static str
{
match kind
{
PlaceKind::Home => "navigation/home",
PlaceKind::Desktop => "hardware/external-monitor",
PlaceKind::Documents => "archives/document-text",
PlaceKind::Downloads => "archives/folder-downloads",
PlaceKind::Music => "archives/folder-music",
PlaceKind::Pictures => "archives/folder-photos",
PlaceKind::Videos => "archives/folder-videos",
PlaceKind::Root => "hardware/harddisk",
PlaceKind::Mount => FOLDER,
}
}
fn item_icon( item: &Item ) -> &'static str
{
if item.is_dir { return FOLDER; }
let mime = item.mime.unwrap_or( "" );
let ( top, sub ) = mime.split_once( '/' ).unwrap_or( ( mime, "" ) );
match top
{
"image" => "archives/image",
"video" => "archives/video",
"audio" => "archives/audio",
"text" => "archives/document-text",
_ if sub == "pdf" => "archives/pdf",
_ if sub.contains( "zip" ) || sub.contains( "tar" ) || sub.contains( "compressed" ) => "archives/compressed-file",
_ => "archives/document",
}
}
pub( crate ) fn size( bytes: u64 ) -> String
{
const UNITS: [ &str; 4 ] = [ "KB", "MB", "GB", "TB" ];
if bytes < 1024 { return format!( "{bytes} B" ); }
let mut value = bytes as f64 / 1024.0;
let mut unit = 0;
while value >= 1024.0 && unit + 1 < UNITS.len()
{
value /= 1024.0;
unit += 1;
}
if value < 10.0 { format!( "{value:.1} {}", UNITS[ unit ] ) } else { format!( "{value:.0} {}", UNITS[ unit ] ) }
}