Merge branch 'pop-os:master' into master

This commit is contained in:
David Carvalho 2024-09-17 13:48:43 -03:00 committed by GitHub
commit e10e73be69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 785 additions and 253 deletions

31
examples/gio-list.rs Normal file
View file

@ -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());
}
}

View file

@ -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
sort-largest-to-smallest = Largest to smallest

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -521,11 +521,14 @@ impl App {
location: Location,
selection_path: Option<PathBuf>,
) -> Command<Message> {
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<ContextItem>) -> Element<Message> {
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::<Location>(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::<Tab>() {
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 {

View file

@ -376,10 +376,12 @@ struct App {
impl App {
fn rescan_tab(&self) -> Command<Message> {
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(),

View file

@ -25,6 +25,10 @@ mod spawn_detached;
use tab::Location;
pub mod tab;
pub(crate) fn err_str<T: ToString>(err: T) -> String {
err.to_string()
}
pub fn home_dir() -> PathBuf {
match dirs::home_dir() {
Some(home) => home,

View file

@ -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() {

View file

@ -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<PathBuf> {
if let Some(themed_icon) = icon.downcast_ref::<gio::ThemedIcon>() {
@ -21,10 +26,154 @@ fn gio_icon_to_path(icon: &gio::Icon, size: u16) -> Option<PathBuf> {
None
}
fn network_scan(uri: &str, sizes: IconSizes) -> Result<Vec<tab::Item>, 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<Event>) -> 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<Result<Vec<tab::Item>, 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::<gio::IOErrorEnum>() {
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::<gio::IOErrorEnum>() {
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<Result<Vec<tab::Item>, 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(

View file

@ -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<bool, String>),
}
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<Result<Vec<tab::Item>, String>>;
fn unmount(&self, item: MounterItem) -> Command<()>;
fn subscription(&self) -> subscription::Subscription<MounterMessage>;
}

View file

@ -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<Box<dyn Fn(Option<Point>) -> Message + 'a>>,
on_forward_press: Option<Box<dyn Fn(Option<Point>) -> Message + 'a>>,
on_forward_release: Option<Box<dyn Fn(Option<Point>) -> Message + 'a>>,
on_scroll: Option<Box<dyn Fn(mouse::ScrollDelta, Modifiers) -> Option<Message> + '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<Message> + '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<Point>,
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<Message: Clone>(
}
}
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<Message: Clone>(
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);
}
}

View file

@ -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<T: ToString>(err: T) -> String {
err.to_string()
}
fn handle_replace(
msg_tx: &Arc<Mutex<Sender<Message>>>,
file_from: PathBuf,

View file

@ -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<HashMap<PathBuf, &'static str>> = 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::<chrono::Local>::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<Item
items.par_sort_unstable_by(|a, b| {
let get_modified = |x: &Item| match &x.metadata {
ItemMetadata::Path { metadata, .. } => metadata.modified().ok(),
ItemMetadata::Trash { .. } => None,
_ => None,
};
// Sort with latest modified first
@ -621,7 +649,7 @@ pub fn scan_trash(sizes: IconSizes) -> Vec<Item> {
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<Item> {
recents.into_iter().take(50).map(|(item, _)| item).collect()
}
pub fn scan_networks(sizes: IconSizes) -> Vec<Item> {
//TODO: network folder items
vec![]
pub fn scan_network(uri: &str, mounters: Mounters, sizes: IconSizes) -> Vec<Item> {
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<Item> {
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<Item> {
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<PathBuf>,
pub location_opt: Option<Location>,
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::<chrono::Local>::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::<chrono::Local>::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::<chrono::Local>::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::<chrono::Local>::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<Message> = if item.metadata.is_dir() && item.path_opt.is_some()
let column: Element<Message> = 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::<chrono::Local>::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::<Vec<_>>()
.filter_map(|item| item.path_opt().map(|x| x.clone()))
.collect::<Vec<PathBuf>>()
})
.unwrap_or_default();
let item_view = DndSource::<_, cosmic::app::Message<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<Message> {
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<M> Widget<M, cosmic::Theme, cosmic::Renderer> for ArcElementWrapper<M> {
_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,
)