diff --git a/examples/dialog.rs b/examples/dialog.rs new file mode 100644 index 0000000..11d7344 --- /dev/null +++ b/examples/dialog.rs @@ -0,0 +1,3 @@ +fn main() -> Result<(), Box> { + cosmic_files::dialog() +} diff --git a/src/app.rs b/src/app.rs index 0a11f1e..f5247b4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -94,7 +94,6 @@ impl Action { /// Messages that are used specifically by our [`App`]. #[derive(Clone, Debug)] pub enum Message { - Todo, AppTheme(AppTheme), Config(Config), Copy(Option), @@ -541,9 +540,6 @@ impl Application for App { } match message { - Message::Todo => { - log::warn!("TODO"); - } Message::AppTheme(app_theme) => { config_set!(app_theme, app_theme); return self.update_config(); diff --git a/src/dialog.rs b/src/dialog.rs new file mode 100644 index 0000000..53e7a20 --- /dev/null +++ b/src/dialog.rs @@ -0,0 +1,502 @@ +// Copyright 2023 System76 +// SPDX-License-Identifier: GPL-3.0-only + +use cosmic::{ + app::{message, Command, Core}, + cosmic_theme, executor, + iced::{ + event, + futures::{self, SinkExt}, + keyboard::{Event as KeyEvent, Modifiers}, + subscription::{self, Subscription}, + window, Event, Length, + }, + style, + widget::{self, segmented_button}, + Application, ApplicationExt, Element, +}; +use notify::Watcher; +use std::{any::TypeId, collections::HashSet, path::PathBuf, time}; + +use crate::{ + fl, home_dir, + tab::{self, Location, Tab}, +}; + +#[derive(Clone, Debug)] +pub struct Flags {} + +/// Messages that are used specifically by our [`App`]. +#[derive(Clone, Debug)] +pub enum Message { + Modifiers(Modifiers), + NotifyEvent(notify::Event), + NotifyWatcher(WatcherWrapper), + SelectAll(Option), + TabActivate(segmented_button::Entity), + TabClose(Option), + TabMessage(Option, tab::Message), + TabRescan(segmented_button::Entity, Vec), +} + +#[derive(Debug)] +pub struct WatcherWrapper { + watcher_opt: Option, +} + +impl Clone for WatcherWrapper { + fn clone(&self) -> Self { + Self { watcher_opt: None } + } +} + +impl PartialEq for WatcherWrapper { + fn eq(&self, _other: &Self) -> bool { + false + } +} + +/// The [`App`] stores application-specific state. +pub struct App { + core: Core, + nav_model: segmented_button::SingleSelectModel, + tab_model: segmented_button::Model, + modifiers: Modifiers, + watcher_opt: Option<(notify::RecommendedWatcher, HashSet)>, +} + +impl App { + fn open_tab(&mut self, location: Location) -> Command { + let mut tab = Tab::new(location.clone()); + tab.dialog = true; + let entity = self + .tab_model + .insert() + .text(tab.title()) + .data(tab) + .closable() + .activate() + .id(); + Command::batch([ + self.update_title(), + self.update_watcher(), + self.rescan_tab(entity, location), + ]) + } + + fn rescan_tab( + &mut self, + entity: segmented_button::Entity, + location: Location, + ) -> Command { + Command::perform( + async move { + match tokio::task::spawn_blocking(move || location.scan()).await { + Ok(items) => message::app(Message::TabRescan(entity, items)), + Err(err) => { + log::warn!("failed to rescan: {}", err); + message::none() + } + } + }, + |x| x, + ) + } + + fn update_title(&mut self) -> Command { + let (header_title, window_title) = match self.tab_model.text(self.tab_model.active()) { + Some(tab_title) => ( + tab_title.to_string(), + format!("{tab_title} — COSMIC File Manager"), + ), + None => (String::new(), "COSMIC File Manager".to_string()), + }; + self.set_header_title(header_title); + self.set_window_title(window_title) + } + + fn update_watcher(&mut self) -> Command { + if let Some((mut watcher, old_paths)) = self.watcher_opt.take() { + 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()); + } + } + } + + // Unwatch paths no longer used + for path in old_paths.iter() { + if !new_paths.contains(path) { + match watcher.unwatch(path) { + Ok(()) => { + log::debug!("unwatching {:?}", path); + } + Err(err) => { + log::debug!("failed to unwatch {:?}: {}", path, err); + } + } + } + } + + // Watch new paths + for path in new_paths.iter() { + if !old_paths.contains(path) { + //TODO: should this be recursive? + match watcher.watch(path, notify::RecursiveMode::NonRecursive) { + Ok(()) => { + log::debug!("watching {:?}", path); + } + Err(err) => { + log::debug!("failed to watch {:?}: {}", path, err); + } + } + } + } + + self.watcher_opt = Some((watcher, new_paths)); + } + + //TODO: should any of this run in a command? + Command::none() + } +} + +/// Implement [`Application`] to integrate with COSMIC. +impl Application for App { + /// Default async executor to use with the app. + type Executor = executor::Default; + + /// Argument received + type Flags = Flags; + + /// Message type specific to our [`App`]. + type Message = Message; + + /// The unique application ID to supply to the window manager. + const APP_ID: &'static str = "com.system76.CosmicFiles"; + + fn core(&self) -> &Core { + &self.core + } + + fn core_mut(&mut self) -> &mut Core { + &mut self.core + } + + /// Creates the application, and optionally emits command on initialize. + fn init(mut core: Core, flags: Self::Flags) -> (Self, Command) { + core.window.show_maximize = false; + core.window.show_minimize = false; + + let mut nav_model = segmented_button::ModelBuilder::default(); + if let Some(dir) = dirs::home_dir() { + nav_model = nav_model.insert(move |b| { + b.text(fl!("home")) + .icon(widget::icon::icon(tab::folder_icon_symbolic(&dir, 16)).size(16)) + .data(Location::Path(dir.clone())) + }); + } + //TODO: Sort by name? + for dir_opt in &[ + dirs::document_dir(), + dirs::download_dir(), + dirs::audio_dir(), + dirs::picture_dir(), + dirs::video_dir(), + ] { + if let Some(dir) = dir_opt { + if let Some(file_name) = dir.file_name().and_then(|x| x.to_str()) { + nav_model = nav_model.insert(move |b| { + b.text(file_name.to_string()) + .icon(widget::icon::icon(tab::folder_icon_symbolic(&dir, 16)).size(16)) + .data(Location::Path(dir.clone())) + }); + } + } + } + + let mut app = App { + core, + nav_model: nav_model.build(), + tab_model: segmented_button::ModelBuilder::default().build(), + modifiers: Modifiers::empty(), + watcher_opt: None, + }; + + let mut commands = Vec::new(); + + if app.tab_model.iter().next().is_none() { + commands.push(app.open_tab(Location::Path(home_dir()))); + } + + (app, Command::batch(commands)) + } + + // The default nav_bar widget needs to have its width reduced for cosmic-files + fn nav_bar(&self) -> Option>> { + if !self.core().nav_bar_active() { + return None; + } + + let nav_model = self.nav_model()?; + + let mut nav = widget::nav_bar(nav_model, |entity| { + message::cosmic(cosmic::app::cosmic::Message::NavBar(entity)) + }); + + if !self.core().is_condensed() { + nav = nav.max_width(200); + } + + Some(Element::from(nav)) + } + + fn nav_model(&self) -> Option<&segmented_button::SingleSelectModel> { + Some(&self.nav_model) + } + + fn on_nav_select(&mut self, entity: segmented_button::Entity) -> Command { + let location_opt = self.nav_model.data::(entity).clone(); + + if let Some(location) = location_opt { + let message = Message::TabMessage(None, tab::Message::Location(location.clone())); + return self.update(message); + } + + Command::none() + } + + /// Handle application events here. + fn update(&mut self, message: Self::Message) -> Command { + match message { + Message::Modifiers(modifiers) => { + self.modifiers = modifiers; + } + Message::NotifyEvent(event) => { + log::debug!("{:?}", event); + + let mut needs_reload = Vec::new(); + for entity in self.tab_model.iter() { + if let Some(tab) = self.tab_model.data::(entity) { + //TODO: support reloading trash, somehow + if let Location::Path(path) = &tab.location { + let mut contains_change = false; + for event_path in event.paths.iter() { + if event_path.starts_with(&path) { + contains_change = true; + break; + } + } + if contains_change { + 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)); + } + return Command::batch(commands); + } + Message::NotifyWatcher(mut watcher_wrapper) => match watcher_wrapper.watcher_opt.take() + { + Some(mut watcher) => { + self.watcher_opt = Some((watcher, HashSet::new())); + return self.update_watcher(); + } + None => { + log::warn!("message did not contain notify watcher"); + } + }, + Message::SelectAll(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 Some(ref mut items) = tab.items_opt { + for item in items.iter_mut() { + if item.hidden { + //TODO: option to show hidden files + continue; + } + item.selected = true; + item.click_time = None; + } + } + } + } + Message::TabActivate(entity) => { + self.tab_model.activate(entity); + return self.update_title(); + } + Message::TabClose(entity_opt) => { + let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); + + // Activate closest item + if let Some(position) = self.tab_model.position(entity) { + if position > 0 { + self.tab_model.activate_position(position - 1); + } else { + self.tab_model.activate_position(position + 1); + } + } + + // Remove item + self.tab_model.remove(entity); + + // If that was the last tab, close window + if self.tab_model.iter().next().is_none() { + return window::close(window::Id::MAIN); + } + + return Command::batch([self.update_title(), self.update_watcher()]); + } + Message::TabMessage(entity_opt, tab_message) => { + let entity = entity_opt.unwrap_or_else(|| self.tab_model.active()); + + let mut update_opt = None; + match self.tab_model.data_mut::(entity) { + Some(tab) => { + if tab.update(tab_message, self.modifiers) { + update_opt = Some((tab.title(), tab.location.clone())); + } + } + _ => (), + } + if let Some((tab_title, tab_path)) = update_opt { + self.tab_model.text_set(entity, tab_title); + return Command::batch([ + self.update_title(), + self.update_watcher(), + self.rescan_tab(entity, tab_path), + ]); + } + } + Message::TabRescan(entity, items) => match self.tab_model.data_mut::(entity) { + Some(tab) => { + tab.items_opt = Some(items); + } + _ => (), + }, + } + + Command::none() + } + + /// Creates a view after each update. + fn view(&self) -> Element { + let cosmic_theme::Spacing { space_xxs, .. } = self.core().system_theme().cosmic().spacing; + + let mut tab_column = widget::column::with_capacity(1); + + if self.tab_model.iter().count() > 1 { + tab_column = tab_column.push( + widget::container( + widget::view_switcher::horizontal(&self.tab_model) + .button_height(32) + .button_spacing(space_xxs) + .on_activate(Message::TabActivate) + .on_close(|entity| Message::TabClose(Some(entity))), + ) + .style(style::Container::Background) + .width(Length::Fill), + ); + } + + let entity = self.tab_model.active(); + match self.tab_model.data::(entity) { + Some(tab) => { + tab_column = tab_column.push( + tab.list_view(self.core()) + .map(move |message| Message::TabMessage(Some(entity), message)), + ); + } + None => { + //TODO + } + } + + let content: Element<_> = tab_column.into(); + + // Uncomment to debug layout: + //content.explain(cosmic::iced::Color::WHITE) + content + } + + fn subscription(&self) -> Subscription { + struct WatcherSubscription; + + Subscription::batch([ + event::listen_with(|event, _status| match event { + Event::Keyboard(KeyEvent::ModifiersChanged(modifiers)) => { + Some(Message::Modifiers(modifiers)) + } + _ => None, + }), + subscription::channel( + TypeId::of::(), + 100, + |mut output| async move { + let watcher_res = { + let mut output = output.clone(); + //TODO: debounce + notify::recommended_watcher( + move |event_res: Result| match event_res { + Ok(event) => { + match &event.kind { + notify::EventKind::Access(_) + | notify::EventKind::Modify( + notify::event::ModifyKind::Metadata(_), + ) => { + // Data not mutated + return; + } + _ => {} + } + + match futures::executor::block_on(async { + output.send(Message::NotifyEvent(event)).await + }) { + Ok(()) => {} + Err(err) => { + log::warn!("failed to send notify event: {:?}", err); + } + } + } + Err(err) => { + log::warn!("failed to watch files: {:?}", err); + } + }, + ) + }; + + match watcher_res { + Ok(watcher) => { + match output + .send(Message::NotifyWatcher(WatcherWrapper { + watcher_opt: Some(watcher), + })) + .await + { + Ok(()) => {} + Err(err) => { + log::warn!("failed to send notify watcher: {:?}", err); + } + } + } + Err(err) => { + log::warn!("failed to create file watcher: {:?}", err); + } + } + + //TODO: how to properly kill this task? + loop { + tokio::time::sleep(time::Duration::new(1, 0)).await; + } + }, + ), + ]) + } +} diff --git a/src/lib.rs b/src/lib.rs index d005473..d60a87d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ use app::{App, Flags}; mod app; use config::{Config, CONFIG_VERSION}; mod config; +mod dialog; mod key_bind; mod localize; mod menu; @@ -29,6 +30,24 @@ pub fn home_dir() -> PathBuf { } } +/// Runs application with these settings +pub fn dialog() -> Result<(), Box> { + localize::localize(); + + let mut settings = Settings::default(); + + #[cfg(target_os = "redox")] + { + // Redox does not support resize if doing CSDs + settings = settings.client_decorations(false); + } + + let flags = dialog::Flags {}; + cosmic::app::run::(settings, flags)?; + + Ok(()) +} + /// Runs application with these settings #[rustfmt::skip] pub fn main() -> Result<(), Box> { diff --git a/src/tab.rs b/src/tab.rs index 9a5938f..9e252ed 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -27,6 +27,7 @@ use crate::{fl, mime_icon::mime_icon}; const DOUBLE_CLICK_DURATION: Duration = Duration::from_millis(500); //TODO: configurable +const ICON_SIZE_DIALOG: u16 = 16; const ICON_SIZE_LIST: u16 = 32; const ICON_SIZE_GRID: u16 = 64; static SPECIAL_DIRS: Lazy> = Lazy::new(|| { @@ -228,13 +229,16 @@ pub fn scan_path(tab_path: &PathBuf) -> Vec { let path = entry.path(); //TODO: configurable size - let (icon_handle_grid, icon_handle_list) = if metadata.is_dir() { + let (icon_handle_dialog, icon_handle_grid, icon_handle_list) = if metadata.is_dir() + { ( + folder_icon(&path, ICON_SIZE_DIALOG), folder_icon(&path, ICON_SIZE_GRID), folder_icon(&path, ICON_SIZE_LIST), ) } else { ( + mime_icon(&path, ICON_SIZE_DIALOG), mime_icon(&path, ICON_SIZE_GRID), mime_icon(&path, ICON_SIZE_LIST), ) @@ -258,6 +262,7 @@ pub fn scan_path(tab_path: &PathBuf) -> Vec { metadata: ItemMetadata::Path { metadata, children }, hidden, path, + icon_handle_dialog, icon_handle_grid, icon_handle_list, selected: false, @@ -319,12 +324,14 @@ pub fn scan_trash() -> Vec { let name = entry.name.clone(); //TODO: configurable size - let (icon_handle_grid, icon_handle_list) = match metadata.size { + let (icon_handle_dialog, icon_handle_grid, icon_handle_list) = match metadata.size { trash::TrashItemSize::Entries(_) => ( + folder_icon(&path, ICON_SIZE_DIALOG), folder_icon(&path, ICON_SIZE_GRID), folder_icon(&path, ICON_SIZE_LIST), ), trash::TrashItemSize::Bytes(_) => ( + mime_icon(&path, ICON_SIZE_DIALOG), mime_icon(&path, ICON_SIZE_GRID), mime_icon(&path, ICON_SIZE_LIST), ), @@ -335,6 +342,7 @@ pub fn scan_trash() -> Vec { metadata: ItemMetadata::Trash { metadata, entry }, hidden: false, path, + icon_handle_dialog, icon_handle_grid, icon_handle_list, selected: false, @@ -410,6 +418,7 @@ pub struct Item { pub metadata: ItemMetadata, pub hidden: bool, pub path: PathBuf, + pub icon_handle_dialog: widget::icon::Handle, pub icon_handle_grid: widget::icon::Handle, pub icon_handle_list: widget::icon::Handle, pub selected: bool, @@ -507,10 +516,10 @@ pub enum View { #[derive(Clone, Debug)] pub struct Tab { pub location: Location, - //TODO pub context_menu: Option, pub items_opt: Option>, pub view: View, + pub dialog: bool, pub edit_location: Option, pub history_i: usize, pub history: Vec, @@ -524,6 +533,7 @@ impl Tab { context_menu: None, items_opt: None, view: View::List, + dialog: false, edit_location: None, history_i: 0, history, @@ -879,11 +889,12 @@ impl Tab { return self.empty_view(hidden > 0, core); } } - widget::scrollable(widget::column::with_children(vec![ + widget::column::with_children(vec![ self.location_view(core), - widget::flex_row(children).into(), - ])) - .width(Length::Fill) + widget::scrollable(widget::flex_row(children)) + .width(Length::Fill) + .into(), + ]) .into() } @@ -895,8 +906,6 @@ impl Tab { let mut children: Vec> = Vec::new(); - children.push(self.location_view(core)); - children.push( widget::row::with_children(vec![ widget::text::heading(fl!("name")) @@ -966,9 +975,15 @@ impl Tab { //TODO: align columns let button = widget::button( widget::row::with_children(vec![ - widget::icon::icon(item.icon_handle_list.clone()) - .size(ICON_SIZE_LIST) - .into(), + if self.dialog { + widget::icon::icon(item.icon_handle_dialog.clone()) + .size(ICON_SIZE_DIALOG) + .into() + } else { + widget::icon::icon(item.icon_handle_list.clone()) + .size(ICON_SIZE_LIST) + .into() + }, widget::text(item.name.clone()).width(Length::Fill).into(), widget::text(modified_text).width(column_width).into(), widget::text(size_text).width(column_width).into(), @@ -994,12 +1009,17 @@ impl Tab { return self.empty_view(hidden > 0, core); } } - widget::scrollable( - widget::column::with_children(children) - // Hack to make room for scroll bar - .padding([0, space_xxs, 0, 0]), - ) - .width(Length::Fill) + + widget::column::with_children(vec![ + self.location_view(core).into(), + widget::scrollable( + widget::column::with_children(children) + // Hack to make room for scroll bar + .padding([0, space_xxs, 0, 0]), + ) + .width(Length::Fill) + .into(), + ]) .into() }