Desktop mode

This commit is contained in:
Jeremy Soller 2024-08-20 13:26:10 -06:00
parent bbcfe19375
commit 5d596239be
12 changed files with 640 additions and 218 deletions

8
Cargo.lock generated
View file

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

View file

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

View file

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

View file

@ -0,0 +1,3 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
cosmic_files::desktop()
}

View file

@ -131,7 +131,6 @@ restored = Restored {$items} {$items ->
[one] item
*[other] items
} from {trash}
undo = Undo
unknown-folder = unknown folder
## Open with

View file

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

View file

@ -1,8 +1,19 @@
// Copyright 2023 System76 <info@system76.com>
// 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<cosmic_config::Config>,
pub config: Config,
pub mode: Mode,
pub locations: Vec<Location>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@ -280,6 +300,10 @@ pub enum Message {
DndDropTab(Entity, Option<ClipboardPaste>, DndAction),
DndDropNav(Entity, Option<ClipboardPaste>, 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<segmented_button::SingleSelect>,
config_handler: Option<cosmic_config::Config>,
config: Config,
mode: Mode,
app_themes: Vec<String>,
default_view: Vec<String>,
sort_by_names: Vec<String>,
@ -413,6 +438,10 @@ pub struct App {
search_active: bool,
search_id: widget::Id,
search_input: String,
#[cfg(feature = "wayland")]
surface_ids: HashMap<WlOutput, WindowId>,
#[cfg(feature = "wayland")]
surface_names: HashMap<WindowId, String>,
toasts: widget::toaster::Toasts<Message>,
watcher_opt: Option<(Debouncer<RecommendedWatcher, FileIdMap>, HashSet<PathBuf>)>,
window_id_opt: Option<window::Id>,
@ -429,7 +458,11 @@ impl App {
activate: bool,
selection_path: Option<PathBuf>,
) -> Command<Message> {
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<Self::Message>) {
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<Self::Message> {
@ -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<Self::Message> {
//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<Self::Message> {
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| {

View file

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

View file

@ -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<dyn std::error::Error>> {
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::<App>(settings, flags)?;
Ok(())
}
/// Runs application with these settings
#[rustfmt::skip]
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
@ -53,6 +91,22 @@ pub fn main() -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
let flags = Flags {
config_handler,
config,
mode: app::Mode::App,
locations,
};
cosmic::app::run::<App>(settings, flags)?;

View file

@ -97,8 +97,11 @@ pub fn context_menu<'a>(
selected_types.dedup();
let mut children: Vec<Element<_>> = 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));
}
}

View file

@ -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<PathBuf>) -> Option<process::Command> {
let args_vec: Vec<String> = 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<PathBuf>) -> Option<process::Command> {
let args_vec: Vec<String> = 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)
}
}

View file

@ -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<String>, Option<String>) {
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<Item> {
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<Item> {
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<Item> {
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<Item> {
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<PathBuf>),
DropFiles(PathBuf, ClipboardPaste),
EmptyTrash,
FocusButton(widget::Id),
FocusTextInput(widget::Id),
Iced(cosmic::Command<Message>),
LocationProperties(usize),
MoveToTrash(Vec<PathBuf>),
OpenFile(PathBuf),
OpenInNewTab(PathBuf),
OpenInNewWindow(PathBuf),
LocationProperties(usize),
Scroll(widget::Id, AbsoluteOffset),
DropFiles(PathBuf, ClipboardPaste),
Timeout(Duration, Message),
MoveToTrash(Vec<PathBuf>),
}
#[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<PathBuf>,
@ -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 <Arc<SelectedItems>> 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<Point>,
pub location_context_menu_index: Option<usize>,
pub context_menu: Option<Point>,
pub dialog: Option<DialogKind>,
pub mode: Mode,
pub scroll_opt: Option<AbsoluteOffset>,
pub size_opt: Cell<Option<Size>>,
pub item_view_size_opt: Cell<Option<Size>>,
@ -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<Item>> {
self.items_opt.as_mut()
}
pub fn set_items(&mut self, items: Vec<Item>) {
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<Message> {
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)