From c511242dd4d7a59a3d8aeed40911cb43970a2144 Mon Sep 17 00:00:00 2001 From: Jeremy Soller Date: Fri, 4 Oct 2024 16:28:30 -0600 Subject: [PATCH] Implement desktop view toggles, part of #547 --- i18n/en/cosmic_files.ftl | 9 ++ src/app.rs | 255 +++++++++++++++++++++++++++++++-------- src/config.rs | 23 ++++ src/dialog.rs | 22 ++-- src/menu.rs | 14 ++- src/mounter/gvfs.rs | 86 +++++++------ src/mounter/mod.rs | 5 +- src/tab.rs | 170 ++++++++++++++++++++++---- 8 files changed, 459 insertions(+), 125 deletions(-) diff --git a/i18n/en/cosmic_files.ftl b/i18n/en/cosmic_files.ftl index 3e90ef7..0a49147 100644 --- a/i18n/en/cosmic_files.ftl +++ b/i18n/en/cosmic_files.ftl @@ -11,6 +11,15 @@ recents = Recents undo = Undo today = Today +# Desktop view options +desktop-view-options = Desktop view options... +show-on-desktop = Show on Desktop +desktop-folder-content = Desktop folder content +mounted-drives = Mounted drives +trash-folder-icon = Trash folder icon +icon-size-and-spacing = Icon size and spacing +icon-size = Icon size + # List view name = Name modified = Modified diff --git a/src/app.rs b/src/app.rs index 28ef3b2..3fd0499 100644 --- a/src/app.rs +++ b/src/app.rs @@ -58,7 +58,7 @@ use wayland_client::{protocol::wl_output::WlOutput, Proxy}; use crate::{ clipboard::{ClipboardCopy, ClipboardKind, ClipboardPaste}, - config::{AppTheme, Config, Favorite, IconSizes, TabConfig}, + config::{AppTheme, Config, DesktopConfig, Favorite, IconSizes, TabConfig}, desktop_dir, fl, home_dir, key_bind::key_binds, localize::LANGUAGE_SORTER, @@ -95,6 +95,7 @@ pub enum Action { CosmicSettingsAppearance, CosmicSettingsDisplays, CosmicSettingsWallpaper, + DesktopViewOptions, EditHistory, EditLocation, ExtractHere, @@ -151,6 +152,7 @@ impl Action { Action::CosmicSettingsAppearance => Message::CosmicSettings("appearance"), Action::CosmicSettingsDisplays => Message::CosmicSettings("displays"), Action::CosmicSettingsWallpaper => Message::CosmicSettings("wallpaper"), + Action::DesktopViewOptions => Message::DesktopViewOptions, Action::EditHistory => Message::ToggleContextPage(ContextPage::EditHistory), Action::EditLocation => { Message::TabMessage(entity_opt, tab::Message::EditLocationToggle) @@ -260,6 +262,8 @@ pub enum Message { Copy(Option), CosmicSettings(&'static str), Cut(Option), + DesktopConfig(DesktopConfig), + DesktopViewOptions, DialogCancel, DialogComplete, DialogPush(DialogPage), @@ -446,6 +450,7 @@ pub struct MounterData(MounterKey, MounterItem); #[derive(Clone, Debug)] pub enum WindowKind { Desktop(Entity), + DesktopViewOptions, Preview(Option, PreviewKind), } @@ -583,13 +588,16 @@ impl App { selection_path: Option, ) -> Command { log::info!("rescan_tab {entity:?} {location:?} {selection_path:?}"); + let desktop_config = self.config.desktop; 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(mounters, icon_sizes)) - .await + match tokio::task::spawn_blocking(move || { + location2.scan(desktop_config, mounters, icon_sizes) + }) + .await { Ok(items) => { message::app(Message::TabRescan(entity, location, items, selection_path)) @@ -625,17 +633,14 @@ impl App { let entity = self.tab_model.active(); let mut title_location_opt = None; if let Some(tab) = self.tab_model.data_mut::(entity) { - match &tab.location { - Location::Path(path) | Location::Search(path, ..) => { - let location = if !self.search_input.is_empty() { - Location::Search(path.clone(), self.search_input.clone()) - } else { - Location::Path(path.clone()) - }; - tab.change_location(&location, None); - title_location_opt = Some((tab.title(), tab.location.clone())); - } - _ => {} + if let Some(path) = tab.location.path_opt() { + let location = if !self.search_input.is_empty() { + Location::Search(path.to_path_buf(), self.search_input.clone()) + } else { + Location::Path(path.to_path_buf()) + }; + tab.change_location(&location, None); + title_location_opt = Some((tab.title(), tab.location.clone())); } } if let Some((title, location)) = title_location_opt { @@ -680,6 +685,22 @@ impl App { Command::batch(commands) } + fn update_desktop(&mut self) -> Command { + let mut needs_reload = Vec::new(); + for entity in self.tab_model.iter() { + if let Some(tab) = self.tab_model.data::(entity) { + if let Location::Desktop(..) = &tab.location { + needs_reload.push((entity, tab.location.clone())); + }; + } + } + let mut commands = Vec::with_capacity(needs_reload.len()); + for (entity, location) in needs_reload { + commands.push(self.rescan_tab(entity, location, None)); + } + Command::batch(commands) + } + fn activate_nav_model_location(&mut self, location: &Location) { let nav_bar_id = self.nav_model.iter().find(|&id| { self.nav_model @@ -771,7 +792,7 @@ impl App { if let Some(path) = item.path() { b = b.data(Location::Path(path.clone())); } - if let Some(icon) = item.icon() { + if let Some(icon) = item.icon(true) { b = b.icon(widget::icon::icon(icon).size(16)); } if item.is_mounted() { @@ -830,8 +851,8 @@ impl App { let mut new_paths = HashSet::new(); for entity in self.tab_model.iter() { if let Some(tab) = self.tab_model.data::(entity) { - if let Location::Path(path) = &tab.location { - new_paths.insert(path.clone()); + if let Some(path) = tab.location.path_opt() { + new_paths.insert(path.to_path_buf()); } } } @@ -954,6 +975,72 @@ impl App { .into() } + fn desktop_view_options(&self) -> Element { + let config = self.config.desktop; + + let mut children = Vec::new(); + + let mut section = widget::settings::section().title(fl!("show-on-desktop")); + section = section.add( + widget::settings::item::builder(fl!("desktop-folder-content")).toggler( + config.show_content, + move |show_content| { + Message::DesktopConfig(DesktopConfig { + show_content, + ..config + }) + }, + ), + ); + section = section.add( + widget::settings::item::builder(fl!("mounted-drives")).toggler( + config.show_mounted_drives, + move |show_mounted_drives| { + Message::DesktopConfig(DesktopConfig { + show_mounted_drives, + ..config + }) + }, + ), + ); + section = section.add( + widget::settings::item::builder(fl!("trash-folder-icon")).toggler( + config.show_trash, + move |show_trash| { + Message::DesktopConfig(DesktopConfig { + show_trash, + ..config + }) + }, + ), + ); + children.push(section.into()); + + /*TODO: Desktop icon size and spacing + let mut section = widget::settings::section().title(fl!("icon-size-and-spacing")); + let grid: u16 = config.icon_sizes.grid.into(); + section = section.add( + widget::settings::item::builder(fl!("icon-size")) + .description(format!("{}%", grid)) + .control( + widget::slider(50..=500, grid, move |grid| { + Message::DesktopConfig(DesktopConfig { + icon_sizes: IconSizes { + grid: NonZeroU16::new(grid).unwrap(), + ..config.icon_sizes + }, + ..config + }) + }) + .step(25u16), + ), + ); + children.push(section.into()); + */ + + widget::settings::view_column(children).into() + } + fn edit_history(&self) -> Element { let mut children = Vec::new(); @@ -1459,6 +1546,31 @@ impl Application for App { } } } + Message::DesktopConfig(config) => { + if config != self.config.desktop { + config_set!(desktop, config); + return self.update_desktop(); + } + } + Message::DesktopViewOptions => { + let mut settings = window::Settings::default(); + settings.decorations = true; + settings.min_size = Some(Size::new(360.0, 180.0)); + settings.resizable = true; + settings.size = Size::new(480.0, 444.0); + settings.transparent = true; + + #[cfg(target_os = "linux")] + { + // Use the dialog ID to make it float + settings.platform_specific.application_id = + "com.system76.CosmicFilesDialog".to_string(); + } + + let (id, command) = window::spawn(settings); + self.windows.insert(id, WindowKind::DesktopViewOptions); + return command; + } Message::DialogCancel => { self.dialog_pages.pop_front(); } @@ -1685,6 +1797,9 @@ impl Application for App { //TODO: this could change favorites IDs while they are in use self.update_nav_model(); + // Update desktop tabs + commands.push(self.update_desktop()); + return Command::batch(commands); } Message::NetworkAuth(mounter_key, uri, auth, auth_tx) => { @@ -1739,9 +1854,9 @@ impl Application for App { Message::NewItem(entity_opt, dir) => { let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); if let Some(tab) = self.tab_model.data_mut::(entity) { - if let Location::Path(path) = &tab.location { + if let Some(path) = &tab.location.path_opt() { self.dialog_pages.push_back(DialogPage::NewItem { - parent: path.clone(), + parent: path.to_path_buf(), name: String::new(), dir, }); @@ -1760,7 +1875,7 @@ impl Application for App { let entities: Vec<_> = self.tab_model.iter().collect(); for entity in entities { if let Some(tab) = self.tab_model.data_mut::(entity) { - if let Location::Path(path) = &tab.location { + if let Some(path) = &tab.location.path_opt() { let mut contains_change = false; for event in events.iter() { for event_path in event.paths.iter() { @@ -1834,18 +1949,18 @@ impl Application for App { let mut paths = Vec::new(); let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); if let Some(tab) = self.tab_model.data_mut::(entity) { - if let Location::Path(path) = &tab.location { + if let Some(path) = &tab.location.path_opt() { if let Some(items) = tab.items_opt() { for item in items.iter() { if item.selected { - if let Some(Location::Path(path)) = &item.location_opt { - paths.push(path.clone()); + if let Some(path) = item.path_opt() { + paths.push(path.to_path_buf()); } } } } if paths.is_empty() { - paths.push(path.clone()); + paths.push(path.to_path_buf()); } } } @@ -1975,7 +2090,7 @@ impl Application for App { Message::Paste(entity_opt) => { let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); if let Some(tab) = self.tab_model.data_mut::(entity) { - if let Location::Path(path) = &tab.location { + if let Some(path) = tab.location.path_opt() { let to = path.clone(); return clipboard::read_data::(move |contents_opt| { match contents_opt { @@ -2124,7 +2239,7 @@ impl Application for App { let maybe_entity = self.nav_model.iter().find(|&entity| { self.nav_model .data::(entity) - .map(|loc| *loc == Location::Trash) + .map(|loc| matches!(loc, Location::Trash)) .unwrap_or_default() }); if let Some(entity) = maybe_entity { @@ -2132,19 +2247,19 @@ impl Application for App { .icon_set(entity, widget::icon::icon(tab::trash_icon_symbolic(16))); } - return self.rescan_trash(); + return Command::batch([self.rescan_trash(), self.update_desktop()]); } Message::Rename(entity_opt) => { let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); if let Some(tab) = self.tab_model.data_mut::(entity) { - if let Location::Path(parent) = &tab.location { + if let Some(parent) = tab.location.path_opt() { if let Some(items) = tab.items_opt() { let mut selected = Vec::new(); for item in items.iter() { if item.selected { - if let Some(Location::Path(path)) = &item.location_opt { - selected.push(path.clone()); + if let Some(path) = item.path_opt() { + selected.push(path.to_path_buf()); } } } @@ -2470,6 +2585,17 @@ impl Application for App { log::error!("failed to get current executable path: {}", err); } }, + tab::Command::OpenTrash => { + //TODO: use handler for x-scheme-handler/trash and open trash:/// + let mut command = process::Command::new("cosmic-files"); + command.arg("--trash"); + match spawn_detached(&mut command) { + Ok(()) => {} + Err(err) => { + log::warn!("failed to run cosmic-files --trash: {}", err) + } + } + } tab::Command::Preview(kind) => { self.context_page = ContextPage::Preview(Some(entity), kind); self.set_show_context(true); @@ -2532,12 +2658,13 @@ impl Application for App { self.toasts.remove(id); let mut paths = Vec::with_capacity(recently_trashed.len()); + let desktop_config = self.config.desktop; 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(mounters, icon_sizes) + Location::Trash.scan(desktop_config, mounters, icon_sizes) }) .await { @@ -2802,7 +2929,11 @@ impl Application for App { } NavMenuAction::Preview(entity) => { - if let Some(Location::Path(path)) = self.nav_model.data::(entity) { + if let Some(path) = self + .nav_model + .data::(entity) + .and_then(|location| location.path_opt()) + { match tab::item_from_path(path, IconSizes::default()) { Ok(item) => { self.context_page = ContextPage::Preview( @@ -2856,22 +2987,28 @@ impl Application for App { None => {} } - match output_info_opt { + let display = match output_info_opt { Some(output_info) => match output_info.name { Some(output_name) => { self.surface_names.insert(surface_id, output_name.clone()); + output_name } None => { log::warn!("output {}: no output name", output.id()); + String::new() } }, None => { log::warn!("output {}: no output info", output.id()); + String::new() } - } + }; - let (entity, command) = - self.open_tab_entity(Location::Path(desktop_dir()), false, None); + let (entity, command) = self.open_tab_entity( + Location::Desktop(desktop_dir(), display), + false, + None, + ); self.windows.insert(surface_id, WindowKind::Desktop(entity)); return Command::batch([ command, @@ -3557,19 +3694,20 @@ impl Application for App { fn view_window(&self, id: WindowId) -> Element { let content = match self.windows.get(&id) { Some(WindowKind::Desktop(entity)) => { - let mut tab_column = widget::column::with_capacity(2); + let mut tab_column = widget::column::with_capacity(3); - match self.tab_model.data::(*entity) { - Some(tab) => { - let tab_view = tab - .view(&self.key_binds) - .map(move |message| Message::TabMessage(Some(*entity), message)); - tab_column = tab_column.push(tab_view); - } - None => { - //TODO - } + let tab_view = match self.tab_model.data::(*entity) { + Some(tab) => tab + .view(&self.key_binds) + .map(move |message| Message::TabMessage(Some(*entity), message)), + None => widget::vertical_space(Length::Fill).into(), + }; + + let mut popover = widget::popover(tab_view); + if let Some(dialog) = self.dialog() { + popover = popover.popup(dialog); } + tab_column = tab_column.push(popover); // The toaster is added on top of an empty element to ensure that it does not override context menus tab_column = tab_column.push(widget::toaster( @@ -3579,6 +3717,7 @@ impl Application for App { return tab_column.into(); } + Some(WindowKind::DesktopViewOptions) => self.desktop_view_options(), Some(WindowKind::Preview(entity_opt, kind)) => self.preview(entity_opt, kind, false), None => { //TODO: distinct views per monitor in desktop mode @@ -3597,7 +3736,10 @@ impl Application for App { widget::container( widget::scrollable(widget::row::with_children(vec![ content, - widget::horizontal_space(Length::Fixed((scrollbar_width + scrollbar_margin).into())).into(), + widget::horizontal_space(Length::Fixed( + (scrollbar_width + scrollbar_margin).into(), + )) + .into(), ])) .direction(scrollable::Direction::Vertical( scrollable::Properties::new() @@ -3607,7 +3749,12 @@ impl Application for App { ) .width(Length::Fill) .height(Length::Fill) - .padding([0, space_l - (scrollbar_width + scrollbar_margin), space_l, space_l]) + .padding([ + 0, + space_l - (scrollbar_width + scrollbar_margin), + space_l, + space_l, + ]) .style(theme::Container::WindowBackground) .into() } @@ -4085,7 +4232,11 @@ pub(crate) mod test_utils { // New tab with items let location = Location::Path(path.to_owned()); - let items = location.scan(Mounters::new(MounterMap::new()), IconSizes::default()); + let items = location.scan( + DesktopConfig::default(), + Mounters::new(MounterMap::new()), + IconSizes::default(), + ); let mut tab = Tab::new(location, TabConfig::default()); tab.set_items(items); @@ -4127,7 +4278,7 @@ pub(crate) mod test_utils { /// Asserts `tab`'s location changed to `path` pub fn assert_eq_tab_path(tab: &Tab, path: &Path) { // Paths should be the same - let Location::Path(ref tab_path) = tab.location else { + let Some(tab_path) = tab.location.path_opt() else { panic!("Expected tab's location to be a path"); }; @@ -4142,7 +4293,7 @@ pub(crate) mod test_utils { /// 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 { + let Some(tab_path) = tab.location.path_opt() else { panic!("Expected tab's location to be a path"); }; diff --git a/src/config.rs b/src/config.rs index b2da776..229d943 100644 --- a/src/config.rs +++ b/src/config.rs @@ -90,8 +90,10 @@ impl Favorite { } #[derive(Clone, CosmicConfigEntry, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(default)] pub struct Config { pub app_theme: AppTheme, + pub desktop: DesktopConfig, pub favorites: Vec, pub show_details: bool, pub tab: TabConfig, @@ -131,6 +133,7 @@ impl Default for Config { fn default() -> Self { Self { app_theme: AppTheme::System, + desktop: DesktopConfig::default(), favorites: vec![ Favorite::Home, Favorite::Documents, @@ -145,12 +148,31 @@ impl Default for Config { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq, CosmicConfigEntry, Deserialize, Serialize)] +#[serde(default)] +pub struct DesktopConfig { + pub show_content: bool, + pub show_mounted_drives: bool, + pub show_trash: bool, +} + +impl Default for DesktopConfig { + fn default() -> Self { + Self { + show_content: true, + show_mounted_drives: false, + show_trash: false, + } + } +} + /// Global and local [`crate::tab::Tab`] config. /// /// [`TabConfig`] contains options that are passed to each instance of [`crate::tab::Tab`]. /// These options are set globally through the main config, but each tab may change options /// locally. Local changes aren't saved to the main config. #[derive(Clone, Copy, Debug, Eq, PartialEq, CosmicConfigEntry, Deserialize, Serialize)] +#[serde(default)] pub struct TabConfig { pub view: View, /// Show folders before files @@ -179,6 +201,7 @@ macro_rules! percent { } #[derive(Clone, Copy, Debug, Eq, PartialEq, CosmicConfigEntry, Deserialize, Serialize)] +#[serde(default)] pub struct IconSizes { pub list: NonZeroU16, pub grid: NonZeroU16, diff --git a/src/dialog.rs b/src/dialog.rs index 1c3b3d6..b8a1dab 100644 --- a/src/dialog.rs +++ b/src/dialog.rs @@ -485,11 +485,15 @@ impl App { fn rescan_tab(&self) -> Command { let location = self.tab.location.clone(); + let desktop_config = self.flags.config.desktop; 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(mounters, icon_sizes)).await + match tokio::task::spawn_blocking(move || { + location.scan(desktop_config, mounters, icon_sizes) + }) + .await { Ok(items) => message::app(Message::TabRescan(items)), Err(err) => { @@ -589,7 +593,7 @@ impl App { if let Some(path) = item.path() { b = b.data(Location::Path(path.clone())); } - if let Some(icon) = item.icon() { + if let Some(icon) = item.icon(true) { b = b.icon(widget::icon::icon(icon).size(16)); } if item.is_mounted() { @@ -615,8 +619,8 @@ impl App { fn update_watcher(&mut self) -> Command { if let Some((mut watcher, old_paths)) = self.watcher_opt.take() { let mut new_paths = HashSet::new(); - if let Location::Path(path) = &self.tab.location { - new_paths.insert(path.clone()); + if let Some(path) = &self.tab.location.path_opt() { + new_paths.insert(path.to_path_buf()); } // Unwatch paths no longer used @@ -1121,9 +1125,9 @@ impl Application for App { return Command::batch(commands); } Message::NewFolder => { - if let Location::Path(path) = &self.tab.location { + if let Some(path) = self.tab.location.path_opt() { self.dialog_pages.push_back(DialogPage::NewFolder { - parent: path.clone(), + parent: path.to_path_buf(), name: String::new(), }); return widget::text_input::focus(self.dialog_text_input.clone()); @@ -1132,7 +1136,7 @@ impl Application for App { Message::NotifyEvents(events) => { log::debug!("{:?}", events); - if let Location::Path(path) = &self.tab.location { + if let Some(path) = self.tab.location.path_opt() { let mut contains_change = false; for event in events.iter() { for event_path in event.paths.iter() { @@ -1198,7 +1202,7 @@ impl Application for App { if let Some(items) = self.tab.items_opt() { for item in items.iter() { if item.selected { - if let Some(Location::Path(path)) = &item.location_opt { + if let Some(path) = item.path_opt() { paths.push(path.clone()); let _ = update_recently_used( &path.clone(), @@ -1258,7 +1262,7 @@ impl Application for App { Message::Save(replace) => { if let DialogKind::SaveFile { filename } = &self.flags.kind { if !filename.is_empty() { - if let Location::Path(tab_path) = &self.tab.location { + if let Some(tab_path) = self.tab.location.path_opt() { let path = tab_path.join(&filename); if path.is_dir() { // cd to directory diff --git a/src/menu.rs b/src/menu.rs index 4a2551e..9768f23 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -95,7 +95,10 @@ pub fn context_menu<'a>( match (&tab.mode, &tab.location) { ( tab::Mode::App | tab::Mode::Desktop, - Location::Path(_) | Location::Search(_, _) | Location::Recents, + Location::Desktop(_, _) + | Location::Path(_) + | Location::Search(_, _) + | Location::Recents, ) => { if selected > 0 { if selected_dir == 1 && selected == 1 || selected_dir == 0 { @@ -189,11 +192,18 @@ pub fn context_menu<'a>( 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)); + children.push(divider::horizontal::light().into()); + children.push( + menu_item(fl!("desktop-view-options"), Action::DesktopViewOptions).into(), + ); } } ( tab::Mode::Dialog(dialog_kind), - Location::Path(_) | Location::Search(_, _) | Location::Recents, + Location::Desktop(_, _) + | Location::Path(_) + | Location::Search(_, _) + | Location::Recents, ) => { if selected > 0 { if selected_dir == 1 && selected == 1 || selected_dir == 0 { diff --git a/src/mounter/gvfs.rs b/src/mounter/gvfs.rs index 210a16b..8155b8a 100644 --- a/src/mounter/gvfs.rs +++ b/src/mounter/gvfs.rs @@ -26,6 +26,37 @@ fn gio_icon_to_path(icon: &gio::Icon, size: u16) -> Option { None } +fn items(monitor: &gio::VolumeMonitor, sizes: IconSizes) -> MounterItems { + let mut items = MounterItems::new(); + for (i, mount) in monitor.mounts().into_iter().enumerate() { + items.push(MounterItem::Gvfs(Item { + kind: ItemKind::Mount, + index: i, + name: MountExt::name(&mount).to_string(), + is_mounted: true, + icon_opt: gio_icon_to_path(&MountExt::icon(&mount), sizes.grid()), + icon_symbolic_opt: gio_icon_to_path(&MountExt::symbolic_icon(&mount), 16), + path_opt: MountExt::root(&mount).path(), + })); + } + for (i, volume) in monitor.volumes().into_iter().enumerate() { + if volume.get_mount().is_some() { + // Volumes with mounts are already listed by mount + continue; + } + items.push(MounterItem::Gvfs(Item { + kind: ItemKind::Volume, + index: i, + name: VolumeExt::name(&volume).to_string(), + is_mounted: false, + icon_opt: gio_icon_to_path(&VolumeExt::icon(&volume), sizes.grid()), + icon_symbolic_opt: gio_icon_to_path(&VolumeExt::symbolic_icon(&volume), 16), + path_opt: None, + })); + } + items +} + fn network_scan(uri: &str, sizes: IconSizes) -> Result, String> { let file = gio::File::for_uri(uri); let mut items = Vec::new(); @@ -166,6 +197,7 @@ fn mount_op(uri: String, event_tx: mpsc::UnboundedSender) -> gio::MountOp } enum Cmd { + Items(IconSizes, mpsc::Sender), Rescan, Mount(MounterItem), NetworkDrive(String), @@ -198,6 +230,7 @@ pub struct Item { name: String, is_mounted: bool, icon_opt: Option, + icon_symbolic_opt: Option, path_opt: Option, } @@ -210,10 +243,13 @@ impl Item { self.is_mounted } - pub fn icon(&self) -> Option { - self.icon_opt - .as_ref() - .map(|icon| widget::icon::from_path(icon.clone())) + pub fn icon(&self, symbolic: bool) -> Option { + if symbolic { + self.icon_symbolic_opt.as_ref() + } else { + self.icon_opt.as_ref() + } + .map(|icon| widget::icon::from_path(icon.clone())) } pub fn path(&self) -> Option { @@ -281,39 +317,11 @@ impl Gvfs { while let Some(command) = command_rx.recv().await { match command { + Cmd::Items(sizes, items_tx) => { + items_tx.send(items(&monitor, sizes)).await.unwrap(); + } Cmd::Rescan => { - let mut items = MounterItems::new(); - for (i, mount) in monitor.mounts().into_iter().enumerate() { - items.push(MounterItem::Gvfs(Item { - kind: ItemKind::Mount, - index: i, - name: MountExt::name(&mount).to_string(), - is_mounted: true, - icon_opt: gio_icon_to_path( - &MountExt::symbolic_icon(&mount), - 16, - ), - path_opt: MountExt::root(&mount).path(), - })); - } - for (i, volume) in monitor.volumes().into_iter().enumerate() { - if volume.get_mount().is_some() { - // Volumes with mounts are already listed by mount - continue; - } - items.push(MounterItem::Gvfs(Item { - kind: ItemKind::Volume, - index: i, - name: VolumeExt::name(&volume).to_string(), - is_mounted: false, - icon_opt: gio_icon_to_path( - &VolumeExt::symbolic_icon(&volume), - 16, - ), - path_opt: None, - })); - } - event_tx.send(Event::Items(items)).unwrap(); + event_tx.send(Event::Items(items(&monitor, IconSizes::default()))).unwrap(); } Cmd::Mount(mounter_item) => { let MounterItem::Gvfs(item) = mounter_item else { continue }; @@ -437,6 +445,12 @@ impl Gvfs { } impl Mounter for Gvfs { + fn items(&self, sizes: IconSizes) -> Option { + let (items_tx, mut items_rx) = mpsc::channel(1); + self.command_tx.send(Cmd::Items(sizes, items_tx)).unwrap(); + items_rx.blocking_recv() + } + fn mount(&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 a17dc1f..7c39e81 100644 --- a/src/mounter/mod.rs +++ b/src/mounter/mod.rs @@ -62,10 +62,10 @@ impl MounterItem { } } - pub fn icon(&self) -> Option { + pub fn icon(&self, symbolic: bool) -> Option { match self { #[cfg(feature = "gvfs")] - Self::Gvfs(item) => item.icon(), + Self::Gvfs(item) => item.icon(symbolic), Self::None => unreachable!(), } } @@ -89,6 +89,7 @@ pub enum MounterMessage { } pub trait Mounter: Send + Sync { + fn items(&self, sizes: IconSizes) -> Option; //TODO: send result fn mount(&self, item: MounterItem) -> Command<()>; fn network_drive(&self, uri: String) -> Command<()>; diff --git a/src/tab.rs b/src/tab.rs index cd80ec2..989a2f3 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -57,7 +57,7 @@ use std::{ use crate::{ app::{self, Action, PreviewItem, PreviewKind}, clipboard::{ClipboardCopy, ClipboardKind, ClipboardPaste}, - config::{IconSizes, TabConfig, ICON_SCALE_MAX, ICON_SIZE_GRID}, + config::{DesktopConfig, IconSizes, TabConfig, ICON_SCALE_MAX, ICON_SIZE_GRID}, dialog::DialogKind, fl, localize::{LANGUAGE_CHRONO, LANGUAGE_SORTER}, @@ -184,15 +184,31 @@ pub fn folder_icon_symbolic(path: &PathBuf, icon_size: u16) -> widget::icon::Han .handle() } +#[cfg(target_os = "macos")] +pub fn trash_entries() -> usize { + 0 +} + +#[cfg(not(target_os = "macos"))] +pub fn trash_entries() -> usize { + match trash::os_limited::list() { + Ok(entries) => entries.len(), + Err(_err) => 0, + } +} + +pub fn trash_icon(icon_size: u16) -> widget::icon::Handle { + widget::icon::from_name(if trash_entries() > 0 { + "user-trash-full" + } else { + "user-trash" + }) + .size(icon_size) + .handle() +} + pub fn trash_icon_symbolic(icon_size: u16) -> widget::icon::Handle { - #[cfg(target_os = "macos")] - let full = false; // TODO: add support for macos - #[cfg(not(target_os = "macos"))] - let full = match trash::os_limited::list() { - Ok(entries) => !entries.is_empty(), - Err(_err) => false, - }; - widget::icon::from_name(if full { + widget::icon::from_name(if trash_entries() > 0 { "user-trash-full-symbolic" } else { "user-trash-symbolic" @@ -783,23 +799,111 @@ pub fn scan_network(uri: &str, mounters: Mounters, sizes: IconSizes) -> Vec Vec { + let mut items = Vec::new(); + + if desktop_config.show_content { + items.extend(scan_path(tab_path, sizes)); + } + + if desktop_config.show_mounted_drives { + for (_mounter_key, mounter) in mounters.iter() { + for mounter_item in mounter.items(sizes).unwrap_or_default() { + let Some(path) = mounter_item.path() else { + continue; + }; + + // Get most item data from path + let mut item = match item_from_path(&path, sizes) { + Ok(item) => item, + Err(err) => { + log::warn!("failed to get item from mounter item {:?}: {}", path, err); + continue; + } + }; + + //Override some data with mounter information + item.name = mounter_item.name(); + item.display_name = Item::display_name(&item.name); + + //TODO: use icon size for mounter item icon + if let Some(icon) = mounter_item.icon(false) { + item.icon_handle_grid = icon.clone(); + item.icon_handle_list = icon.clone(); + item.icon_handle_list_condensed = icon; + } + + items.push(item); + } + } + } + + if desktop_config.show_trash { + let name = fl!("trash"); + let display_name = Item::display_name(&name); + + let metadata = ItemMetadata::SimpleDir { + entries: trash_entries() as u64, + }; + + let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = { + ( + "inode/directory".parse().unwrap(), + trash_icon(sizes.grid()), + trash_icon(sizes.list()), + trash_icon(sizes.list_condensed()), + ) + }; + + items.push(Item { + name, + display_name, + metadata, + hidden: false, + location_opt: Some(Location::Trash), + 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, + }) + } + + items +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum Location { + Desktop(PathBuf, String), + Network(String, String), Path(PathBuf), + Recents, Search(PathBuf, String), Trash, - Recents, - Network(String, String), } impl std::fmt::Display for Location { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::Desktop(path, display) => write!(f, "{} on display {display}", path.display()), + Self::Network(uri, _) => write!(f, "{}", uri), Self::Path(path) => write!(f, "{}", path.display()), + Self::Recents => write!(f, "recents"), Self::Search(path, term) => write!(f, "search {} for {}", path.display(), term), Self::Trash => write!(f, "trash"), - Self::Recents => write!(f, "recents"), - Self::Network(uri, _) => write!(f, "{}", uri), } } } @@ -807,14 +911,23 @@ impl std::fmt::Display for Location { impl Location { pub fn path_opt(&self) -> Option<&PathBuf> { match self { + Self::Desktop(path, _display) => Some(&path), Self::Path(path) => Some(&path), Self::Search(path, _) => Some(&path), _ => None, } } - pub fn scan(&self, mounters: Mounters, sizes: IconSizes) -> Vec { + pub fn scan( + &self, + desktop_config: DesktopConfig, + mounters: Mounters, + sizes: IconSizes, + ) -> Vec { match self { + Self::Desktop(path, display) => { + scan_desktop(path, display, desktop_config, mounters, sizes) + } Self::Path(path) => scan_path(path, sizes), Self::Search(path, term) => scan_search(path, term, sizes), Self::Trash => scan_trash(sizes), @@ -836,6 +949,7 @@ pub enum Command { OpenFile(PathBuf), OpenInNewTab(PathBuf), OpenInNewWindow(PathBuf), + OpenTrash, Preview(PreviewKind), WindowDrag, WindowToggleMaximize, @@ -1079,7 +1193,7 @@ impl Item { { ItemThumbnail::NotImage => icon, ItemThumbnail::Rgba(rgba, _) => { - if let Some(Location::Path(path)) = &self.location_opt { + if let Some(path) = self.path_opt() { if self.mime.type_() == mime::IMAGE { return widget::image(widget::image::Handle::from_path(path)).into(); } @@ -1430,6 +1544,10 @@ impl Tab { pub fn title(&self) -> String { match &self.location { + Location::Desktop(path, _display) => { + let (name, _) = folder_name(path); + name + } Location::Path(path) => { let (name, _) = folder_name(path); name @@ -1787,8 +1905,8 @@ impl Tab { if clicked_item.metadata.is_dir() { cd = Some(location.clone()); } else { - if let Location::Path(path) = location { - commands.push(Command::OpenFile(path.clone())); + if let Some(path) = location.path_opt() { + commands.push(Command::OpenFile(path.to_path_buf())); } else { log::warn!("no path for item {:?}", clicked_item); } @@ -2275,8 +2393,9 @@ impl Tab { //TODO: allow opening multiple tabs? cd = Some(location.clone()); } else { - if let Location::Path(path) = location { - commands.push(Command::OpenFile(path.clone())); + if let Some(path) = location.path_opt() { + commands + .push(Command::OpenFile(path.to_path_buf())); } } } else { @@ -2316,7 +2435,7 @@ impl Tab { if let Some(clicked_item) = self.items_opt.as_ref().and_then(|items| items.get(click_i)) { - if let Some(Location::Path(path)) = &clicked_item.location_opt { + if let Some(path) = clicked_item.path_opt() { if clicked_item.metadata.is_dir() { //cd = Some(Location::Path(path.clone())); commands.push(Command::OpenInNewTab(path.clone())) @@ -2483,6 +2602,9 @@ impl Tab { Location::Path(path) => { commands.push(Command::OpenFile(path)); } + Location::Trash => { + commands.push(Command::OpenTrash); + } _ => {} } } else if location != self.location { @@ -2491,8 +2613,8 @@ impl Tab { Location::Search(path, _term) => path.is_dir(), _ => true, } { - let prev_path = if let Location::Path(path) = &self.location { - Some(path.clone()) + let prev_path = if let Some(path) = self.location.path_opt() { + Some(path.to_path_buf()) } else { None }; @@ -2968,7 +3090,7 @@ impl Tab { let mut children: Vec> = Vec::new(); match &self.location { - Location::Path(path) | Location::Search(path, ..) => { + Location::Desktop(path, _) | Location::Path(path) | Location::Search(path, _) => { let excess_str = "..."; let excess_width = text_width_body(excess_str); for (index, ancestor) in path.ancestors().enumerate() { @@ -3974,7 +4096,7 @@ impl Tab { } } - if let Some(Location::Path(path)) = item.location_opt.clone() { + if let Some(path) = item.path_opt().map(|path| path.to_path_buf()) { let mime = item.mime.clone(); subscriptions.push(subscription::channel( path.clone(),