diff --git a/examples/gio-list.rs b/examples/gio-list.rs new file mode 100644 index 0000000..a83a0d6 --- /dev/null +++ b/examples/gio-list.rs @@ -0,0 +1,31 @@ +use gio::prelude::*; +use std::env; + +fn main() { + let uri = env::args().nth(1).expect("no uri provided"); + let file = gio::File::for_uri(&uri); + for entry_res in file + .enumerate_children("*", gio::FileQueryInfoFlags::NONE, gio::Cancellable::NONE) + .unwrap() + { + let entry = entry_res.unwrap(); + println!("{:?}", entry.display_name()); + for attribute in entry.list_attributes(None) { + println!( + " {:?}: {:?}", + attribute, + entry.attribute_as_string(&attribute) + ); + } + + //TODO: what is the best way to resolve shortcuts? + let child = if let Some(target_uri) = + entry.attribute_string(gio::FILE_ATTRIBUTE_STANDARD_TARGET_URI) + { + gio::File::for_uri(&target_uri) + } else { + file.child(entry.name()) + }; + println!("{:?}", child.uri()); + } +} diff --git a/examples/mount.rs b/examples/gio-mount.rs similarity index 100% rename from examples/mount.rs rename to examples/gio-mount.rs diff --git a/i18n/en/cosmic_files.ftl b/i18n/en/cosmic_files.ftl index cd3a0f9..77b53b5 100644 --- a/i18n/en/cosmic_files.ftl +++ b/i18n/en/cosmic_files.ftl @@ -9,6 +9,7 @@ notification-in-progress = File operations are in progress. trash = Trash recents = Recents undo = Undo +today = Today # List view name = Name @@ -235,4 +236,4 @@ sort-z-a = Z-A sort-newest-first = Newest first sort-oldest-first = Oldest first sort-smallest-to-largest = Smallest to largest -sort-largest-to-smallest = Largest to smallest \ No newline at end of file +sort-largest-to-smallest = Largest to smallest diff --git a/i18n/fr/cosmic_files.ftl b/i18n/fr/cosmic_files.ftl index 7bfee3d..24f9b26 100644 --- a/i18n/fr/cosmic_files.ftl +++ b/i18n/fr/cosmic_files.ftl @@ -4,9 +4,9 @@ empty-folder-hidden = Dossier vide (contient des éléments cachés) no-results = Aucun résultat trouvé filesystem = Système de fichiers home = Dossier personnel -notification-in-progress = Les opérations sur les fichiers sont en cours. +notification-in-progress = Des opérations sur des fichiers sont en cours. trash = Corbeille -recents = Récente +recents = Récents undo = Annuler # List view @@ -77,6 +77,31 @@ execute = Exécution ## About git-description = Git commit {$hash} le {$date} +## Add Network Drive +add-network-drive = Ajouter un lecteur réseau +connect = Connecter +connect-anonymously = Connecter anonymement +connecting = Connection en cours... +domain = Domaine +enter-server-address = Entrez l'adresse du serveur +network-drive-description = + Les adresses de serveur incluent un préfixe de protocole et une adresse. + Exemples: ssh://192.168.0.1, ftp://[2001:db8::1] +### Make sure to keep the comma which separates the columns +network-drive-schemes = + Protocoles disponibles,Préfixe + AppleTalk,afp:// + File Transfer Protocol,ftp:// or ftps:// + Network File System,nfs:// + Server Message Block,smb:// + SSH File Transfer Protocol,sftp:// or ssh:// + WebDav,dav:// or davs:// +network-drive-error = Impossible d'accéder au lecteur réseau +password = Mot de passe +remember-password = Se souvenir du mot de passe +try-again = Essayer à nouveau +username = Nom d'utilisateur + ## Operations edit-history = Modifier l'historique history = Historique @@ -203,3 +228,12 @@ show-hidden-files = Afficher les fichiers cachés list-directories-first = Lister les répertoires en premier menu-settings = Paramètres... menu-about = À propos de Fichiers COSMIC... + +## Sort +sort = Trier +sort-a-z = A-Z +sort-z-a = Z-A +sort-newest-first = Le plus récent en premier +sort-oldest-first = Le plus ancien en premier +sort-smallest-to-largest = Du plus petit au plus grand +sort-largest-to-smallest = Du plus grand au plus petit diff --git a/i18n/pl/cosmic_files.ftl b/i18n/pl/cosmic_files.ftl index 267cd76..472c45e 100644 --- a/i18n/pl/cosmic_files.ftl +++ b/i18n/pl/cosmic_files.ftl @@ -4,8 +4,10 @@ empty-folder-hidden = Pusty katalog (z ukrytymi plikami) no-results = Brak wyników filesystem = System plików home = Katalog Domowy +networks = sieci notification-in-progress = Operacje na plikach w toku. trash = Kosz +recents = Ubiegłe undo = Cofnij # List view @@ -62,11 +64,44 @@ apply-to-all = Zastosuj do wszystkich keep-both = Zachowaj oba skip = Pomiń +## Metadata Dialog +owner = Właściciel +group = Grupa +other = Inni +read = Odczyt +write = Zapis +execute = Wykonywanie + # Context Pages ## About git-description = Git commit {$hash} z {$date} +## Add Network Drive +add-network-drive = Dodaj dysk sieciowy +connect = Połącz +connect-anonymously = Połącz anonimowo +connecting = Łączenie... +domain = Domena +enter-server-address = Wprowadź adres serwera +network-drive-description = + Adres serwera zawiera prefiks protokołu i adres. + Przykładowo: ssh://192.168.0.1, ftp://[2001:db8::1] +### Make sure to keep the comma which separates the columns +network-drive-schemes = + Dostępne protokoły,Prefiks + AppleTalk,afp:// + File Transfer Protocol,ftp:// or ftps:// + Network File System,nfs:// + Server Message Block,smb:// + SSH File Transfer Protocol,sftp:// or ssh:// + WebDav,dav:// or davs:// +network-drive-error = Brak dostępu do dysku sieciowego +password = Hasło +remember-password = Zapamiętaj hasło +try-again = Spróbuj ponownie +username = Nazwa użytkownika + ## Operations edit-history = Edytuj historię history = Historia @@ -200,11 +235,6 @@ show-hidden-files = Pokaż ukryte pliki list-directories-first = Najpierw wyświetlaj katalogi menu-settings = Ustawienia... menu-about = O Plikach COSMIC... -list-view = Widok listy -show-hidden-files = Pokaż ukryte pliki -list-directories-first = Najpierw wyświetlaj katalogi -menu-settings = Ustawienia... -menu-about = O Plikach COSMIC... ## Sort sort = Uszereguj @@ -212,5 +242,5 @@ sort-a-z = A-Z sort-z-a = Z-A sort-newest-first = Najpierw najnowsze sort-oldest-first = Najpierw najstarsze -sort-smallest-to-largest = Od najmniejszego do największego -sort-largest-to-smallest = Od największego do najmniejszego +sort-smallest-to-largest = Najpierw najmniejsze +sort-largest-to-smallest = Najpierw największe diff --git a/i18n/sv/cosmic_files.ftl b/i18n/sv/cosmic_files.ftl index 9c25347..fac61b1 100644 --- a/i18n/sv/cosmic_files.ftl +++ b/i18n/sv/cosmic_files.ftl @@ -2,7 +2,11 @@ empty-folder = Tom katalog empty-folder-hidden = Tom katalog (har dolda objekt) filesystem = Filsystem home = Hem +networks = Nätverk trash = Papperskorg +recents = Senaste +undo = Ångra +today = Idag # Dialog cancel = Avbryt @@ -27,6 +31,15 @@ properties = Egenskaper ## Settings settings = Inställningar +settings-tab = Flik +settings-show-hidden = Visa dolda filer +default-view = Standardvy +icon-size-list = Ikonstorlek (lista) +icon-size-grid = Ikonstorlek (rutnät) +sorting-name = Sortera efter +direction = Riktning +ascending = Stigande +descending = Fallande ### Appearance appearance = Utseende @@ -78,3 +91,39 @@ default-app = {$name} (standard) ## Show details show-details = Visa detaljer + + +network-drive-description = + Serveradresser inkluderar ett protokollprefix och en adress. + Exempel: ssh://192.168.0.1, ftp://[2001:db8::1] +### Se till att behålla kommatecken som skiljer kolumnerna åt +network-drive-schemes = + Tillgängliga protokoll, Prefix + AppleTalk,afp:// + Filöverföringsprotokoll,ftp:// eller ftps:// + Nätverksfilsystem,nfs:// + Servermeddelandeblock,smb:// + SSH-filöverföringsprotokoll,sftp:// eller ssh:// + WebDav,dav:// eller davs:// +network-drive-error = Kan inte komma åt nätverksenheten +password = Lösenord +remember-password = Kom ihåg lösenord + +## Lägg till en Nätverksenhet +add-network-drive = Lägg till en Nätverksenhet +connect = Anslut +connect-anonymously = Anslut anonymt +connecting = Ansluter... +domain = Domän +enter-server-address = Ange server address +try-again = Försök igen +username = Användarnamn + +## Sort +sort = Sortera +sort-a-z = A-Z +sort-z-a = Z-A +sort-newest-first = Nyaste först +sort-oldest-first = Äldst först +sort-smallest-to-largest = Minsta till största +sort-largest-to-smallest = Största till minsta diff --git a/src/app.rs b/src/app.rs index 86542c3..e164fd4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -521,11 +521,14 @@ impl App { location: Location, selection_path: Option, ) -> Command { + let mounters = self.mounters.clone(); let icon_sizes = self.config.tab.icon_sizes; Command::perform( async move { let location2 = location.clone(); - match tokio::task::spawn_blocking(move || location2.scan(icon_sizes)).await { + match tokio::task::spawn_blocking(move || location2.scan(mounters, icon_sizes)) + .await + { Ok(items) => { message::app(Message::TabRescan(entity, location, items, selection_path)) } @@ -591,7 +594,7 @@ impl App { if let Some(ref items) = tab.items_opt() { for item in items.iter() { if item.selected { - if let Some(path) = &item.path_opt { + if let Some(Location::Path(path)) = &item.location_opt { paths.push(path.clone()); } } @@ -672,7 +675,10 @@ impl App { .size(16) .handle(), )) - .data(Location::Networks) + .data(Location::Network( + "network:///".to_string(), + fl!("networks"), + )) .divider_above() }); @@ -941,49 +947,35 @@ impl App { fn properties(&self, entity: Option) -> Element { match entity { None => self.tab_properties(self.tab_model.active()), - Some(ContextItem::TabBar(entity)) => self.tab_properties(entity), - Some(ContextItem::NavBar(item)) => { - let mut children = Vec::new(); - + let mut children = Vec::with_capacity(1); if let Some(location) = self.nav_model.data::(item) { if let Location::Path(path) = location { - let parent = path.parent().unwrap_or(path); - - for item in Location::Path(parent.to_owned()).scan(IconSizes::default()) { - if item.path_opt.as_deref() == Some(path) { - children.push(item.property_view(IconSizes::default())); - } + //TODO: this should be done once, not when generating the view! + if let Ok(item) = tab::item_from_path(path, self.config.tab.icon_sizes) { + children.push(item.property_view(IconSizes::default())); } - }; + } } - widget::settings::view_column(children).into() } Some(ContextItem::BreadCrumbs(index)) => { - let mut children = Vec::new(); - + let mut children = Vec::with_capacity(1); if let Some(tab) = self.tab_model.active_data::() { - let path = match tab.location { - Location::Path(ref path) => Some(path), - Location::Search(ref path, _) => Some(path), - _ => None, - } - .and_then(|path| path.ancestors().nth(index)) - .map(|path| path.to_path_buf()); - if let Some(ref path) = path { - let parent = path.parent().unwrap_or(path); - - for item in Location::Path(parent.to_owned()).scan(IconSizes::default()) { - if item.path_opt.as_deref() == Some(path) { - children.push(item.property_view(IconSizes::default())); - } + let path_opt = tab + .location + .path_opt() + .and_then(|path| path.ancestors().nth(index)) + .map(|path| path.to_path_buf()); + if let Some(ref path) = path_opt { + //TODO: this should be done once, not when generating the view! + if let Ok(item) = tab::item_from_path(path, self.config.tab.icon_sizes) { + children.push(item.property_view(IconSizes::default())); } }; } - widget::settings::view_column(children).into() } } @@ -1772,9 +1764,7 @@ impl Application for App { //TODO: this could be further optimized by looking at what exactly changed if let Some(items) = &mut tab.items_opt { for item in items.iter_mut() { - if item.path_opt.as_ref() - == Some(event_path) - { + if item.path_opt() == Some(event_path) { //TODO: reload more, like mime types? match fs::metadata(&event_path) { Ok(new_metadata) => match &mut item @@ -1836,7 +1826,7 @@ impl Application for App { if let Some(items) = tab.items_opt() { for item in items.iter() { if item.selected { - if let Some(path) = &item.path_opt { + if let Some(Location::Path(path)) = &item.location_opt { paths.push(path.clone()); } } @@ -2036,7 +2026,7 @@ impl Application for App { let mut selected = Vec::new(); for item in items.iter() { if item.selected { - if let Some(path) = &item.path_opt { + if let Some(Location::Path(path)) = &item.location_opt { selected.push(path.clone()); } } @@ -2398,11 +2388,14 @@ impl Application for App { self.toasts.remove(id); let mut paths = Vec::with_capacity(recently_trashed.len()); + let mounters = self.mounters.clone(); let icon_sizes = self.config.tab.icon_sizes; return cosmic::command::future(async move { - match tokio::task::spawn_blocking(move || Location::Trash.scan(icon_sizes)) - .await + match tokio::task::spawn_blocking(move || { + Location::Trash.scan(mounters, icon_sizes) + }) + .await { Ok(items) => { for path in &*recently_trashed { @@ -3551,6 +3544,7 @@ pub(crate) mod test_utils { use crate::{ config::{IconSizes, TabConfig}, + mounter::MounterMap, tab::Item, }; @@ -3713,7 +3707,7 @@ pub(crate) mod test_utils { // New tab with items let location = Location::Path(path.to_owned()); - let items = location.scan(IconSizes::default()); + let items = location.scan(Mounters::new(MounterMap::new()), IconSizes::default()); let mut tab = Tab::new(location, TabConfig::default()); tab.set_items(items); @@ -3748,7 +3742,7 @@ pub(crate) mod test_utils { name == item.name && is_dir == item.metadata.is_dir() - && path == item.path_opt.as_ref().expect("item should have path") + && path == item.path_opt().expect("item should have path") && is_hidden == item.hidden } @@ -3768,6 +3762,19 @@ pub(crate) mod test_utils { ); } + pub fn assert_zoom_affects_item_size(tab: &mut Tab, message: tab::Message, should_zoom: bool) { + let grid_icon_size = tab.config.icon_sizes.grid; + let list_icon_size = tab.config.icon_sizes.list; + + debug!("Emitting {:?}", message); + tab.update(message, Modifiers::empty()); + + let grid_size_changed = grid_icon_size != tab.config.icon_sizes.grid; + let list_size_changed = list_icon_size != tab.config.icon_sizes.list; + + assert_eq!(grid_size_changed || list_size_changed, should_zoom); + } + /// Assert that tab's items are equal to a path's entries. pub fn assert_eq_tab_path_contents(tab: &Tab, path: &Path) { let Location::Path(ref tab_path) = tab.location else { diff --git a/src/dialog.rs b/src/dialog.rs index ced9257..30d203a 100644 --- a/src/dialog.rs +++ b/src/dialog.rs @@ -376,10 +376,12 @@ struct App { impl App { fn rescan_tab(&self) -> Command { let location = self.tab.location.clone(); + let mounters = self.mounters.clone(); let icon_sizes = self.tab.config.icon_sizes; Command::perform( async move { - match tokio::task::spawn_blocking(move || location.scan(icon_sizes)).await { + match tokio::task::spawn_blocking(move || location.scan(mounters, icon_sizes)).await + { Ok(items) => message::app(Message::TabRescan(items)), Err(err) => { log::warn!("failed to rescan: {}", err); @@ -895,7 +897,7 @@ impl Application for App { } } } - DialogPage::Replace { filename } => { + DialogPage::Replace { .. } => { return self.update(Message::Save(true)); } } @@ -999,7 +1001,7 @@ impl Application for App { //TODO: this could be further optimized by looking at what exactly changed if let Some(items) = &mut self.tab.items_opt { for item in items.iter_mut() { - if item.path_opt.as_ref() == Some(event_path) { + if item.path_opt() == Some(event_path) { //TODO: reload more, like mime types? match fs::metadata(&event_path) { Ok(new_metadata) => { @@ -1049,7 +1051,7 @@ impl Application for App { if let Some(items) = self.tab.items_opt() { for item in items.iter() { if item.selected { - if let Some(path) = &item.path_opt { + if let Some(Location::Path(path)) = &item.location_opt { paths.push(path.clone()); let _ = update_recently_used( &path.clone(), diff --git a/src/lib.rs b/src/lib.rs index f23d24e..0f201d7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,10 @@ mod spawn_detached; use tab::Location; pub mod tab; +pub(crate) fn err_str(err: T) -> String { + err.to_string() +} + pub fn home_dir() -> PathBuf { match dirs::home_dir() { Some(home) => home, diff --git a/src/menu.rs b/src/menu.rs index 86b1d08..dd64abb 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -197,8 +197,22 @@ pub fn context_menu<'a>( children.push(sort_item(fl!("sort-by-size"), HeadingOptions::Size)); } } - (_, Location::Networks) => { - //TODO: networks context menu? + (_, Location::Network(_, _)) => { + if selected > 0 { + if selected_dir == 1 && selected == 1 || selected_dir == 0 { + children.push(menu_item(fl!("open"), Action::Open).into()); + } + } else { + if tab.mode.multiple() { + children.push(menu_item(fl!("select-all"), Action::SelectAll).into()); + } + if !children.is_empty() { + children.push(divider::horizontal::light().into()); + } + children.push(sort_item(fl!("sort-by-name"), HeadingOptions::Name)); + children.push(sort_item(fl!("sort-by-modified"), HeadingOptions::Modified)); + children.push(sort_item(fl!("sort-by-size"), HeadingOptions::Size)); + } } (_, Location::Trash) => { if tab.mode.multiple() { diff --git a/src/mounter/gvfs.rs b/src/mounter/gvfs.rs index 7ad7b4c..210a16b 100644 --- a/src/mounter/gvfs.rs +++ b/src/mounter/gvfs.rs @@ -3,10 +3,15 @@ use cosmic::{ widget, Command, }; use gio::{glib, prelude::*}; -use std::{any::TypeId, future::pending, path::PathBuf, sync::Arc}; +use std::{any::TypeId, cell::Cell, future::pending, path::PathBuf, sync::Arc}; use tokio::sync::{mpsc, Mutex}; use super::{Mounter, MounterAuth, MounterItem, MounterItems, MounterMessage}; +use crate::{ + config::IconSizes, + err_str, + tab::{self, ItemMetadata, ItemThumbnail, Location}, +}; fn gio_icon_to_path(icon: &gio::Icon, size: u16) -> Option { if let Some(themed_icon) = icon.downcast_ref::() { @@ -21,10 +26,154 @@ fn gio_icon_to_path(icon: &gio::Icon, size: u16) -> Option { None } +fn network_scan(uri: &str, sizes: IconSizes) -> Result, String> { + let file = gio::File::for_uri(uri); + let mut items = Vec::new(); + for info_res in file + .enumerate_children("*", gio::FileQueryInfoFlags::NONE, gio::Cancellable::NONE) + .map_err(err_str)? + { + let info = info_res.map_err(err_str)?; + println!("{:?}", info.display_name()); + for attribute in info.list_attributes(None) { + println!( + " {:?}: {:?}: {:?}", + attribute, + info.attribute_type(&attribute), + info.attribute_as_string(&attribute) + ); + } + + let name = info.name().to_string_lossy().to_string(); + let display_name = info.display_name().to_string(); + + //TODO: what is the best way to resolve shortcuts? + let location = Location::Network( + if let Some(target_uri) = info.attribute_string(gio::FILE_ATTRIBUTE_STANDARD_TARGET_URI) + { + target_uri.to_string() + } else { + file.child(info.name()).uri().to_string() + }, + display_name.clone(), + ); + + //TODO: support dir or file + let metadata = ItemMetadata::SimpleDir { entries: 0 }; + + let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = { + let file_icon = |size| { + info.icon() + .as_ref() + .and_then(|icon| gio_icon_to_path(icon, size)) + .map(|path| widget::icon::from_path(path)) + .unwrap_or( + widget::icon::from_name(if metadata.is_dir() { + "folder" + } else { + "text-x-generic" + }) + .size(size) + .handle(), + ) + }; + ( + //TODO: get mime from content_type? + "inode/directory".parse().unwrap(), + file_icon(sizes.grid()), + file_icon(sizes.list()), + file_icon(sizes.list_condensed()), + ) + }; + + items.push(tab::Item { + name, + display_name, + metadata, + hidden: false, + location_opt: Some(location), + mime, + icon_handle_grid, + icon_handle_list, + icon_handle_list_condensed, + open_with: Vec::new(), + thumbnail_opt: Some(ItemThumbnail::NotImage), + button_id: widget::Id::unique(), + pos_opt: Cell::new(None), + rect_opt: Cell::new(None), + selected: false, + overlaps_drag_rect: false, + }); + } + Ok(items) +} + +fn mount_op(uri: String, event_tx: mpsc::UnboundedSender) -> gio::MountOperation { + let mount_op = gio::MountOperation::new(); + mount_op.connect_ask_password( + move |mount_op, message, default_user, default_domain, flags| { + let auth = MounterAuth { + message: message.to_string(), + username_opt: if flags.contains(gio::AskPasswordFlags::NEED_USERNAME) { + Some(default_user.to_string()) + } else { + None + }, + domain_opt: if flags.contains(gio::AskPasswordFlags::NEED_DOMAIN) { + Some(default_domain.to_string()) + } else { + None + }, + password_opt: if flags.contains(gio::AskPasswordFlags::NEED_PASSWORD) { + Some(String::new()) + } else { + None + }, + remember_opt: if flags.contains(gio::AskPasswordFlags::SAVING_SUPPORTED) { + Some(false) + } else { + None + }, + anonymous_opt: if flags.contains(gio::AskPasswordFlags::ANONYMOUS_SUPPORTED) { + Some(false) + } else { + None + }, + }; + let (auth_tx, mut auth_rx) = mpsc::channel(1); + event_tx + .send(Event::NetworkAuth(uri.clone(), auth, auth_tx)) + .unwrap(); + //TODO: async recv? + if let Some(auth) = auth_rx.blocking_recv() { + if auth.anonymous_opt == Some(true) { + mount_op.set_anonymous(true); + } else { + mount_op.set_username(auth.username_opt.as_deref()); + mount_op.set_domain(auth.domain_opt.as_deref()); + mount_op.set_password(auth.password_opt.as_deref()); + if auth.remember_opt == Some(true) { + mount_op.set_password_save(gio::PasswordSave::Permanently); + } + } + mount_op.reply(gio::MountOperationResult::Handled); + } else { + mount_op.reply(gio::MountOperationResult::Aborted); + } + }, + ); + mount_op +} + enum Cmd { Rescan, Mount(MounterItem), NetworkDrive(String), + NetworkScan( + String, + IconSizes, + mpsc::Sender, String>>, + ), Unmount(MounterItem), } @@ -194,62 +343,8 @@ impl Gvfs { } } Cmd::NetworkDrive(uri) => { - let mount_op = gio::MountOperation::new(); - - { - let event_tx = event_tx.clone(); - let uri = uri.clone(); - mount_op.connect_ask_password(move |mount_op, message, default_user, default_domain, flags| { - let auth = MounterAuth { - message: message.to_string(), - username_opt: if flags.contains(gio::AskPasswordFlags::NEED_USERNAME) { - Some(default_user.to_string()) - } else { - None - }, - domain_opt: if flags.contains(gio::AskPasswordFlags::NEED_DOMAIN) { - Some(default_domain.to_string()) - } else { - None - }, - password_opt: if flags.contains(gio::AskPasswordFlags::NEED_PASSWORD) { - Some(String::new()) - } else { - None - }, - remember_opt: if flags.contains(gio::AskPasswordFlags::SAVING_SUPPORTED) { - Some(false) - } else { - None - }, - anonymous_opt: if flags.contains(gio::AskPasswordFlags::ANONYMOUS_SUPPORTED) { - Some(false) - } else { - None - } - }; - let (auth_tx, mut auth_rx) = mpsc::channel(1); - event_tx.send(Event::NetworkAuth(uri.clone(), auth, auth_tx)).unwrap(); - //TODO: async recv? - if let Some(auth) = auth_rx.blocking_recv() { - if auth.anonymous_opt == Some(true) { - mount_op.set_anonymous(true); - } else { - mount_op.set_username(auth.username_opt.as_deref()); - mount_op.set_domain(auth.domain_opt.as_deref()); - mount_op.set_password(auth.password_opt.as_deref()); - if auth.remember_opt == Some(true) { - mount_op.set_password_save(gio::PasswordSave::Permanently); - } - } - mount_op.reply(gio::MountOperationResult::Handled); - } else { - mount_op.reply(gio::MountOperationResult::Aborted); - } - }); - } - let file = gio::File::for_uri(&uri); + let mount_op = mount_op(uri.clone(), event_tx.clone()); let event_tx = event_tx.clone(); file.mount_enclosing_volume( gio::MountMountFlags::empty(), @@ -267,6 +362,40 @@ impl Gvfs { } ); } + Cmd::NetworkScan(uri, sizes, items_tx) => { + let file = gio::File::for_uri(&uri); + let needs_mount = match file.find_enclosing_mount(gio::Cancellable::NONE) { + Ok(_) => false, + Err(err) => match err.kind::() { + Some(gio::IOErrorEnum::NotMounted) => true, + _ => false + } + }; + if needs_mount { + let mount_op = mount_op(uri.clone(), event_tx.clone()); + let event_tx = event_tx.clone(); + file.mount_enclosing_volume( + gio::MountMountFlags::empty(), + Some(&mount_op), + gio::Cancellable::NONE, + move |res| { + log::info!("network scan mounted {}: result {:?}", uri, res); + items_tx.blocking_send(network_scan(&uri, sizes)).unwrap(); + event_tx.send(Event::NetworkResult(uri, match res { + Ok(()) => { + Ok(true) + }, + Err(err) => match err.kind::() { + Some(gio::IOErrorEnum::FailedHandled) => Ok(false), + _ => Err(format!("{}", err)) + } + })).unwrap(); + } + ); + } else { + items_tx.send(network_scan(&uri, sizes)).await.unwrap(); + } + } Cmd::Unmount(mounter_item) => { let MounterItem::Gvfs(item) = mounter_item else { continue }; let ItemKind::Mount = item.kind else { continue }; @@ -330,6 +459,14 @@ impl Mounter for Gvfs { ) } + fn network_scan(&self, uri: &str, sizes: IconSizes) -> Option, String>> { + let (items_tx, mut items_rx) = mpsc::channel(1); + self.command_tx + .send(Cmd::NetworkScan(uri.to_string(), sizes, items_tx)) + .unwrap(); + items_rx.blocking_recv() + } + fn unmount(&self, item: MounterItem) -> Command<()> { let command_tx = self.command_tx.clone(); Command::perform( diff --git a/src/mounter/mod.rs b/src/mounter/mod.rs index bca9622..a17dc1f 100644 --- a/src/mounter/mod.rs +++ b/src/mounter/mod.rs @@ -2,6 +2,8 @@ use cosmic::{iced::subscription, widget, Command}; use std::{collections::BTreeMap, fmt, path::PathBuf, sync::Arc}; use tokio::sync::mpsc; +use crate::{config::IconSizes, tab}; + #[cfg(feature = "gvfs")] mod gvfs; @@ -86,10 +88,11 @@ pub enum MounterMessage { NetworkResult(String, Result), } -pub trait Mounter { +pub trait Mounter: Send + Sync { //TODO: send result fn mount(&self, item: MounterItem) -> Command<()>; fn network_drive(&self, uri: String) -> Command<()>; + fn network_scan(&self, uri: &str, sizes: IconSizes) -> Option, String>>; fn unmount(&self, item: MounterItem) -> Command<()>; fn subscription(&self) -> subscription::Subscription; } diff --git a/src/mouse_area.rs b/src/mouse_area.rs index 71a049e..2de7aa7 100644 --- a/src/mouse_area.rs +++ b/src/mouse_area.rs @@ -6,8 +6,14 @@ use cosmic::{ iced_core::{ border::Border, event::{self, Event}, + keyboard::{ + self, + key::{self, Key}, + Event::{KeyPressed, KeyReleased}, + Modifiers, + }, layout, - mouse::{self, click}, + mouse::{self, click, Event as MouseEvent}, overlay, renderer::{self, Quad, Renderer as _}, touch, @@ -39,6 +45,7 @@ pub struct MouseArea<'a, Message> { on_back_release: Option) -> Message + 'a>>, on_forward_press: Option) -> Message + 'a>>, on_forward_release: Option) -> Message + 'a>>, + on_scroll: Option Option + 'a>>, show_drag_rect: bool, } @@ -144,6 +151,16 @@ impl<'a, Message> MouseArea<'a, Message> { self } + /// The message to emit on a scroll. + #[must_use] + pub fn on_scroll( + mut self, + message: impl Fn(mouse::ScrollDelta, Modifiers) -> Option + 'a, + ) -> Self { + self.on_scroll = Some(Box::new(message)); + self + } + #[must_use] pub fn show_drag_rect(mut self, show_drag_rect: bool) -> Self { self.show_drag_rect = show_drag_rect; @@ -163,7 +180,7 @@ impl<'a, Message> MouseArea<'a, Message> { struct State { // TODO: Support on_mouse_enter and on_mouse_exit drag_initiated: Option, - + modifiers: Modifiers, prev_click: Option<(mouse::Click, Instant)>, } @@ -227,6 +244,7 @@ impl<'a, Message> MouseArea<'a, Message> { on_back_release: None, on_forward_press: None, on_forward_release: None, + on_scroll: None, show_drag_rect: false, } } @@ -578,6 +596,21 @@ fn update( } } + if let Some(message) = widget.on_scroll.as_ref() { + if let Event::Mouse(mouse::Event::WheelScrolled { delta }) = event { + if let Some(on_scroll) = widget.on_scroll.as_ref() { + if let Some(message) = on_scroll(delta.clone(), state.modifiers) { + shell.publish(message); + return event::Status::Captured; + } + } + } + } + + if let Event::Keyboard(key_event) = event { + handle_key_event(key_event, state) + }; + if let Some((message, drag_rect)) = widget.on_drag.as_ref().zip(state.drag_rect(cursor)) { shell.publish(message(drag_rect.intersection(&layout_bounds).map( |mut rect| { @@ -590,3 +623,53 @@ fn update( event::Status::Ignored } + +fn handle_key_event(key_event: &keyboard::Event, state: &mut State) { + if let KeyPressed { + key: Key::Named(key::Named::Control), + .. + } = key_event + { + state.modifiers.insert(Modifiers::CTRL); + } + + if let KeyReleased { + key: Key::Named(key::Named::Control), + .. + } = key_event + { + state.modifiers.remove(Modifiers::CTRL); + } + + if let KeyPressed { + key: Key::Named(key::Named::Shift), + .. + } = key_event + { + state.modifiers.insert(Modifiers::SHIFT); + } + + if let KeyReleased { + key: Key::Named(key::Named::Shift), + .. + } = key_event + { + state.modifiers.remove(Modifiers::SHIFT); + } + + if let KeyPressed { + key: Key::Named(key::Named::Alt), + .. + } = key_event + { + state.modifiers.insert(Modifiers::ALT); + } + + if let KeyReleased { + key: Key::Named(key::Named::Alt), + .. + } = key_event + { + state.modifiers.remove(Modifiers::ALT); + } +} diff --git a/src/operation.rs b/src/operation.rs index b5ac4e7..15dabcc 100644 --- a/src/operation.rs +++ b/src/operation.rs @@ -15,15 +15,11 @@ use walkdir::WalkDir; use crate::{ app::{ArchiveType, DialogPage, Message}, config::IconSizes, - fl, + err_str, fl, mime_icon::mime_for_path, tab, }; -fn err_str(err: T) -> String { - err.to_string() -} - fn handle_replace( msg_tx: &Arc>>, file_from: PathBuf, diff --git a/src/tab.rs b/src/tab.rs index 18293e0..3632695 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -7,8 +7,9 @@ use cosmic::{ }, alignment::{Horizontal, Vertical}, clipboard::dnd::DndAction, + event, futures::SinkExt, - keyboard::Modifiers, + keyboard::{self, Modifiers}, subscription::{self, Subscription}, //TODO: export in cosmic::widget widget::{ @@ -24,7 +25,7 @@ use cosmic::{ Rectangle, Size, }, - iced_core::widget::tree, + iced_core::{mouse::ScrollDelta, widget::tree}, iced_style::rule, theme, widget::{ @@ -44,7 +45,7 @@ use std::{ cell::{Cell, RefCell}, cmp::Ordering, collections::HashMap, - fmt, + fmt::{self, Display}, fs::{self, Metadata}, num::NonZeroU16, os::unix::fs::MetadataExt, @@ -63,6 +64,7 @@ use crate::{ menu, mime_app::{mime_apps, MimeApp}, mime_icon::{mime_for_path, mime_icon}, + mounter::Mounters, mouse_area, }; use unix_permissions_ext::UNIXPermissionsExt; @@ -72,7 +74,8 @@ pub const DOUBLE_CLICK_DURATION: Duration = Duration::from_millis(500); pub const HOVER_DURATION: Duration = Duration::from_millis(1600); //TODO: adjust for locales? -const TIME_FORMAT: &'static str = "%a %-d %b %-Y %r"; +const DATE_TIME_FORMAT: &'static str = "%b %-d, %-Y, %-I:%M %p"; +const TIME_FORMAT: &'static str = "%-I:%M %p"; static SPECIAL_DIRS: Lazy> = Lazy::new(|| { let mut special_dirs = HashMap::new(); if let Some(dir) = dirs::document_dir() { @@ -260,6 +263,31 @@ fn format_permissions(metadata: &Metadata, owner: PermissionOwner) -> String { perms.join(" ") } +struct FormatTime(std::time::SystemTime); + +impl Display for FormatTime { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let date_time = chrono::DateTime::::from(self.0); + let now = chrono::Local::now(); + if date_time.date() == now.date() { + write!( + f, + "{}, {}", + fl!("today"), + date_time.format_localized(TIME_FORMAT, *LANGUAGE_CHRONO) + ) + } else { + date_time + .format_localized(DATE_TIME_FORMAT, *LANGUAGE_CHRONO) + .fmt(f) + } + } +} + +fn format_time(time: std::time::SystemTime) -> FormatTime { + FormatTime(time) +} + #[cfg(not(target_os = "windows"))] fn hidden_attribute(_metadata: &Metadata) -> bool { false @@ -377,7 +405,7 @@ pub fn item_from_entry( display_name, metadata: ItemMetadata::Path { metadata, children }, hidden, - path_opt: Some(path), + location_opt: Some(Location::Path(path)), mime, icon_handle_grid, icon_handle_list, @@ -535,7 +563,7 @@ pub fn scan_search(tab_path: &PathBuf, term: &str, sizes: IconSizes) -> Vec metadata.modified().ok(), - ItemMetadata::Trash { .. } => None, + _ => None, }; // Sort with latest modified first @@ -621,7 +649,7 @@ pub fn scan_trash(sizes: IconSizes) -> Vec { display_name, metadata: ItemMetadata::Trash { metadata, entry }, hidden: false, - path_opt: None, + location_opt: None, mime, icon_handle_grid, icon_handle_list, @@ -724,9 +752,17 @@ pub fn scan_recents(sizes: IconSizes) -> Vec { recents.into_iter().take(50).map(|(item, _)| item).collect() } -pub fn scan_networks(sizes: IconSizes) -> Vec { - //TODO: network folder items - vec![] +pub fn scan_network(uri: &str, mounters: Mounters, sizes: IconSizes) -> Vec { + for (key, mounter) in mounters.iter() { + match mounter.network_scan(uri, sizes) { + Some(Ok(items)) => return items, + Some(Err(err)) => { + log::warn!("failed to scan {:?}: {}", uri, err); + } + None => {} + } + } + Vec::new() } #[derive(Clone, Debug, Eq, PartialEq)] @@ -735,7 +771,7 @@ pub enum Location { Search(PathBuf, String), Trash, Recents, - Networks, + Network(String, String), } impl std::fmt::Display for Location { @@ -745,19 +781,27 @@ impl std::fmt::Display for Location { Self::Search(path, term) => write!(f, "search {} for {}", path.display(), term), Self::Trash => write!(f, "trash"), Self::Recents => write!(f, "recents"), - Self::Networks => write!(f, "networks"), + Self::Network(uri, _) => write!(f, "{}", uri), } } } impl Location { - pub fn scan(&self, sizes: IconSizes) -> Vec { + pub fn path_opt(&self) -> Option<&PathBuf> { + match self { + Self::Path(path) => Some(&path), + Self::Search(path, _) => Some(&path), + _ => None, + } + } + + pub fn scan(&self, mounters: Mounters, sizes: IconSizes) -> Vec { match self { Self::Path(path) => scan_path(path, sizes), Self::Search(path, term) => scan_search(path, term, sizes), Self::Trash => scan_trash(sizes), Self::Recents => scan_recents(sizes), - Self::Networks => scan_networks(sizes), + Self::Network(uri, _) => scan_network(uri, mounters, sizes), } } } @@ -847,6 +891,12 @@ pub enum ItemMetadata { metadata: trash::TrashItemMetadata, entry: trash::TrashItem, }, + SimpleDir { + entries: u64, + }, + SimpleFile { + size: u64, + }, } impl ItemMetadata { @@ -857,6 +907,8 @@ impl ItemMetadata { trash::TrashItemSize::Entries(_) => true, trash::TrashItemSize::Bytes(_) => false, }, + Self::SimpleDir { .. } => true, + Self::SimpleFile { .. } => false, } } } @@ -874,7 +926,7 @@ pub struct Item { pub display_name: String, pub metadata: ItemMetadata, pub hidden: bool, - pub path_opt: Option, + pub location_opt: Option, pub mime: Mime, pub icon_handle_grid: widget::icon::Handle, pub icon_handle_list: widget::icon::Handle, @@ -894,6 +946,10 @@ impl Item { name.replace(".", ".\u{200B}").replace("_", "_\u{200B}") } + pub fn path_opt(&self) -> Option<&PathBuf> { + self.location_opt.as_ref()?.path_opt() + } + fn preview(&self, sizes: IconSizes) -> Element<'static, app::Message> { // This loads the image only if thumbnailing worked let icon = widget::icon::icon(self.icon_handle_grid.clone()) @@ -907,7 +963,7 @@ impl Item { { ItemThumbnail::NotImage => icon, ItemThumbnail::Rgba(_) => { - if let Some(path) = &self.path_opt { + if let Some(Location::Path(path)) = &self.location_opt { widget::image::viewer(widget::image::Handle::from_path(path)) .min_scale(1.0) .into() @@ -916,7 +972,7 @@ impl Item { } } ItemThumbnail::Svg => { - if let Some(path) = &self.path_opt { + if let Some(Location::Path(path)) = &self.location_opt { widget::Svg::from_path(path).into() } else { icon @@ -944,7 +1000,7 @@ impl Item { column = column.push(widget::text(format!("Type: {}", self.mime))); - if let Some(path) = &self.path_opt { + if let Some(Location::Path(path)) = &self.location_opt { for app in self.open_with.iter() { column = column.push( widget::button( @@ -998,27 +1054,15 @@ impl Item { } if let Ok(time) = metadata.created() { - column = column.push(widget::text(format!( - "Created: {}", - chrono::DateTime::::from(time) - .format_localized(TIME_FORMAT, *LANGUAGE_CHRONO) - ))); + column = column.push(widget::text(format!("Created: {}", format_time(time)))); } if let Ok(time) = metadata.modified() { - column = column.push(widget::text(format!( - "Modified: {}", - chrono::DateTime::::from(time) - .format_localized(TIME_FORMAT, *LANGUAGE_CHRONO) - ))); + column = column.push(widget::text(format!("Modified: {}", format_time(time)))); } if let Ok(time) = metadata.accessed() { - column = column.push(widget::text(format!( - "Accessed: {}", - chrono::DateTime::::from(time) - .format_localized(TIME_FORMAT, *LANGUAGE_CHRONO) - ))); + column = column.push(widget::text(format!("Accessed: {}", format_time(time)))); } #[cfg(not(target_os = "windows"))] { @@ -1061,8 +1105,8 @@ impl Item { ); } } - ItemMetadata::Trash { .. } => { - //TODO: trash metadata + _ => { + //TODO: other metadata types } } @@ -1097,13 +1141,12 @@ impl Item { if let Ok(time) = metadata.modified() { column = column.push(widget::text(format!( "Last modified: {}", - chrono::DateTime::::from(time) - .format_localized(TIME_FORMAT, *LANGUAGE_CHRONO) + format_time(time) ))); } } - ItemMetadata::Trash { .. } => { - //TODO: trash metadata + _ => { + //TODO: other metadata } } @@ -1253,9 +1296,7 @@ impl Tab { Location::Recents => { fl!("recents") } - Location::Networks => { - fl!("networks") - } + Location::Network(_uri, display_name) => display_name.clone(), } } @@ -1309,10 +1350,11 @@ impl Tab { } pub fn select_path(&mut self, path: PathBuf) { + let location = Location::Path(path); *self.cached_selected.borrow_mut() = None; if let Some(ref mut items) = self.items_opt { for item in items.iter_mut() { - item.selected = item.path_opt.as_ref() == Some(&path); + item.selected = item.location_opt.as_ref() == Some(&location); } } } @@ -1566,14 +1608,18 @@ impl Tab { .as_ref() .and_then(|items| click_i_opt.and_then(|click_i| items.get(click_i))) { - if let Some(path) = &clicked_item.path_opt { + if let Some(location) = &clicked_item.location_opt { if clicked_item.metadata.is_dir() { - cd = Some(Location::Path(path.clone())); + cd = Some(location.clone()); } else { - commands.push(Command::OpenFile(path.clone())); + if let Location::Path(path) = location { + commands.push(Command::OpenFile(path.clone())); + } else { + log::warn!("no path for item {:?}", clicked_item); + } } } else { - log::warn!("no path for item {:?}", clicked_item); + log::warn!("no location for item {:?}", clicked_item); } } else { log::warn!("no item for click index {:?}", click_i_opt); @@ -1964,12 +2010,14 @@ impl Tab { if let Some(ref mut items) = self.items_opt { for item in items.iter() { if item.selected { - if let Some(path) = &item.path_opt { - if path.is_dir() { + if let Some(location) = &item.location_opt { + if item.metadata.is_dir() { //TODO: allow opening multiple tabs? - cd = Some(Location::Path(path.clone())); + cd = Some(location.clone()); } else { - commands.push(Command::OpenFile(path.clone())); + if let Location::Path(path) = location { + commands.push(Command::OpenFile(path.clone())); + } } } else { //TODO: open properties? @@ -2004,7 +2052,7 @@ impl Tab { if let Some(clicked_item) = self.items_opt.as_ref().and_then(|items| items.get(click_i)) { - if let Some(path) = &clicked_item.path_opt { + if let Some(Location::Path(path)) = &clicked_item.location_opt { if clicked_item.metadata.is_dir() { //cd = Some(Location::Path(path.clone())); commands.push(Command::OpenInNewTab(path.clone())) @@ -2035,8 +2083,9 @@ impl Tab { } Message::Thumbnail(path, thumbnail) => { if let Some(ref mut items) = self.items_opt { + let location = Location::Path(path); for item in items.iter_mut() { - if item.path_opt.as_ref() == Some(&path) { + if item.location_opt.as_ref() == Some(&location) { if let ItemThumbnail::Rgba(rgba) = &thumbnail { //TODO: pass handles already generated to avoid blocking main thread let handle = widget::icon::from_raster_pixels( @@ -2085,20 +2134,11 @@ impl Tab { } commands.push(Command::DropFiles(to, from)) } - Location::Search(_, _) => { - log::warn!(" Copy/cut to search not supported."); - } Location::Trash if matches!(from.kind, ClipboardKind::Cut) => { commands.push(Command::MoveToTrash(from.paths)) } - Location::Trash => { - log::warn!("Copy to trash is not supported."); - } - Location::Recents => { - log::warn!("Copy to recents is not supported."); - } - Location::Networks => { - log::warn!("Copy to networks is not supported."); + _ => { + log::warn!("{:?} to {:?} is not supported.", from.kind, to); } }; } @@ -2227,6 +2267,8 @@ impl Tab { trash::TrashItemSize::Entries(entries) => (true, entries as u64), trash::TrashItemSize::Bytes(bytes) => (false, bytes), }, + ItemMetadata::SimpleDir { entries } => (true, *entries), + ItemMetadata::SimpleFile { size } => (false, *size), }; let (a_is_entry, a_size) = get_size(a.1); let (b_is_entry, b_size) = get_size(b.1); @@ -2260,7 +2302,7 @@ impl Tab { items.sort_by(|a, b| { let get_modified = |x: &Item| match &x.metadata { ItemMetadata::Path { metadata, .. } => metadata.modified().ok(), - ItemMetadata::Trash { .. } => None, + _ => None, }; let a_modified = get_modified(a.1); @@ -2561,11 +2603,14 @@ impl Tab { .into(), ); } - Location::Networks => { + Location::Network(uri, display_name) => { children.push( - widget::button(widget::text::heading(fl!("networks"))) + widget::button(widget::text::heading(display_name)) .padding(space_xxxs) - .on_press(Message::Location(Location::Networks)) + .on_press(Message::Location(Location::Network( + uri.clone(), + display_name.clone(), + ))) .style(theme::Button::Text) .into(), ); @@ -2765,9 +2810,10 @@ impl Tab { } } - let column: Element = if item.metadata.is_dir() && item.path_opt.is_some() + let column: Element = if item.metadata.is_dir() + && item.location_opt.is_some() { - let tab_location = Location::Path(item.path_opt.clone().unwrap()); + let tab_location = item.location_opt.clone().unwrap(); let tab_location_enter = tab_location.clone(); let tab_location_leave = tab_location.clone(); let is_dnd_hovered = @@ -3036,18 +3082,21 @@ impl Tab { let modified_text = match &item.metadata { ItemMetadata::Path { metadata, .. } => match metadata.modified() { - Ok(time) => chrono::DateTime::::from(time) - .format_localized(TIME_FORMAT, *LANGUAGE_CHRONO) - .to_string(), + Ok(time) => format_time(time).to_string(), Err(_) => String::new(), }, - ItemMetadata::Trash { .. } => String::new(), + _ => String::new(), }; let size_text = match &item.metadata { ItemMetadata::Path { metadata, children } => { if metadata.is_dir() { - format!("{} items", children) + //TODO: translate + if *children == 1 { + format!("{} item", children) + } else { + format!("{} items", children) + } } else { format_size(metadata.len()) } @@ -3063,6 +3112,15 @@ impl Tab { } trash::TrashItemSize::Bytes(bytes) => format_size(bytes), }, + ItemMetadata::SimpleDir { entries } => { + //TODO: translate + if *entries == 1 { + format!("{} item", entries) + } else { + format!("{} items", entries) + } + } + ItemMetadata::SimpleFile { size } => format_size(*size), }; let row = if condensed { @@ -3126,58 +3184,59 @@ impl Tab { }; let button_row = button(row.into()); - let button_row: Element<_> = if item.metadata.is_dir() && item.path_opt.is_some() { - let tab_location = Location::Path(item.path_opt.clone().unwrap()); - let tab_location_enter = tab_location.clone(); - let tab_location_leave = tab_location.clone(); - let is_dnd_hovered = - self.dnd_hovered.as_ref().map(|(l, _)| l) == Some(&tab_location); - cosmic::widget::container( - DndDestination::for_data(button_row, move |data, action| { - if let Some(mut data) = data { - if action == DndAction::Copy { - Message::Drop(Some((tab_location.clone(), data))) - } else if action == DndAction::Move { - data.kind = ClipboardKind::Cut; - Message::Drop(Some((tab_location.clone(), data))) + let button_row: Element<_> = + if item.metadata.is_dir() && item.location_opt.is_some() { + let tab_location = item.location_opt.clone().unwrap(); + let tab_location_enter = tab_location.clone(); + let tab_location_leave = tab_location.clone(); + let is_dnd_hovered = + self.dnd_hovered.as_ref().map(|(l, _)| l) == Some(&tab_location); + cosmic::widget::container( + DndDestination::for_data(button_row, move |data, action| { + if let Some(mut data) = data { + if action == DndAction::Copy { + Message::Drop(Some((tab_location.clone(), data))) + } else if action == DndAction::Move { + data.kind = ClipboardKind::Cut; + Message::Drop(Some((tab_location.clone(), data))) + } else { + log::warn!("unsupported action: {:?}", action); + Message::Drop(None) + } } else { - log::warn!("unsupported action: {:?}", action); + log::warn!("No data for drop."); Message::Drop(None) } - } else { - log::warn!("No data for drop."); - Message::Drop(None) - } - }) - .on_enter(move |_, _, _| Message::DndEnter(tab_location_enter.clone())) - .on_leave(move || Message::DndLeave(tab_location_leave.clone())), - ) - // todo refactor into the dnd destination wrapper - .style(if is_dnd_hovered { - theme::Container::custom(|t| { - let mut a = cosmic::iced_style::container::StyleSheet::appearance( - t, - &theme::Container::default(), - ); - let t = t.cosmic(); - // todo use theme drop target color - let mut bg = t.accent_color(); - bg.alpha = 0.2; - a.background = Some(Color::from(bg).into()); - a.border = Border { - color: t.accent_color().into(), - width: 1.0, - radius: t.radius_s().into(), - }; - a + }) + .on_enter(move |_, _, _| Message::DndEnter(tab_location_enter.clone())) + .on_leave(move || Message::DndLeave(tab_location_leave.clone())), + ) + // todo refactor into the dnd destination wrapper + .style(if is_dnd_hovered { + theme::Container::custom(|t| { + let mut a = cosmic::iced_style::container::StyleSheet::appearance( + t, + &theme::Container::default(), + ); + let t = t.cosmic(); + // todo use theme drop target color + let mut bg = t.accent_color(); + bg.alpha = 0.2; + a.background = Some(Color::from(bg).into()); + a.border = Border { + color: t.accent_color().into(), + width: 1.0, + radius: t.radius_s().into(), + }; + a + }) + } else { + theme::Container::default() }) + .into() } else { - theme::Container::default() - }) - .into() - } else { - button_row.into() - }; + button_row.into() + }; if item.selected || !drag_items.is_empty() { let dnd_row = if !item.selected { @@ -3306,8 +3365,8 @@ impl Tab { items .iter() .filter(|item| item.selected) - .filter_map(|item| item.path_opt.clone()) - .collect::>() + .filter_map(|item| item.path_opt().map(|x| x.clone())) + .collect::>() }) .unwrap_or_default(); let item_view = DndSource::<_, cosmic::app::Message, ClipboardCopy>::with_id( @@ -3336,7 +3395,8 @@ impl Tab { .on_press(move |_point_opt| Message::Click(None)) .on_release(|_| Message::ClickRelease(None)) .on_back_press(move |_point_opt| Message::GoPrevious) - .on_forward_press(move |_point_opt| Message::GoNext); + .on_forward_press(move |_point_opt| Message::GoNext) + .on_scroll(respond_to_scroll_direction); if self.context_menu.is_some() { mouse_area = mouse_area.on_right_press(move |_point_opt| Message::ContextMenu(None)); @@ -3344,6 +3404,7 @@ impl Tab { mouse_area = mouse_area.on_right_press(Message::ContextMenu); } + let should_propogate_events = true; let mut popover = widget::popover(mouse_area); if let Some(point) = self.context_menu { @@ -3383,7 +3444,7 @@ impl Tab { } } } - Location::Networks => { + Location::Network(uri, display_name) if uri == "network:///" => { tab_column = tab_column.push( widget::layer_container(widget::row::with_children(vec![ widget::horizontal_space(Length::Fill).into(), @@ -3485,7 +3546,7 @@ impl Tab { } } - if let Some(path) = item.path_opt.clone() { + if let Some(Location::Path(path)) = item.location_opt.clone() { subscriptions.push(subscription::channel( path.clone(), 1, @@ -3549,20 +3610,42 @@ impl Tab { } } +pub fn respond_to_scroll_direction(delta: ScrollDelta, modifiers: Modifiers) -> Option { + if !modifiers.control() { + return None; + } + + let delta_y = match delta { + ScrollDelta::Lines { y, .. } => y, + ScrollDelta::Pixels { y, .. } => y, + }; + + if delta_y > 0.0 { + return Some(Message::ZoomIn); + } + + if delta_y < 0.0 { + return Some(Message::ZoomOut); + } + + None +} + #[cfg(test)] mod tests { use std::{fs, io, path::PathBuf}; - use cosmic::iced_runtime::keyboard::Modifiers; + use cosmic::{iced::mouse::ScrollDelta, iced_runtime::keyboard::Modifiers}; use log::{debug, trace}; use tempfile::TempDir; use test_log::test; - use super::{scan_path, Location, Message, Tab}; + use super::{respond_to_scroll_direction, scan_path, Location, Message, Tab}; use crate::{ app::test_utils::{ - assert_eq_tab_path, empty_fs, eq_path_item, filter_dirs, read_dir_sorted, simple_fs, - tab_click_new, NAME_LEN, NUM_DIRS, NUM_FILES, NUM_HIDDEN, NUM_NESTED, + assert_eq_tab_path, assert_zoom_affects_item_size, empty_fs, eq_path_item, filter_dirs, + read_dir_sorted, simple_fs, tab_click_new, NAME_LEN, NUM_DIRS, NUM_FILES, NUM_HIDDEN, + NUM_NESTED, }, config::{IconSizes, TabConfig}, }; @@ -3795,6 +3878,64 @@ mod tests { Ok(()) } + #[test] + fn tab_zoom_in_increases_item_view_size() -> io::Result<()> { + let fs = simple_fs(0, NUM_NESTED, NUM_DIRS, 0, NAME_LEN)?; + let path = fs.path(); + + let mut tab = Tab::new(Location::Path(path.into()), TabConfig::default()); + + let should_affect_size = true; + assert_zoom_affects_item_size(&mut tab, Message::ZoomIn, should_affect_size); + Ok(()) + } + + fn tab_zoom_out_decreases_item_view_size() -> io::Result<()> { + let fs = simple_fs(0, NUM_NESTED, NUM_DIRS, 0, NAME_LEN)?; + let path = fs.path(); + + let mut tab = Tab::new(Location::Path(path.into()), TabConfig::default()); + + let should_affect_size = true; + assert_zoom_affects_item_size(&mut tab, Message::ZoomOut, should_affect_size); + Ok(()) + } + + #[test] + fn tab_scroll_up_with_ctrl_modifier_zooms() -> io::Result<()> { + let message_maybe = + respond_to_scroll_direction(ScrollDelta::Pixels { x: 0.0, y: 1.0 }, Modifiers::CTRL); + assert!(!message_maybe.is_none()); + assert!(matches!(message_maybe.unwrap(), Message::ZoomIn)); + Ok(()) + } + + #[test] + fn tab_scroll_up_without_ctrl_modifier_does_not_zoom() -> io::Result<()> { + let message_maybe = + respond_to_scroll_direction(ScrollDelta::Pixels { x: 0.0, y: 1.0 }, Modifiers::empty()); + assert!(message_maybe.is_none()); + Ok(()) + } + + #[test] + fn tab_scroll_down_with_ctrl_modifier_zooms() -> io::Result<()> { + let message_maybe = + respond_to_scroll_direction(ScrollDelta::Pixels { x: 0.0, y: -1.0 }, Modifiers::CTRL); + assert!(!message_maybe.is_none()); + assert!(matches!(message_maybe.unwrap(), Message::ZoomOut)); + Ok(()) + } + + #[test] + fn tab_scroll_down_without_ctrl_modifier_does_not_zoom() -> io::Result<()> { + let message_maybe = respond_to_scroll_direction( + ScrollDelta::Pixels { x: 0.0, y: -1.0 }, + Modifiers::empty(), + ); + assert!(message_maybe.is_none()); + Ok(()) + } #[test] fn tab_empty_history_does_nothing_on_prev_next() -> io::Result<()> { let fs = simple_fs(0, NUM_NESTED, NUM_DIRS, 0, NAME_LEN)?; @@ -3942,7 +4083,7 @@ impl Widget for ArcElementWrapper { _clipboard: &mut dyn cosmic::iced_core::Clipboard, _shell: &mut cosmic::iced_core::Shell<'_, M>, _viewport: &Rectangle, - ) -> cosmic::iced_core::event::Status { + ) -> event::Status { self.0.lock().unwrap().as_widget_mut().on_event( _state, _event, _layout, _cursor, _renderer, _clipboard, _shell, _viewport, )