From 5d596239be1fd99a3bbce2cb9deabe8376db88c1 Mon Sep 17 00:00:00 2001 From: Jeremy Soller Date: Tue, 20 Aug 2024 13:26:10 -0600 Subject: [PATCH] Desktop mode --- Cargo.lock | 8 + Cargo.toml | 8 +- cosmic-files-applet/Cargo.toml | 9 + cosmic-files-applet/src/main.rs | 3 + i18n/en/cosmic_files.ftl | 1 - justfile | 8 +- src/app.rs | 281 +++++++++++++++++++++------- src/dialog.rs | 58 +++--- src/lib.rs | 64 ++++++- src/menu.rs | 57 +++++- src/mime_app.rs | 46 ++--- src/tab.rs | 315 ++++++++++++++++++++++++-------- 12 files changed, 640 insertions(+), 218 deletions(-) create mode 100644 cosmic-files-applet/Cargo.toml create mode 100644 cosmic-files-applet/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 561d96d..d13cfdb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1286,11 +1286,19 @@ dependencies = [ "uzers", "vergen", "walkdir", + "wayland-client", "xdg", "xdg-mime", "zip", ] +[[package]] +name = "cosmic-files-applet" +version = "0.1.0" +dependencies = [ + "cosmic-files", +] + [[package]] name = "cosmic-protocols" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 0b027a1..c0b4941 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ tokio = { version = "1", features = ["sync"] } trash = { git = "https://github.com/jackpot51/trash-rs.git", branch = "delete-info" } url = "2.5" walkdir = "2.5.0" +wayland-client = { version = "0.31.5", optional = true } xdg = { version = "2.5.2", optional = true } xdg-mime = "0.3" # Internationalization @@ -58,7 +59,7 @@ uzers = "0.12.0" [dependencies.libcosmic] git = "https://github.com/pop-os/libcosmic.git" default-features = false -features = ["a11y", "multi-window", "tokio"] +features = ["a11y", "clipboard", "multi-window", "tokio"] [dependencies.smol_str] version = "0.2.1" @@ -69,7 +70,7 @@ default = ["desktop", "gvfs", "notify", "winit", "wgpu"] desktop = ["libcosmic/desktop", "dep:freedesktop_entry_parser", "dep:xdg"] gvfs = ["dep:gio", "dep:glib"] notify = ["dep:notify-rust"] -wayland = ["libcosmic/wayland"] +wayland = ["libcosmic/wayland", "dep:wayland-client"] winit = ["libcosmic/winit"] wgpu = ["libcosmic/wgpu"] @@ -108,3 +109,6 @@ filetime = { git = "https://github.com/jackpot51/filetime" } # [patch.'https://github.com/pop-os/smithay-clipboard'] # smithay-clipboard = { path = "../smithay-clipboard" } + +[workspace] +members = ["cosmic-files-applet"] diff --git a/cosmic-files-applet/Cargo.toml b/cosmic-files-applet/Cargo.toml new file mode 100644 index 0000000..b2a64e6 --- /dev/null +++ b/cosmic-files-applet/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "cosmic-files-applet" +version = "0.1.0" +edition = "2021" + +[dependencies.cosmic-files] +path = ".." +default-features = false +features = ["desktop", "gvfs", "wayland", "wgpu"] \ No newline at end of file diff --git a/cosmic-files-applet/src/main.rs b/cosmic-files-applet/src/main.rs new file mode 100644 index 0000000..8ac8491 --- /dev/null +++ b/cosmic-files-applet/src/main.rs @@ -0,0 +1,3 @@ +fn main() -> Result<(), Box> { + cosmic_files::desktop() +} diff --git a/i18n/en/cosmic_files.ftl b/i18n/en/cosmic_files.ftl index 61086f1..8778c23 100644 --- a/i18n/en/cosmic_files.ftl +++ b/i18n/en/cosmic_files.ftl @@ -131,7 +131,6 @@ restored = Restored {$items} {$items -> [one] item *[other] items } from {trash} -undo = Undo unknown-folder = unknown folder ## Open with diff --git a/justfile b/justfile index a36f6d7..0698d2f 100644 --- a/justfile +++ b/justfile @@ -12,6 +12,10 @@ cargo-target-dir := env('CARGO_TARGET_DIR', 'target') bin-src := cargo-target-dir / 'release' / name bin-dst := base-dir / 'bin' / name +applet-name := name + '-applet' +applet-src := cargo-target-dir / 'release' / applet-name +applet-dst := base-dir / 'bin' / applet-name + desktop := APPID + '.desktop' desktop-src := 'res' / desktop desktop-dst := clean(rootdir / prefix) / 'share' / 'applications' / desktop @@ -40,6 +44,7 @@ clean-dist: clean clean-vendor # Compiles with debug profile build-debug *args: cargo build {{args}} + cargo build --package {{applet-name}} {{args}} # Compiles with release profile build-release *args: (build-debug '--release' args) @@ -71,6 +76,7 @@ test *args: # Installs files install: install -Dm0755 {{bin-src}} {{bin-dst}} + install -Dm0755 {{applet-src}} {{applet-dst}} install -Dm0644 {{desktop-src}} {{desktop-dst}} install -Dm0644 {{metainfo-src}} {{metainfo-dst}} for size in `ls {{icons-src}}`; do \ @@ -79,7 +85,7 @@ install: # Uninstalls installed files uninstall: - rm {{bin-dst}} + rm -f {{bin-dst}} {{applet-dst}} # Vendor dependencies locally vendor: diff --git a/src/app.rs b/src/app.rs index ac9874b..81f1c17 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,8 +1,19 @@ // Copyright 2023 System76 // SPDX-License-Identifier: GPL-3.0-only +#[cfg(feature = "wayland")] +use cosmic::iced::{ + event::wayland::{Event as WaylandEvent, OutputEvent}, + wayland::{ + actions::layer_surface::{IcedMargin, IcedOutput, SctkLayerSurfaceSettings}, + layer_surface::{ + destroy_layer_surface, get_layer_surface, Anchor, KeyboardInteractivity, Layer, + }, + }, + Limits, +}; use cosmic::{ - app::{message, Command, Core}, + app::{self, message, Command, Core}, cosmic_config, cosmic_theme, executor, iced::{ clipboard::dnd::DndAction, @@ -10,8 +21,7 @@ use cosmic::{ futures::{self, SinkExt}, keyboard::{Event as KeyEvent, Key, Modifiers}, subscription::{self, Subscription}, - widget::scrollable, - window::{self, Event as WindowEvent}, + window::{self, Event as WindowEvent, Id as WindowId}, Alignment, Event, Length, }, iced_runtime::clipboard, @@ -43,6 +53,8 @@ use std::{ }; use tokio::sync::mpsc; use trash::TrashItem; +#[cfg(feature = "wayland")] +use wayland_client::{protocol::wl_output::WlOutput, Proxy}; use crate::{ clipboard::{ClipboardCopy, ClipboardKind, ClipboardPaste}, @@ -50,17 +62,25 @@ use crate::{ fl, home_dir, key_bind::key_binds, localize::LANGUAGE_SORTER, - menu, mime_app, + menu, mime_app, mime_icon, mounter::{mounters, MounterItem, MounterItems, MounterKey, Mounters}, operation::{Operation, ReplaceResult}, spawn_detached::spawn_detached, tab::{self, HeadingOptions, ItemMetadata, Location, Tab, HOVER_DURATION}, }; +#[derive(Clone, Debug)] +pub enum Mode { + App, + Desktop, +} + #[derive(Clone, Debug)] pub struct Flags { pub config_handler: Option, pub config: Config, + pub mode: Mode, + pub locations: Vec, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -280,6 +300,10 @@ pub enum Message { DndDropTab(Entity, Option, DndAction), DndDropNav(Entity, Option, DndAction), Recents, + #[cfg(feature = "wayland")] + OutputEvent(OutputEvent, WlOutput), + Cosmic(app::cosmic::Message), + None, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -393,6 +417,7 @@ pub struct App { tab_model: segmented_button::Model, config_handler: Option, config: Config, + mode: Mode, app_themes: Vec, default_view: Vec, sort_by_names: Vec, @@ -413,6 +438,10 @@ pub struct App { search_active: bool, search_id: widget::Id, search_input: String, + #[cfg(feature = "wayland")] + surface_ids: HashMap, + #[cfg(feature = "wayland")] + surface_names: HashMap, toasts: widget::toaster::Toasts, watcher_opt: Option<(Debouncer, HashSet)>, window_id_opt: Option, @@ -429,7 +458,11 @@ impl App { activate: bool, selection_path: Option, ) -> Command { - let tab = Tab::new(location.clone(), self.config.tab); + let mut tab = Tab::new(location.clone(), self.config.tab); + tab.mode = match self.mode { + Mode::App => tab::Mode::App, + Mode::Desktop => tab::Mode::Desktop, + }; let entity = self .tab_model .insert() @@ -1043,6 +1076,19 @@ impl Application for App { /// Creates the application, and optionally emits command on initialize. fn init(mut core: Core, flags: Self::Flags) -> (Self, Command) { + match flags.mode { + Mode::App => {} + Mode::Desktop => { + core.window.content_container = false; + core.window.show_window_menu = false; + core.window.show_headerbar = false; + core.window.sharp_corners = false; + core.window.show_maximize = false; + core.window.show_minimize = false; + core.window.use_template = true; + } + } + let app_themes = vec![fl!("match-desktop"), fl!("dark"), fl!("light")]; let mut app = App { @@ -1052,6 +1098,7 @@ impl Application for App { tab_model: segmented_button::ModelBuilder::default().build(), config_handler: flags.config_handler, config: flags.config, + mode: flags.mode, app_themes, default_view: vec![fl!("grid-view"), fl!("list-view")], sort_by_names: HeadingOptions::names(), @@ -1072,6 +1119,10 @@ impl Application for App { search_active: false, search_id: widget::Id::unique(), search_input: String::new(), + #[cfg(feature = "wayland")] + surface_ids: HashMap::new(), + #[cfg(feature = "wayland")] + surface_names: HashMap::new(), toasts: widget::toaster::Toasts::new(Message::CloseToast), watcher_opt: None, window_id_opt: Some(window::Id::MAIN), @@ -1083,18 +1134,7 @@ impl Application for App { let mut commands = vec![app.update_config()]; - for arg in env::args().skip(1) { - let location = if &arg == "--trash" { - Location::Trash - } else { - match fs::canonicalize(&arg) { - Ok(absolute) => Location::Path(absolute), - Err(err) => { - log::warn!("failed to canonicalize {:?}: {}", arg, err); - continue; - } - } - }; + for location in flags.locations { commands.push(app.open_tab(location, true, None)); } @@ -1199,7 +1239,10 @@ impl Application for App { } fn nav_model(&self) -> Option<&segmented_button::SingleSelectModel> { - Some(&self.nav_model) + match self.mode { + Mode::App => Some(&self.nav_model), + Mode::Desktop => None, + } } fn on_nav_select(&mut self, entity: Entity) -> Command { @@ -2049,27 +2092,76 @@ impl Application for App { self.rescan_tab(entity, tab_path, selection_path), ])); } + tab::Command::DropFiles(to, from) => { + commands.push(self.update(Message::PasteContents(to, from))); + } tab::Command::EmptyTrash => { self.dialog_pages.push_back(DialogPage::EmptyTrash); } - tab::Command::FocusButton(id) => { - commands.push(widget::button::focus(id)); + tab::Command::Iced(iced_command) => { + commands.push(iced_command.map(move |tab_message| { + message::app(Message::TabMessage(Some(entity), tab_message)) + })); } - tab::Command::FocusTextInput(id) => { - commands.push(widget::text_input::focus(id)); + tab::Command::LocationProperties(index) => { + self.context_page = + ContextPage::Properties(Some(ContextItem::BreadCrumbs(index))); + self.core.window.show_context = true; + self.set_context_title(self.context_page.title()); } - tab::Command::OpenFile(item_path) => { - match open::that_detached(&item_path) { - Ok(()) => { - let _ = recently_used_xbel::update_recently_used( - &item_path, - App::APP_ID.to_string(), - "cosmic-files".to_string(), - None, - ); - } - Err(err) => { - log::warn!("failed to open {:?}: {}", item_path, err); + tab::Command::MoveToTrash(paths) => { + self.operation(Operation::Delete { paths }); + } + tab::Command::OpenFile(path) => { + let mut found_desktop_exec = false; + if mime_icon::mime_for_path(&path) == "application/x-desktop" { + match freedesktop_entry_parser::parse_entry(&path) { + Ok(entry) => { + match entry.section("Desktop Entry").attr("Exec") { + Some(exec) => { + match mime_app::exec_to_command(exec, None) { + Some(mut command) => { + match spawn_detached(&mut command) { + Ok(()) => { + found_desktop_exec = true; + } + Err(err) => { + log::warn!( + "failed to execute {:?}: {}", + path, + err + ); + } + } + } + None => { + log::warn!("failed to parse {:?}: invalid Desktop Entry/Exec", path); + } + } + } + None => { + log::warn!("failed to parse {:?}: missing Desktop Entry/Exec", path); + } + } + } + Err(err) => { + log::warn!("failed to parse {:?}: {}", path, err); + } + }; + } + if !found_desktop_exec { + match open::that_detached(&path) { + Ok(()) => { + let _ = recently_used_xbel::update_recently_used( + &path, + App::APP_ID.to_string(), + "cosmic-files".to_string(), + None, + ); + } + Err(err) => { + log::warn!("failed to open {:?}: {}", path, err); + } } } } @@ -2087,35 +2179,6 @@ impl Application for App { log::error!("failed to get current executable path: {}", err); } }, - tab::Command::LocationProperties(index) => { - self.context_page = - ContextPage::Properties(Some(ContextItem::BreadCrumbs(index))); - self.core.window.show_context = true; - self.set_context_title(self.context_page.title()); - } - tab::Command::Scroll(id, offset) => { - commands.push(scrollable::scroll_to(id, offset)); - } - tab::Command::DropFiles(to, from) => { - commands.push(self.update(Message::PasteContents(to, from))); - } - tab::Command::Timeout(d, tab_msg) => { - commands.push(Command::perform( - async move { - tokio::time::sleep(d).await; - tab_msg - }, - move |msg| { - cosmic::app::Message::App(Message::TabMessage( - Some(entity), - msg, - )) - }, - )); - } - tab::Command::MoveToTrash(paths) => { - self.operation(Operation::Delete { paths }); - } } } return Command::batch(commands); @@ -2386,6 +2449,80 @@ impl Application for App { Message::Recents => { return self.open_tab(Location::Recents, false, None); } + #[cfg(feature = "wayland")] + Message::OutputEvent(output_event, output) => { + match output_event { + OutputEvent::Created(output_info_opt) => { + log::info!("output {}: created", output.id()); + + let surface_id = WindowId::unique(); + match self.surface_ids.insert(output.clone(), surface_id) { + Some(old_surface_id) => { + //TODO: remove old surface? + log::warn!( + "output {}: already had surface ID {:?}", + output.id(), + old_surface_id + ); + } + None => {} + } + + match output_info_opt { + Some(output_info) => match output_info.name { + Some(output_name) => { + self.surface_names.insert(surface_id, output_name.clone()); + } + None => { + log::warn!("output {}: no output name", output.id()); + } + }, + None => { + log::warn!("output {}: no output info", output.id()); + } + } + + return Command::batch([get_layer_surface(SctkLayerSurfaceSettings { + id: surface_id, + layer: Layer::Bottom, + keyboard_interactivity: KeyboardInteractivity::OnDemand, + pointer_interactivity: true, + anchor: Anchor::TOP | Anchor::BOTTOM | Anchor::LEFT | Anchor::RIGHT, + output: IcedOutput::Output(output), + namespace: "cosmic-files-applet".into(), + size: Some((None, None)), + margin: IcedMargin { + top: 0, + bottom: 0, + left: 0, + right: 0, + }, + exclusive_zone: -1, + size_limits: Limits::NONE.min_width(1.0).min_height(1.0), + })]); + } + OutputEvent::Removed => { + log::info!("output {}: removed", output.id()); + match self.surface_ids.remove(&output) { + Some(surface_id) => { + self.surface_names.remove(&surface_id); + return destroy_layer_surface(surface_id); + } + None => { + log::warn!("output {}: no surface found", output.id()); + } + } + } + OutputEvent::InfoUpdate(_output_info) => { + log::info!("output {}: info update", output.id()); + } + } + } + Message::Cosmic(cosmic) => { + // Forward cosmic messages + return Command::perform(async move { cosmic }, |cosmic| message::cosmic(cosmic)); + } + Message::None => {} } Command::none() @@ -2796,6 +2933,15 @@ impl Application for App { content } + fn view_window(&self, id: WindowId) -> Element { + //TODO: distinct views per window? + self.view_main().map(|message| match message { + app::Message::App(app) => app, + app::Message::Cosmic(cosmic) => Message::Cosmic(cosmic), + app::Message::None => Message::None, + }) + } + fn subscription(&self) -> Subscription { struct ThemeSubscription; struct WatcherSubscription; @@ -2811,6 +2957,15 @@ impl Application for App { Some(Message::Modifiers(modifiers)) } Event::Window(_id, WindowEvent::CloseRequested) => Some(Message::WindowClose), + #[cfg(feature = "wayland")] + Event::PlatformSpecific(event::PlatformSpecific::Wayland(wayland_event)) => { + match wayland_event { + WaylandEvent::Output(output_event, output) => { + Some(Message::OutputEvent(output_event, output)) + } + _ => None, + } + } _ => None, }), Config::subscription().map(|update| { diff --git a/src/dialog.rs b/src/dialog.rs index 10f7bb4..ab04a03 100644 --- a/src/dialog.rs +++ b/src/dialog.rs @@ -13,11 +13,14 @@ use cosmic::{ futures::{self, SinkExt}, keyboard::{Event as KeyEvent, Modifiers}, subscription::{self, Subscription}, - widget::scrollable, window, Alignment, Event, Length, Size, }, theme, - widget::{self, menu::KeyBind, segmented_button}, + widget::{ + self, + menu::{Action as MenuAction, KeyBind}, + segmented_button, + }, Application, ApplicationExt, Element, }; use notify_debouncer_full::{ @@ -36,7 +39,7 @@ use std::{ }; use crate::{ - app::Action, + app::{Action, Message as AppMessage}, config::{Config, Favorite, TabConfig}, fl, home_dir, localize::LANGUAGE_SORTER, @@ -556,7 +559,7 @@ impl Application for App { ..Default::default() }; let mut tab = Tab::new(location, tab_config); - tab.dialog = Some(flags.kind.clone()); + tab.mode = tab::Mode::Dialog(flags.kind.clone()); let view_model = segmented_button::SingleSelectModel::builder() .insert(|b| { @@ -957,24 +960,24 @@ impl Application for App { let mut commands = Vec::new(); for tab_command in tab_commands { match tab_command { - tab::Command::Action(action) => { - log::warn!("Action {:?} not supported in dialog", action); - } + tab::Command::Action(action) => match action.message() { + AppMessage::TabMessage(_entity_opt, tab_message) => { + commands.push(self.update(Message::TabMessage(tab_message))); + } + unsupported => { + log::warn!("{unsupported:?} not supported in dialog mode"); + } + }, tab::Command::ChangeLocation(_tab_title, _tab_path, _selection_path) => { commands .push(Command::batch([self.update_watcher(), self.rescan_tab()])); } - tab::Command::DropFiles(_, _) => { - log::warn!("DropFiles not supported in dialog"); - } - tab::Command::EmptyTrash => { - log::warn!("EmptyTrash not supported in dialog"); - } - tab::Command::FocusButton(id) => { - commands.push(widget::button::focus(id)); - } - tab::Command::FocusTextInput(id) => { - commands.push(widget::text_input::focus(id)); + tab::Command::Iced(iced_command) => { + commands.push( + iced_command.map(|tab_message| { + message::app(Message::TabMessage(tab_message)) + }), + ); } tab::Command::OpenFile(_item_path) => { if self.flags.kind.save() { @@ -983,23 +986,8 @@ impl Application for App { commands.push(self.update(Message::Open)); } } - tab::Command::OpenInNewTab(_path) => { - log::warn!("OpenInNewTab not supported in dialog"); - } - tab::Command::OpenInNewWindow(_path) => { - log::warn!("OpenInNewWindow not supported in dialog"); - } - tab::Command::LocationProperties(_path) => { - log::warn!("LocationProperties not supported in dialog"); - } - tab::Command::Scroll(id, offset) => { - commands.push(scrollable::scroll_to(id, offset)); - } - tab::Command::Timeout(_, _) => { - log::warn!("Timeout not supported in dialog"); - } - tab::Command::MoveToTrash(_) => { - log::warn!("MoveToTrash not supported in dialog"); + unsupported => { + log::warn!("{unsupported:?} not supported in dialog mode"); } } } diff --git a/src/lib.rs b/src/lib.rs index 0263f89..f23d24e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,13 +5,13 @@ use cosmic::{ app::{Application, Settings}, iced::Limits, }; -use std::{path::PathBuf, process}; +use std::{env, fs, path::PathBuf, process}; use app::{App, Flags}; -mod app; +pub mod app; pub mod clipboard; use config::Config; -mod config; +pub mod config; pub mod dialog; mod key_bind; mod localize; @@ -22,7 +22,8 @@ mod mounter; mod mouse_area; mod operation; mod spawn_detached; -mod tab; +use tab::Location; +pub mod tab; pub fn home_dir() -> PathBuf { match dirs::home_dir() { @@ -34,6 +35,43 @@ pub fn home_dir() -> PathBuf { } } +/// Runs application in desktop mode +#[rustfmt::skip] +pub fn desktop() -> Result<(), Box> { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); + + localize::localize(); + + let (config_handler, config) = Config::load(); + + let locations = vec![ + match dirs::desktop_dir() { + Some(path) => Location::Path(path), + None => Location::Path(home_dir()), + } + ]; + + let mut settings = Settings::default(); + settings = settings.theme(config.app_theme.theme()); + settings = settings.size_limits(Limits::NONE.min_width(360.0).min_height(180.0)); + settings = settings.exit_on_close(false); + settings = settings.transparent(true); + #[cfg(feature = "wayland")] + { + settings = settings.no_main_window(true); + } + + let flags = Flags { + config_handler, + config, + mode: app::Mode::Desktop, + locations, + }; + cosmic::app::run::(settings, flags)?; + + Ok(()) +} + /// Runs application with these settings #[rustfmt::skip] pub fn main() -> Result<(), Box> { @@ -53,6 +91,22 @@ pub fn main() -> Result<(), Box> { let (config_handler, config) = Config::load(); + let mut locations = Vec::new(); + for arg in env::args().skip(1) { + let location = if &arg == "--trash" { + Location::Trash + } else { + match fs::canonicalize(&arg) { + Ok(absolute) => Location::Path(absolute), + Err(err) => { + log::warn!("failed to canonicalize {:?}: {}", arg, err); + continue; + } + } + }; + locations.push(location); + } + let mut settings = Settings::default(); settings = settings.theme(config.app_theme.theme()); settings = settings.size_limits(Limits::NONE.min_width(360.0).min_height(180.0)); @@ -61,6 +115,8 @@ pub fn main() -> Result<(), Box> { let flags = Flags { config_handler, config, + mode: app::Mode::App, + locations, }; cosmic::app::run::(settings, flags)?; diff --git a/src/menu.rs b/src/menu.rs index 6d250b8..16e39a2 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -97,8 +97,11 @@ pub fn context_menu<'a>( selected_types.dedup(); let mut children: Vec> = Vec::new(); - match tab.location { - Location::Path(_) | Location::Search(_, _) | Location::Recents => { + match (&tab.mode, &tab.location) { + ( + tab::Mode::App | tab::Mode::Desktop, + Location::Path(_) | Location::Search(_, _) | Location::Recents, + ) => { if selected > 0 { if selected_dir == 1 && selected == 1 || selected_dir == 0 { children.push(menu_item(fl!("open"), Action::Open).into()); @@ -155,7 +158,9 @@ pub fn context_menu<'a>( children.push(menu_item(fl!("new-file"), Action::NewFile).into()); children.push(menu_item(fl!("open-in-terminal"), Action::OpenTerminal).into()); children.push(divider::horizontal::light().into()); - children.push(menu_item(fl!("select-all"), Action::SelectAll).into()); + if tab.mode.multiple() { + children.push(menu_item(fl!("select-all"), Action::SelectAll).into()); + } children.push(menu_item(fl!("paste"), Action::Paste).into()); children.push(divider::horizontal::light().into()); // TODO: Nested menu @@ -164,20 +169,52 @@ pub fn context_menu<'a>( children.push(sort_item(fl!("sort-by-size"), HeadingOptions::Size)); } } - Location::Trash => { - children.push(menu_item(fl!("select-all"), Action::SelectAll).into()); + ( + tab::Mode::Dialog(dialog_kind), + Location::Path(_) | Location::Search(_, _) | Location::Recents, + ) => { if selected > 0 { + if selected_dir == 1 && selected == 1 || selected_dir == 0 { + children.push(menu_item(fl!("open"), Action::Open).into()); + } + if matches!(tab.location, Location::Search(_, _)) { + children.push( + menu_item(fl!("open-item-location"), Action::OpenItemLocation).into(), + ); + } + } else { + if dialog_kind.save() { + children.push(menu_item(fl!("new-folder"), Action::NewFolder).into()); + } + 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() { + children.push(menu_item(fl!("select-all"), Action::SelectAll).into()); + } + if !children.is_empty() { children.push(divider::horizontal::light().into()); + } + if selected > 0 { children.push(menu_item(fl!("show-details"), Action::Properties).into()); children.push(divider::horizontal::light().into()); children .push(menu_item(fl!("restore-from-trash"), Action::RestoreFromTrash).into()); + } else { + // TODO: Nested menu + 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()); - // TODO: Nested menu - 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)); } } diff --git a/src/mime_app.rs b/src/mime_app.rs index a8de06c..268152f 100644 --- a/src/mime_app.rs +++ b/src/mime_app.rs @@ -10,6 +10,30 @@ use std::{ cmp::Ordering, collections::HashMap, env, path::PathBuf, process, sync::Mutex, time::Instant, }; +pub fn exec_to_command(exec: &str, path_opt: Option) -> Option { + let args_vec: Vec = shlex::split(exec)?; + let mut args = args_vec.iter(); + let mut command = process::Command::new(args.next()?); + for arg in args { + if arg.starts_with('%') { + match arg.as_str() { + "%f" | "%F" | "%u" | "%U" => { + if let Some(path) = &path_opt { + command.arg(path); + } + } + _ => { + log::warn!("unsupported Exec code {:?} in {:?}", arg, exec); + return None; + } + } + } else { + command.arg(arg); + } + } + Some(command) +} + #[derive(Clone, Debug)] pub struct MimeApp { pub id: String, @@ -23,27 +47,7 @@ pub struct MimeApp { impl MimeApp { //TODO: move to libcosmic, support multiple files pub fn command(&self, path_opt: Option) -> Option { - let args_vec: Vec = self.exec.as_deref().and_then(shlex::split)?; - let mut args = args_vec.iter(); - let mut command = process::Command::new(args.next()?); - for arg in args { - if arg.starts_with('%') { - match arg.as_str() { - "%f" | "%F" | "%u" | "%U" => { - if let Some(path) = &path_opt { - command.arg(path); - } - } - _ => { - log::warn!("unsupported Exec code {:?} in {:?}", arg, self.id); - return None; - } - } - } else { - command.arg(arg); - } - } - Some(command) + exec_to_command(self.exec.as_deref()?, path_opt) } } diff --git a/src/tab.rs b/src/tab.rs index 26ea08c..a20a4cb 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -13,7 +13,7 @@ use cosmic::{ //TODO: export in cosmic::widget widget::{ container, horizontal_rule, - scrollable::{AbsoluteOffset, Viewport}, + scrollable::{self, AbsoluteOffset, Viewport}, }, Alignment, Border, @@ -111,6 +111,7 @@ fn button_appearance( focused: bool, accent: bool, condensed_radius: bool, + desktop: bool, ) -> widget::button::Appearance { let cosmic = theme.cosmic(); let mut appearance = widget::button::Appearance::new(); @@ -122,6 +123,10 @@ fn button_appearance( } else { appearance.background = Some(Color::from(cosmic.bg_component_color()).into()); } + } else if desktop { + appearance.background = Some(Color::from(cosmic.bg_color()).into()); + appearance.icon_color = Some(Color::from(cosmic.on_bg_color())); + appearance.text_color = Some(Color::from(cosmic.on_bg_color())); } if focused && accent { appearance.outline_width = 1.0; @@ -137,20 +142,25 @@ fn button_appearance( appearance } -fn button_style(selected: bool, accent: bool, condensed_radius: bool) -> theme::Button { +fn button_style( + selected: bool, + accent: bool, + condensed_radius: bool, + desktop: bool, +) -> theme::Button { //TODO: move to libcosmic? theme::Button::Custom { active: Box::new(move |focused, theme| { - button_appearance(theme, selected, focused, accent, condensed_radius) + button_appearance(theme, selected, focused, accent, condensed_radius, desktop) }), disabled: Box::new(move |theme| { - button_appearance(theme, selected, false, accent, condensed_radius) + button_appearance(theme, selected, false, accent, condensed_radius, desktop) }), hovered: Box::new(move |focused, theme| { - button_appearance(theme, selected, focused, accent, condensed_radius) + button_appearance(theme, selected, focused, accent, condensed_radius, desktop) }), pressed: Box::new(move |focused, theme| { - button_appearance(theme, selected, focused, accent, condensed_radius) + button_appearance(theme, selected, focused, accent, condensed_radius, desktop) }), } } @@ -263,13 +273,33 @@ fn hidden_attribute(metadata: &Metadata) -> bool { metadata.file_attributes() & FILE_ATTRIBUTE_HIDDEN == FILE_ATTRIBUTE_HIDDEN } +pub fn parse_desktop_file(path: &Path) -> (Option, Option) { + let entry = match freedesktop_entry_parser::parse_entry(path) { + Ok(ok) => ok, + Err(err) => { + log::warn!("failed to parse {:?}: {}", path, err); + return (None, None); + } + }; + ( + entry + .section("Desktop Entry") + .attr("Name") + .map(|x| x.to_string()), + entry + .section("Desktop Entry") + .attr("Icon") + .map(|x| x.to_string()), + ) +} + pub fn item_from_entry( path: PathBuf, name: String, metadata: fs::Metadata, sizes: IconSizes, ) -> Item { - let grid_name = Item::grid_name(&name); + let mut display_name = Item::display_name(&name); let hidden = name.starts_with(".") || hidden_attribute(&metadata); @@ -284,12 +314,37 @@ pub fn item_from_entry( ) } else { let mime = mime_for_path(&path); - ( - mime.clone(), - mime_icon(mime.clone(), sizes.grid()), - mime_icon(mime.clone(), sizes.list()), - mime_icon(mime, sizes.list_condensed()), - ) + //TODO: clean this up, implement for trash + let icon_name_opt = if mime == "application/x-desktop" { + let (desktop_name_opt, icon_name_opt) = parse_desktop_file(&path); + if let Some(desktop_name) = desktop_name_opt { + display_name = Item::display_name(&desktop_name); + } + icon_name_opt + } else { + None + }; + if let Some(icon_name) = icon_name_opt { + ( + mime.clone(), + widget::icon::from_name(&*icon_name) + .size(sizes.grid()) + .handle(), + widget::icon::from_name(&*icon_name) + .size(sizes.list()) + .handle(), + widget::icon::from_name(&*icon_name) + .size(sizes.list_condensed()) + .handle(), + ) + } else { + ( + mime.clone(), + mime_icon(mime.clone(), sizes.grid()), + mime_icon(mime.clone(), sizes.list()), + mime_icon(mime, sizes.list_condensed()), + ) + } }; let open_with = mime_apps(&mime); @@ -319,7 +374,7 @@ pub fn item_from_entry( Item { name, - grid_name, + display_name, metadata: ItemMetadata::Path { metadata, children }, hidden, path_opt: Some(path), @@ -401,7 +456,7 @@ pub fn scan_path(tab_path: &PathBuf, sizes: IconSizes) -> Vec { items.sort_by(|a, b| match (a.metadata.is_dir(), b.metadata.is_dir()) { (true, false) => Ordering::Less, (false, true) => Ordering::Greater, - _ => LANGUAGE_SORTER.compare(&a.name, &b.name), + _ => LANGUAGE_SORTER.compare(&a.display_name, &b.display_name), }); items } @@ -538,7 +593,7 @@ pub fn scan_trash(sizes: IconSizes) -> Vec { let original_path = entry.original_path(); let name = entry.name.to_string_lossy().to_string(); - let grid_name = Item::grid_name(&name); + let display_name = Item::display_name(&name); let (mime, icon_handle_grid, icon_handle_list, icon_handle_list_condensed) = match metadata.size { @@ -563,7 +618,7 @@ pub fn scan_trash(sizes: IconSizes) -> Vec { items.push(Item { name, - grid_name, + display_name, metadata: ItemMetadata::Trash { metadata, entry }, hidden: false, path_opt: None, @@ -588,7 +643,7 @@ pub fn scan_trash(sizes: IconSizes) -> Vec { items.sort_by(|a, b| match (a.metadata.is_dir(), b.metadata.is_dir()) { (true, false) => Ordering::Less, (false, true) => Ordering::Greater, - _ => LANGUAGE_SORTER.compare(&a.name, &b.name), + _ => LANGUAGE_SORTER.compare(&a.display_name, &b.display_name), }); items } @@ -699,21 +754,18 @@ impl Location { } } -#[derive(Clone, Debug)] +#[derive(Debug)] pub enum Command { Action(Action), ChangeLocation(String, Location, Option), + DropFiles(PathBuf, ClipboardPaste), EmptyTrash, - FocusButton(widget::Id), - FocusTextInput(widget::Id), + Iced(cosmic::Command), + LocationProperties(usize), + MoveToTrash(Vec), OpenFile(PathBuf), OpenInNewTab(PathBuf), OpenInNewWindow(PathBuf), - LocationProperties(usize), - Scroll(widget::Id, AbsoluteOffset), - DropFiles(PathBuf, ClipboardPaste), - Timeout(Duration, Message), - MoveToTrash(Vec), } #[derive(Clone, Debug)] @@ -808,7 +860,7 @@ pub enum ItemThumbnail { #[derive(Clone, Debug)] pub struct Item { pub name: String, - pub grid_name: String, + pub display_name: String, pub metadata: ItemMetadata, pub hidden: bool, pub path_opt: Option, @@ -826,7 +878,7 @@ pub struct Item { } impl Item { - fn grid_name(name: &str) -> String { + fn display_name(name: &str) -> String { // In order to wrap at periods and underscores, add a zero width space after each one name.replace(".", ".\u{200B}").replace("_", "_\u{200B}") } @@ -1081,6 +1133,23 @@ impl HeadingOptions { } } +#[derive(Clone, Debug)] +pub enum Mode { + App, + Desktop, + Dialog(DialogKind), +} + +impl Mode { + /// Whether multiple files can be selected in this mode + pub fn multiple(&self) -> bool { + match self { + Mode::App | Mode::Desktop => true, + Mode::Dialog(dialog) => dialog.multiple(), + } + } +} + // TODO when creating items, pass > to each item // as a drag data, so that when dnd is initiated, they are all included #[derive(Clone)] @@ -1090,7 +1159,7 @@ pub struct Tab { pub location_context_menu_point: Option, pub location_context_menu_index: Option, pub context_menu: Option, - pub dialog: Option, + pub mode: Mode, pub scroll_opt: Option, pub size_opt: Cell>, pub item_view_size_opt: Cell>, @@ -1136,7 +1205,7 @@ impl Tab { context_menu: None, location_context_menu_point: None, location_context_menu_index: None, - dialog: None, + mode: Mode::App, scroll_opt: None, size_opt: Cell::new(None), item_view_size_opt: Cell::new(None), @@ -1181,6 +1250,10 @@ impl Tab { self.items_opt.as_ref() } + pub fn items_opt_mut(&mut self) -> Option<&mut Vec> { + self.items_opt.as_mut() + } + pub fn set_items(&mut self, items: Vec) { self.items_opt = Some(items); } @@ -1441,10 +1514,8 @@ impl Tab { let mut commands = Vec::new(); let mut cd = None; let mut history_i_opt = None; - let mod_ctrl = modifiers.contains(Modifiers::CTRL) - && self.dialog.as_ref().map_or(true, |x| x.multiple()); - let mod_shift = modifiers.contains(Modifiers::SHIFT) - && self.dialog.as_ref().map_or(true, |x| x.multiple()); + let mod_ctrl = modifiers.contains(Modifiers::CTRL) && self.mode.multiple(); + let mod_shift = modifiers.contains(Modifiers::SHIFT) && self.mode.multiple(); match message { Message::ClickRelease(click_i_opt) => { if click_i_opt == self.clicked.take() { @@ -1590,7 +1661,7 @@ impl Tab { for (i, item) in items.iter_mut().enumerate() { if Some(i) == click_i_opt { // Filter out selection if it does not match dialog kind - if let Some(dialog) = &self.dialog { + if let Mode::Dialog(dialog) = &self.mode { let item_is_dir = item.metadata.is_dir(); if item_is_dir != dialog.is_dir() { // Allow selecting folder if dialog is for files to make it @@ -1616,7 +1687,7 @@ impl Tab { } if self.select_focus.take().is_some() { // Unfocus currently focused button - commands.push(Command::FocusButton(widget::Id::unique())); + commands.push(Command::Iced(widget::button::focus(widget::Id::unique()))); } } } @@ -1674,14 +1745,16 @@ impl Tab { self.select_rect(rect, mod_ctrl, mod_shift); if self.select_focus.take().is_some() { // Unfocus currently focused button - commands.push(Command::FocusButton(widget::Id::unique())); + commands.push(Command::Iced(widget::button::focus(widget::Id::unique()))); } } None => {} }, Message::EditLocation(edit_location) => { if self.edit_location.is_none() && edit_location.is_some() { - commands.push(Command::FocusTextInput(self.edit_location_id.clone())); + commands.push(Command::Iced(widget::text_input::focus( + self.edit_location_id.clone(), + ))); } self.edit_location = edit_location; } @@ -1727,10 +1800,13 @@ impl Tab { self.select_position(0, 0, mod_shift); } if let Some(offset) = self.select_focus_scroll() { - commands.push(Command::Scroll(self.scrollable_id.clone(), offset)); + commands.push(Command::Iced(scrollable::scroll_to( + self.scrollable_id.clone(), + offset, + ))); } if let Some(id) = self.select_focus_id() { - commands.push(Command::FocusButton(id)); + commands.push(Command::Iced(widget::button::focus(id))); } } Message::ItemLeft => { @@ -1772,10 +1848,13 @@ impl Tab { self.select_position(0, 0, mod_shift); } if let Some(offset) = self.select_focus_scroll() { - commands.push(Command::Scroll(self.scrollable_id.clone(), offset)); + commands.push(Command::Iced(scrollable::scroll_to( + self.scrollable_id.clone(), + offset, + ))); } if let Some(id) = self.select_focus_id() { - commands.push(Command::FocusButton(id)); + commands.push(Command::Iced(widget::button::focus(id))); } } Message::ItemRight => { @@ -1799,10 +1878,13 @@ impl Tab { self.select_position(0, 0, mod_shift); } if let Some(offset) = self.select_focus_scroll() { - commands.push(Command::Scroll(self.scrollable_id.clone(), offset)); + commands.push(Command::Iced(scrollable::scroll_to( + self.scrollable_id.clone(), + offset, + ))); } if let Some(id) = self.select_focus_id() { - commands.push(Command::FocusButton(id)); + commands.push(Command::Iced(widget::button::focus(id))); } } Message::ItemUp => { @@ -1829,10 +1911,13 @@ impl Tab { self.select_position(0, 0, mod_shift); } if let Some(offset) = self.select_focus_scroll() { - commands.push(Command::Scroll(self.scrollable_id.clone(), offset)); + commands.push(Command::Iced(scrollable::scroll_to( + self.scrollable_id.clone(), + offset, + ))); } if let Some(id) = self.select_focus_id() { - commands.push(Command::FocusButton(id)); + commands.push(Command::Iced(widget::button::focus(id))); } } Message::Location(location) => { @@ -1929,7 +2014,7 @@ impl Tab { self.select_all(); if self.select_focus.take().is_some() { // Unfocus currently focused button - commands.push(Command::FocusButton(widget::Id::unique())); + commands.push(Command::Iced(widget::button::focus(widget::Id::unique()))); } } Message::Thumbnail(path, thumbnail) => { @@ -2011,7 +2096,13 @@ impl Tab { Message::DndEnter(loc) => { self.dnd_hovered = Some((loc.clone(), Instant::now())); if loc != self.location { - commands.push(Command::Timeout(HOVER_DURATION, Message::DndHover(loc))); + commands.push(Command::Iced(cosmic::Command::perform( + async move { + tokio::time::sleep(HOVER_DURATION).await; + Message::DndHover(loc) + }, + |x| x, + ))); } } Message::DndLeave(loc) => { @@ -2063,7 +2154,14 @@ impl Tab { } } if let Some(location) = cd { - if location != self.location { + if matches!(self.mode, Mode::Desktop) { + match location { + Location::Path(path) => { + commands.push(Command::OpenFile(path)); + } + _ => {} + } + } else if location != self.location { if match &location { Location::Path(path) => path.is_dir(), Location::Search(path, _term) => path.is_dir(), @@ -2129,12 +2227,15 @@ impl Tab { (true, false) => Ordering::Less, (false, true) => Ordering::Greater, _ => check_reverse( - LANGUAGE_SORTER.compare(&a.1.name, &b.1.name), + LANGUAGE_SORTER.compare(&a.1.display_name, &b.1.display_name), heading_sort, ), } } else { - check_reverse(LANGUAGE_SORTER.compare(&a.1.name, &b.1.name), heading_sort) + check_reverse( + LANGUAGE_SORTER.compare(&a.1.display_name, &b.1.display_name), + heading_sort, + ) } }), HeadingOptions::Modified => { @@ -2494,21 +2595,25 @@ impl Tab { pub fn empty_view(&self, has_hidden: bool) -> Element { let cosmic_theme::Spacing { space_xxs, .. } = theme::active().cosmic().spacing; + //TODO: left clicking on an empty folder does not clear context menu widget::column::with_children(vec![widget::container( - widget::column::with_children(vec![ - widget::icon::from_name("folder-symbolic") - .size(64) - .icon() + widget::column::with_children(match self.mode { + Mode::App | Mode::Dialog(_) => vec![ + widget::icon::from_name("folder-symbolic") + .size(64) + .icon() + .into(), + widget::text(if has_hidden { + fl!("empty-folder-hidden") + } else if matches!(self.location, Location::Search(_, _)) { + fl!("no-results") + } else { + fl!("empty-folder") + }) .into(), - widget::text(if has_hidden { - fl!("empty-folder-hidden") - } else if matches!(self.location, Location::Search(_, _)) { - fl!("no-results") - } else { - fl!("empty-folder") - }) - .into(), - ]) + ], + Mode::Desktop => Vec::new(), + }) .align_items(Alignment::Center) .spacing(space_xxs), ) @@ -2568,6 +2673,12 @@ impl Tab { (cols, spacing as u16) }; + let rows = { + let height_m1 = height.checked_sub(item_height).unwrap_or(0); + let rows_m1 = height_m1 / (item_height + space_xxs as usize); + rows_m1 + 1 + }; + let mut grid = widget::grid() .column_spacing(column_spacing) .row_spacing(space_xxs) @@ -2584,7 +2695,9 @@ impl Tab { let mut count = 0; let mut col = 0; let mut row = 0; + let mut page_row = 0; let mut hidden = 0; + let mut grid_elements = Vec::new(); for &(i, item) in items.iter() { if !show_hidden && item.hidden { item.pos_opt.set(None); @@ -2609,11 +2722,16 @@ impl Tab { .size(icon_sizes.grid()), ) .padding(space_xxxs) - .style(button_style(item.selected, false, false)), - widget::button(widget::text::body(&item.grid_name)) + .style(button_style(item.selected, false, false, false)), + widget::button(widget::text::body(&item.display_name)) .id(item.button_id.clone()) .padding([0, space_xxxs]) - .style(button_style(item.selected, true, true)), + .style(button_style( + item.selected, + true, + true, + matches!(self.mode, Mode::Desktop), + )), ]; let mut column = widget::column::with_capacity(buttons.len()) @@ -2696,17 +2814,41 @@ impl Tab { .on_double_click(move |_| Message::DoubleClick(Some(i))) .on_release(move |_| Message::ClickRelease(Some(i))) .on_middle_press(move |_| Message::MiddleClick(i)); - grid = grid.push(mouse_area); + + //TODO: error if the row or col is already set? + while grid_elements.len() <= row { + grid_elements.push(Vec::new()); + } + grid_elements[row].push(mouse_area); count += 1; - col += 1; - if col >= cols { - col = 0; + if matches!(self.mode, Mode::Desktop) { row += 1; - grid = grid.insert_row(); + if row >= page_row + rows { + row = 0; + col += 1; + } + if col >= cols { + col = 0; + page_row += rows; + row = page_row; + } + } else { + col += 1; + if col >= cols { + col = 0; + row += 1; + } } } + for row_elements in grid_elements { + for element in row_elements { + grid = grid.push(element); + } + grid = grid.insert_row(); + } + if count == 0 { return (None, self.empty_view(hidden > 0), false); } @@ -2770,12 +2912,13 @@ impl Tab { item.selected, false, false, + false, )), - widget::button(widget::text(item.grid_name.clone())) + widget::button(widget::text(item.display_name.clone())) .id(item.button_id.clone()) .on_press(Message::Click(Some(*i))) .padding([0, space_xxxs]) - .style(button_style(item.selected, true, true)), + .style(button_style(item.selected, true, true, false)), ]; let mut column = widget::column::with_capacity(buttons.len()) @@ -2914,7 +3057,7 @@ impl Tab { .size(icon_size) .into(), widget::column::with_children(vec![ - widget::text(item.name.clone()).into(), + widget::text(item.display_name.clone()).into(), //TODO: translate? widget::text::caption(format!("{} - {}", modified_text, size_text)) .into(), @@ -2930,7 +3073,9 @@ impl Tab { .content_fit(ContentFit::Contain) .size(icon_size) .into(), - widget::text(item.name.clone()).width(Length::Fill).into(), + widget::text(item.display_name.clone()) + .width(Length::Fill) + .into(), widget::text(modified_text.clone()) .width(Length::Fixed(modified_width)) .into(), @@ -2949,7 +3094,7 @@ impl Tab { .width(Length::Fill) .id(item.button_id.clone()) .padding([0, space_xxs]) - .style(button_style(item.selected, true, false)), + .style(button_style(item.selected, true, false, false)), ) .on_press(move |_| Message::Click(Some(i))) .on_double_click(move |_| Message::DoubleClick(Some(i))) @@ -3029,7 +3174,7 @@ impl Tab { .size(icon_size) .into(), widget::column::with_children(vec![ - widget::text(item.name.clone()).into(), + widget::text(item.display_name.clone()).into(), //TODO: translate? widget::text(format!("{} - {}", modified_text, size_text)).into(), ]) @@ -3044,7 +3189,9 @@ impl Tab { .content_fit(ContentFit::Contain) .size(icon_size) .into(), - widget::text(item.name.clone()).width(Length::Fill).into(), + widget::text(item.display_name.clone()) + .width(Length::Fill) + .into(), widget::text(modified_text) .width(Length::Fixed(modified_width)) .into(), @@ -3121,7 +3268,11 @@ impl Tab { // Update cached size self.size_opt.set(Some(size)); - let location_view = self.location_view(); + let location_view_opt = if matches!(self.mode, Mode::Desktop) { + None + } else { + Some(self.location_view()) + }; let (drag_list, mut item_view, can_scroll) = match self.config.view { View::Grid => self.grid_view(), View::List => self.list_view(), @@ -3180,7 +3331,9 @@ impl Tab { .position(widget::popover::Position::Point(point)); } let mut tab_column = widget::column::with_capacity(3); - tab_column = tab_column.push(location_view); + if let Some(location_view) = location_view_opt { + tab_column = tab_column.push(location_view); + } if can_scroll { tab_column = tab_column.push( widget::scrollable(popover)