diff --git a/.gitignore b/.gitignore index 54059777..12ee6d5a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ /target/ /vendor.tar /vendor/ + +.idea \ No newline at end of file diff --git a/i18n/en/cosmic_files.ftl b/i18n/en/cosmic_files.ftl index b150ffc1..8c9047a2 100644 --- a/i18n/en/cosmic_files.ftl +++ b/i18n/en/cosmic_files.ftl @@ -6,6 +6,7 @@ filesystem = Filesystem home = Home notification-in-progress = File operations are in progress. trash = Trash +bookmarks = Bookmarks undo = Undo # List view @@ -154,6 +155,7 @@ new-file = New file... new-folder = New folder... open-in-terminal = Open in terminal move-to-trash = Move to trash +add_remove_bookmark = Add/remove bookmark restore-from-trash = Restore from trash remove-from-sidebar = Remove from sidebar sort-by-name = Sort by name diff --git a/i18n/it/cosmic_files.ftl b/i18n/it/cosmic_files.ftl index 79c6b83d..e491bd41 100644 --- a/i18n/it/cosmic_files.ftl +++ b/i18n/it/cosmic_files.ftl @@ -6,6 +6,7 @@ filesystem = Filesystem home = Home notification-in-progress = Operazioni sui file in corso ... trash = Cestino +bookmarks = Segnalibri undo = Annulla ultima operazione # List view @@ -145,6 +146,7 @@ new-file = Nuovo file new-folder = Nuova cartella open-in-terminal = Apri in terminale move-to-trash = Sposta nel cestino +add_remove_bookmark = Aggiungi/rimuovi segnalibro restore-from-trash = Ripristina dal cestino remove-from-sidebar = Rimuovi da sidebar sort-by-name = Ordina per nome diff --git a/src/app.rs b/src/app.rs index 9041498f..726350aa 100644 --- a/src/app.rs +++ b/src/app.rs @@ -109,6 +109,7 @@ pub enum Action { ZoomDefault, ZoomIn, ZoomOut, + AddBookmark, } impl Action { @@ -168,6 +169,7 @@ impl Action { Action::ZoomDefault => Message::TabMessage(entity_opt, tab::Message::ZoomDefault), Action::ZoomIn => Message::TabMessage(entity_opt, tab::Message::ZoomIn), Action::ZoomOut => Message::TabMessage(entity_opt, tab::Message::ZoomOut), + Action::AddBookmark => Message::AddBookmark(entity_opt), } } } @@ -274,6 +276,7 @@ pub enum Message { DndExitTab, DndDropTab(Entity, Option, DndAction), DndDropNav(Entity, Option, DndAction), + AddBookmark(Option), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -527,6 +530,13 @@ impl App { fn update_nav_model(&mut self) { let mut nav_model = segmented_button::ModelBuilder::default(); + + nav_model = nav_model.insert(|b| { + b.text("Bookmark") + .icon(widget::icon::from_name("user-bookmarks-symbolic")) + .data(Location::Bookmarks) + }); + for (favorite_i, favorite) in self.config.favorites.iter().enumerate() { if let Some(path) = favorite.path_opt() { let name = if matches!(favorite, Favorite::Home) { @@ -2252,6 +2262,9 @@ impl Application for App { Some(Location::Trash) => { return self.open_tab(Location::Trash, false, None); } + Some(Location::Bookmarks) => { + return self.open_tab(Location::Bookmarks, false, None); + } _ => {} } } @@ -2295,6 +2308,21 @@ impl Application for App { self.dialog_pages.push_front(DialogPage::EmptyTrash); } }, + Message::AddBookmark(entity) => { + let selected_paths = self.selected_paths(entity); + let mut bookmarks = self.config.bookmarks.clone(); + + for path in selected_paths { + if !bookmarks.iter().any(|p| p == &path) { + bookmarks.push(path); + } + else { + bookmarks.retain(|p| p != &path); + } + } + + config_set!(bookmarks, bookmarks); + } } Command::none() diff --git a/src/config.rs b/src/config.rs index 96c5ed3f..f760e359 100644 --- a/src/config.rs +++ b/src/config.rs @@ -95,6 +95,7 @@ pub struct Config { pub app_theme: AppTheme, pub favorites: Vec, pub tab: TabConfig, + pub bookmarks: Vec, } impl Default for Config { @@ -110,6 +111,7 @@ impl Default for Config { Favorite::Videos, ], tab: TabConfig::default(), + bookmarks: Vec::new(), } } } diff --git a/src/menu.rs b/src/menu.rs index 68b08bab..2b7d3682 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -97,7 +97,7 @@ pub fn context_menu<'a>( let mut children: Vec> = Vec::new(); match tab.location { - Location::Path(_) | Location::Search(_, _) => { + Location::Path(_) | Location::Search(_, _) | Location::Bookmarks => { if selected > 0 { if selected_dir == 1 && selected == 1 || selected_dir == 0 { children.push(menu_item(fl!("open"), Action::Open).into()); @@ -141,6 +141,8 @@ pub fn context_menu<'a>( children.push(menu_item(fl!("add-to-sidebar"), Action::AddToSidebar).into()); children.push(container(horizontal_rule(1)).padding([0, 8]).into()); children.push(menu_item(fl!("move-to-trash"), Action::MoveToTrash).into()); + //TODO this should be add or remove if it already exist not only add + children.push(menu_item(fl!("add_remove_bookmark"), Action::AddBookmark).into()); } else { //TODO: need better designs for menu with no selection //TODO: have things like properties but they apply to the folder? diff --git a/src/tab.rs b/src/tab.rs index 3abf36e4..f8656922 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -1,38 +1,32 @@ -use cosmic::{ - cosmic_theme, font, - iced::{ - advanced::{ - graphics, - text::{self, Paragraph}, - }, - alignment::{Horizontal, Vertical}, - clipboard::dnd::DndAction, - futures::SinkExt, - keyboard::Modifiers, - subscription::{self, Subscription}, - //TODO: export in cosmic::widget - widget::{ - container, horizontal_rule, - scrollable::{AbsoluteOffset, Viewport}, - }, - Alignment, - Border, - Color, - ContentFit, - Length, - Point, - Rectangle, - Size, +use cosmic::{cosmic_config, cosmic_theme, font, iced::{ + advanced::{ + graphics, + text::{self, Paragraph}, }, - iced_core::widget::tree, - theme, widget, + alignment::{Horizontal, Vertical}, + clipboard::dnd::DndAction, + futures::SinkExt, + keyboard::Modifiers, + subscription::{self, Subscription}, + //TODO: export in cosmic::widget widget::{ - menu::{action::MenuAction, key_bind::KeyBind}, - vertical_space, DndDestination, DndSource, Id, Widget, + container, horizontal_rule, + scrollable::{AbsoluteOffset, Viewport}, }, - Element, -}; - + Alignment, + Border, + Color, + ContentFit, + Length, + Point, + Rectangle, + Size, +}, iced_core::widget::tree, theme, widget, widget::{ + menu::{action::MenuAction, key_bind::KeyBind}, + vertical_space, DndDestination, DndSource, Id, Widget, +}, Application, Element}; +use rayon::prelude::*; +use crate::config::{Config, CONFIG_VERSION}; use mime_guess::{mime, Mime}; use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; @@ -48,7 +42,8 @@ use std::{ sync::{Arc, Mutex}, time::{Duration, Instant}, }; - +use cosmic::cosmic_config::CosmicConfigEntry; +use rayon::iter::IntoParallelIterator; use crate::{ app::{self, Action}, clipboard::{ClipboardCopy, ClipboardKind, ClipboardPaste}, @@ -63,6 +58,7 @@ use crate::{ }; use unix_permissions_ext::UNIXPermissionsExt; use uzers::{get_group_by_gid, get_user_by_uid}; +use crate::app::App; pub const DOUBLE_CLICK_DURATION: Duration = Duration::from_millis(500); pub const HOVER_DURATION: Duration = Duration::from_millis(1600); @@ -162,8 +158,8 @@ pub fn folder_icon_symbolic(path: &PathBuf, icon_size: u16) -> widget::icon::Han "{}-symbolic", SPECIAL_DIRS.get(path).map_or("folder", |x| *x) )) - .size(icon_size) - .handle() + .size(icon_size) + .handle() } pub fn trash_icon_symbolic(icon_size: u16) -> widget::icon::Handle { @@ -179,8 +175,8 @@ pub fn trash_icon_symbolic(icon_size: u16) -> widget::icon::Handle { } else { "user-trash-symbolic" }) - .size(icon_size) - .handle() + .size(icon_size) + .handle() } //TODO: translate, add more levels? @@ -589,11 +585,55 @@ pub fn scan_trash(sizes: IconSizes) -> Vec { items } +pub fn scan_bookmarks(sizes: IconSizes) -> Vec { + let mut items: Vec = Vec::new(); + let bookmarks = match cosmic_config::Config::new(App::APP_ID, CONFIG_VERSION) { + Ok(config_handler) => { + let config = Config::get_entry(&config_handler).unwrap_or_else(|(errs, config)| { + log::info!("errors loading config: {:?}", errs); + config + }); + Some(config.bookmarks) + } + Err(err) => { + log::error!("failed to create config handler: {}", err); + None + } + }; + + bookmarks + .unwrap_or_default() + .into_par_iter() // Parallel iterator + .filter_map(|path| { + let name = match path.file_name() { + Some(name) => name.to_string_lossy().to_string(), + None => { + log::warn!( + "failed to parse entry at {:?} is not valid UTF-8", + path, + ); + return None; + } + }; + + let metadata = match path.metadata() { + Ok(ok) => ok, + Err(err) => { + log::warn!("failed to read metadata for entry at {:?}: {}", path, err); + return None; + } + }; + Some(item_from_entry(path, name, metadata, sizes)) + }) + .collect() +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum Location { Path(PathBuf), Search(PathBuf, String), Trash, + Bookmarks, } impl std::fmt::Display for Location { @@ -602,6 +642,7 @@ impl std::fmt::Display for Location { Self::Path(path) => write!(f, "{}", path.display()), Self::Search(path, term) => write!(f, "search {} for {}", path.display(), term), Self::Trash => write!(f, "trash"), + Self::Bookmarks => write!(f, "bookmarks"), } } } @@ -612,6 +653,7 @@ impl Location { Self::Path(path) => scan_path(path, sizes), Self::Search(path, term) => scan_search(path, term, sizes), Self::Trash => scan_trash(sizes), + Self::Bookmarks => scan_bookmarks(sizes), } } } @@ -810,12 +852,12 @@ impl Item { widget::text(&app.name).into() }, ]) - .spacing(space_xs), + .spacing(space_xs), ) - //TODO: do not clone so much? - .on_press(app::Message::OpenWith(path.clone(), app.clone())) - .padding(space_xs) - .width(Length::Fill), + //TODO: do not clone so much? + .on_press(app::Message::OpenWith(path.clone(), app.clone())) + .padding(space_xs) + .width(Length::Fill), ); } } @@ -885,7 +927,7 @@ impl Item { ))) .push(widget::text(format!( "({})", - format_permissions(metadata, PermissionOwner::Owner,) + format_permissions(metadata, PermissionOwner::Owner, ) ))) .spacing(10), ); @@ -899,7 +941,7 @@ impl Item { ))) .push(widget::text(format!( "({})", - format_permissions(metadata, PermissionOwner::Group,) + format_permissions(metadata, PermissionOwner::Group, ) ))) .spacing(10), ); @@ -909,7 +951,7 @@ impl Item { .push(widget::text(format!("{}", fl!("other")))) .push(widget::text(format!( "({})", - format_permissions(metadata, PermissionOwner::Other,) + format_permissions(metadata, PermissionOwner::Other, ) ))) .spacing(10), ); @@ -1067,6 +1109,9 @@ impl Tab { Location::Trash => { fl!("trash") } + Location::Bookmarks => { + fl!("bookmarks") + } } } @@ -1475,9 +1520,9 @@ impl Tab { } else { let dont_unset = mod_ctrl || self.column_sort().is_some_and(|l| { - l.iter() - .any(|(e_i, e)| Some(e_i) == click_i_opt.as_ref() && e.selected) - }); + l.iter() + .any(|(e_i, e)| Some(e_i) == click_i_opt.as_ref() && e.selected) + }); *self.cached_selected.borrow_mut() = None; if let Some(ref mut items) = self.items_opt { for (i, item) in items.iter_mut().enumerate() { @@ -1541,8 +1586,8 @@ impl Tab { Location::Search(ref path, _) => Some(path), _ => None, } - .and_then(|path| path.ancestors().nth(ancestor_index)) - .map(|path| path.to_path_buf()) + .and_then(|path| path.ancestors().nth(ancestor_index)) + .map(|path| path.to_path_buf()) }; match action { LocationMenuAction::OpenInNewTab(ancestor_index) => { @@ -1744,6 +1789,7 @@ impl Tab { Location::Trash => { cd = Some(location); } + Location::Bookmarks => cd = Some(location), } } Message::LocationUp => { @@ -1885,6 +1931,10 @@ impl Tab { Location::Trash => { log::warn!("Copy to trash is not supported."); } + Location::Bookmarks => { + // enable copy to add to bookmarks? + log::warn!("Copy to bookmarks is not supported"); + } }; } Message::Drop(None) => { @@ -1957,6 +2007,7 @@ impl Tab { Location::Path(path) => path.is_dir(), Location::Search(path, _term) => path.is_dir(), Location::Trash => true, + Location::Bookmarks => true, } { let prev_path = if let Location::Path(path) = &self.location { Some(path.clone()) @@ -2160,10 +2211,10 @@ impl Tab { ), heading_item(fl!("size"), Length::Fixed(size_width), HeadingOptions::Size), ]) - .align_items(Alignment::Center) - .height(Length::Fixed((space_m + 4).into())) - .padding([0, space_xxs]) - .spacing(space_xxs); + .align_items(Alignment::Center) + .height(Length::Fixed((space_m + 4).into())) + .padding([0, space_xxs]) + .spacing(space_xxs); if let Some(location) = &self.edit_location { match location { @@ -2204,7 +2255,7 @@ impl Tab { .style(theme::Button::Icon) .on_press(Message::EditLocation(Some(self.location.clone()))), ) - .on_middle_press(move |_| Message::OpenInNewTab(path.clone())), + .on_middle_press(move |_| Message::OpenInNewTab(path.clone())), ); w += 16.0 + 2.0 * space_xxs as f32; } else if let Location::Search(_, term) = &self.location { @@ -2216,10 +2267,10 @@ impl Tab { .into(), widget::text::body(term).wrap(text::Wrap::None).into(), ]) - .spacing(space_xxs), + .spacing(space_xxs), ) - .padding(space_xxs) - .style(theme::Button::Icon), + .padding(space_xxs) + .style(theme::Button::Icon), ); w += text_width_body(term) + 16.0 + 3.0 * space_xxs as f32; } @@ -2239,7 +2290,7 @@ impl Tab { &ancestor.to_path_buf(), 16, )) - .size(16); + .size(16); found_home = true; (fl!("home"), Some(icon)) } else { @@ -2353,6 +2404,21 @@ impl Tab { .into(), ); } + Location::Bookmarks => { + let mut row = widget::row::with_capacity(2) + .align_items(Alignment::Center) + .spacing(space_xxxs); + row = row.push(widget::icon::from_name("user-bookmarks-symbolic").size(16)); + row = row.push(widget::text::heading(fl!("bookmarks"))); + + children.push( + widget::button(row) + .padding(space_xxxs) + .on_press(Message::Location(Location::Bookmarks)) + .style(theme::Button::Text) + .into(), + ); + } } for child in children { @@ -2399,17 +2465,17 @@ impl Tab { } else { fl!("empty-folder") }) - .into(), + .into(), ]) - .align_items(Alignment::Center) - .spacing(space_xxs), + .align_items(Alignment::Center) + .spacing(space_xxs), ) - .align_x(Horizontal::Center) - .align_y(Vertical::Center) - .width(Length::Fill) - .height(Length::Fill) - .into()]) - .into() + .align_x(Horizontal::Center) + .align_y(Vertical::Center) + .width(Length::Fill) + .height(Length::Fill) + .into()]) + .into() } pub fn grid_view( @@ -2500,8 +2566,8 @@ impl Tab { .content_fit(ContentFit::Contain) .size(icon_sizes.grid()), ) - .padding(space_xxxs) - .style(button_style(item.selected, false, false)), + .padding(space_xxxs) + .style(button_style(item.selected, false, false)), widget::button(widget::text::body(&item.grid_name)) .id(item.button_id.clone()) .padding([0, space_xxxs]) @@ -2547,31 +2613,31 @@ impl Tab { Message::Drop(None) } }) - .on_enter(move |_, _, _| Message::DndEnter(tab_location_enter.clone())) - .on_leave(move || Message::DndLeave(tab_location_leave.clone())), + .on_enter(move |_, _, _| Message::DndEnter(tab_location_enter.clone())) + .on_leave(move || Message::DndLeave(tab_location_leave.clone())), ) - .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 + .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() }) - } else { - theme::Container::default() - }) - .into() + .into() } else { column.into() }; @@ -2656,13 +2722,13 @@ impl Tab { .content_fit(ContentFit::Contain) .size(icon_sizes.grid()), ) - .on_press(Message::Click(Some(*i))) - .padding(space_xxxs) - .style(button_style( - item.selected, - false, - false, - )), + .on_press(Message::Click(Some(*i))) + .padding(space_xxxs) + .style(button_style( + item.selected, + false, + false, + )), widget::button(widget::text(item.grid_name.clone())) .id(item.button_id.clone()) .on_press(Message::Click(Some(*i))) @@ -2694,12 +2760,12 @@ impl Tab { mouse_area::MouseArea::new( widget::container(widget::column::with_children(children)).width(Length::Fill), ) - .on_press(|_| Message::Click(None)) - .on_drag(Message::Drag) - .on_drag_end(|_| Message::DragEnd(None)) - .show_drag_rect(true) - .on_release(|_| Message::ClickRelease(None)) - .into(), + .on_press(|_| Message::Click(None)) + .on_drag(Message::Drag) + .on_drag_end(|_| Message::DragEnd(None)) + .show_drag_rect(true) + .on_release(|_| Message::ClickRelease(None)) + .into(), true, ) } @@ -2811,10 +2877,10 @@ impl Tab { widget::text::caption(format!("{} - {}", modified_text, size_text)) .into(), ]) - .into(), + .into(), ]) - .align_items(Alignment::Center) - .spacing(space_xxs) + .align_items(Alignment::Center) + .spacing(space_xxs) } else { widget::row::with_children(vec![ widget::icon::icon(item.icon_handle_list.clone()) @@ -2829,8 +2895,8 @@ impl Tab { .width(Length::Fixed(size_width)) .into(), ]) - .align_items(Alignment::Center) - .spacing(space_xxs) + .align_items(Alignment::Center) + .spacing(space_xxs) }; let button = |row| { @@ -2850,10 +2916,10 @@ impl Tab { }) .style(button_style(item.selected, true, false)), ) - .on_press(move |_| Message::Click(Some(i))) - .on_double_click(move |_| Message::DoubleClick(Some(i))) - .on_release(move |_| Message::ClickRelease(Some(i))) - .on_middle_press(move |_| Message::MiddleClick(i)); + .on_press(move |_| Message::Click(Some(i))) + .on_double_click(move |_| Message::DoubleClick(Some(i))) + .on_release(move |_| Message::ClickRelease(Some(i))) + .on_middle_press(move |_| Message::MiddleClick(i)); if self.context_menu.is_some() { mouse_area @@ -2888,32 +2954,32 @@ impl Tab { Message::Drop(None) } }) - .on_enter(move |_, _, _| Message::DndEnter(tab_location_enter.clone())) - .on_leave(move || Message::DndLeave(tab_location_leave.clone())), + .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 + // 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() }) - } else { - theme::Container::default() - }) - .into() + .into() } else { button_row.into() }; @@ -2932,11 +2998,11 @@ impl Tab { //TODO: translate? widget::text(format!("{} - {}", modified_text, size_text)).into(), ]) - .into(), + .into(), ]) - .align_items(Alignment::Center) - .spacing(space_xxs) - .into() + .align_items(Alignment::Center) + .spacing(space_xxs) + .into() } else { widget::row::with_children(vec![ widget::icon::icon(item.icon_handle_list.clone()) @@ -2951,9 +3017,9 @@ impl Tab { .width(Length::Fixed(size_width)) .into(), ]) - .align_items(Alignment::Center) - .spacing(space_xxs) - .into() + .align_items(Alignment::Center) + .spacing(space_xxs) + .into() }; if item.selected { drag_items.push( @@ -3001,13 +3067,13 @@ impl Tab { mouse_area::MouseArea::new( widget::column::with_children(children).padding([0, space_s]), ) - .with_id(Id::new("list-view")) - .on_press(|_| Message::Click(None)) - .on_drag(Message::Drag) - .on_drag_end(|_| Message::DragEnd(None)) - .show_drag_rect(true) - .on_release(|_| Message::ClickRelease(None)) - .into(), + .with_id(Id::new("list-view")) + .on_press(|_| Message::Click(None)) + .on_drag(Message::Drag) + .on_drag_end(|_| Message::DragEnd(None)) + .show_drag_rect(true) + .on_release(|_| Message::ClickRelease(None)) + .into(), true, ) } @@ -3107,8 +3173,8 @@ impl Tab { .on_press(Message::EmptyTrash) .into(), ])) - .padding([space_xxs, space_xs]) - .layer(cosmic_theme::Layer::Primary), + .padding([space_xxs, space_xs]) + .layer(cosmic_theme::Layer::Primary), ); } } @@ -3150,8 +3216,8 @@ impl Tab { Message::Drop(None) } }) - .on_enter(move |_, _, _| Message::DndEnter(tab_location_2.clone())) - .on_leave(move || Message::DndLeave(tab_location_3.clone())); + .on_enter(move |_, _, _| Message::DndEnter(tab_location_2.clone())) + .on_leave(move || Message::DndLeave(tab_location_3.clone())); dnd_dest.into() } @@ -3233,8 +3299,8 @@ impl Tab { log::info!("thumbnailed {:?} in {:?}", path, start.elapsed()); (path, thumbnail) }) - .await - .unwrap(); + .await + .unwrap(); match output .send(Message::Thumbnail(path.clone(), thumbnail))