cosmic_files/
menu.rs

1// SPDX-License-Identifier: GPL-3.0-only
2
3use cosmic::app::Core;
4use cosmic::iced::advanced::widget::text::Style as TextStyle;
5use cosmic::iced::keyboard::Modifiers;
6use cosmic::iced::{Alignment, Background, Border, Length};
7use cosmic::widget::menu::key_bind::KeyBind;
8use cosmic::widget::menu::{self, ItemHeight, ItemWidth, MenuBar};
9use cosmic::widget::{
10    self, Row, button, column, container, divider, responsive_menu_bar, space, text,
11};
12use cosmic::{Element, theme};
13use mime_guess::Mime;
14use std::collections::HashMap;
15use std::collections::HashSet;
16use std::sync::LazyLock;
17use std::path::PathBuf;
18
19use crate::app::{Action, Message};
20use crate::config::{Config, ContextActionPreset, TBConfig};
21use crate::fl;
22use crate::tab::{self, HeadingOptions, Location, LocationMenuAction, SearchLocation, Tab};
23use crate::trash::{Trash, TrashExt};
24
25static MENU_ID: LazyLock<cosmic::widget::Id> =
26    LazyLock::new(|| cosmic::widget::Id::new("responsive-menu"));
27
28macro_rules! menu_button {
29    ($($x:expr),+ $(,)?) => (
30        button::custom(
31            Row::with_children(
32                [$(Element::from($x)),+]
33            )
34            .height(Length::Fixed(24.0))
35            .align_y(Alignment::Center)
36        )
37        .padding([theme::spacing().space_xxs, 16])
38        .width(Length::Fill)
39        .class(theme::Button::MenuItem)
40    );
41}
42
43fn is_valid_fastq_selection(paths: &[PathBuf], config: &TBConfig) -> bool {
44    let mut sample_map: HashMap<String, HashSet<u8>> = HashMap::new();
45    for path in paths.iter() {
46        let filename = match path.file_name().and_then(|n| n.to_str()) {
47            Some(f) => f,
48            None => return false,
49        };
50        if let Some(sample) = filename.strip_suffix(config.pair1_suffix.as_str()) {
51            sample_map.entry(sample.to_string()).or_default().insert(1);
52        } else if let Some(sample) = filename.strip_suffix(config.pair2_suffix.as_str()) {
53            sample_map.entry(sample.to_string()).or_default().insert(2);
54        } else {
55            return false;
56        }
57    }
58    sample_map.values().all(|set| set.len() == 2)
59}
60
61const fn menu_button_optional(
62    label: String,
63    action: Action,
64    enabled: bool,
65) -> menu::Item<Action, String> {
66    if enabled {
67        menu::Item::Button(label, None, action)
68    } else {
69        menu::Item::ButtonDisabled(label, None, action)
70    }
71}
72
73pub fn context_menu<'a>(
74    tab: &Tab,
75    key_binds: &HashMap<KeyBind, Action>,
76    modifiers: &Modifiers,
77    clipboard_paste_available: bool,
78    config: &TBConfig,
79    context_actions: &[ContextActionPreset],
80) -> Element<'a, tab::Message> {
81    let find_key = |action: &Action| -> String {
82        for (key_bind, key_action) in key_binds {
83            if action == key_action {
84                return key_bind.to_string();
85            }
86        }
87        String::new()
88    };
89    fn key_style(theme: &cosmic::Theme) -> TextStyle {
90        let mut color = theme.cosmic().background.component.on;
91        color.alpha *= 0.75;
92        TextStyle {
93            color: Some(color.into()),
94            ..Default::default()
95        }
96    }
97    fn disabled_style(theme: &cosmic::Theme) -> TextStyle {
98        let mut color = theme.cosmic().background.component.on;
99        color.alpha *= 0.5;
100        TextStyle {
101            color: Some(color.into()),
102            ..Default::default()
103        }
104    }
105
106    let menu_item = |label, action| {
107        let key = find_key(&action);
108        menu_button!(
109            text::body(label),
110            space::horizontal(),
111            text::body(key).class(theme::Text::Custom(key_style))
112        )
113        .on_press(tab::Message::ContextAction(action))
114    };
115
116    let menu_item_disabled = |label, action: Action| {
117        let key = find_key(&action);
118        menu_button!(
119            text::body(label).class(theme::Text::Custom(disabled_style)),
120            space::horizontal(),
121            text::body(key).class(theme::Text::Custom(disabled_style))
122        )
123    };
124
125    // Allow paste when clipboard has data and we're in a location that supports it
126    let can_paste = clipboard_paste_available && tab.location.supports_paste();
127
128    let (sort_name, sort_direction, _) = tab.sort_options();
129    let sort_item = |label, variant| {
130        let key = find_key(&Action::ToggleSort(variant));
131        let leading: Element<'a, tab::Message> = if sort_name == variant {
132            let icon_name = if sort_direction {
133                "view-sort-ascending-symbolic"
134            } else {
135                "view-sort-descending-symbolic"
136            };
137            widget::icon::from_name(icon_name).size(14).into()
138        } else {
139            space::horizontal().width(Length::Fixed(14.0)).into()
140        };
141        menu_button!(
142            leading,
143            space::horizontal().width(Length::Fixed(theme::spacing().space_xxs.into())),
144            text::body(label),
145            space::horizontal(),
146            text::body(key).class(theme::Text::Custom(key_style))
147        )
148        .on_press(tab::Message::ContextAction(Action::ToggleSort(variant)))
149        .into()
150    };
151
152    let mut selected_dir = 0;
153    let mut selected = 0;
154    let mut selected_trash_only = false;
155    let mut selected_desktop_entry = None;
156    let mut selected_types: Vec<Mime> = vec![];
157    let mut selected_mount_point = 0;
158    let mut selected_client_point = 0;
159    let mut selected_remote_paths: Vec<PathBuf> = Vec::new();
160    if let Some(items) = tab.items_opt() {
161        for item in items {
162            if item.selected {
163                selected += 1;
164                if item.metadata.is_dir() {
165                    selected_mount_point += i32::from(item.is_mount_point);
166                    selected_client_point += i32::from(item.is_client_point);
167                    selected_dir += 1;
168                }
169                if let Some(Location::Remote(_, _, Some(path))) = &item.location_opt {
170                    selected_remote_paths.push(path.clone());
171                }
172                match &item.location_opt {
173                    Some(Location::Trash) | Some(Location::Search(SearchLocation::Trash, ..)) => {
174                        selected_trash_only = true
175                    }
176                    Some(Location::Path(path))
177                        if selected == 1
178                            && path.extension().and_then(|s| s.to_str()) == Some("desktop") =>
179                    {
180                        selected_desktop_entry = Some(&**path);
181                    }
182                    _ => (),
183                }
184                selected_types.push(item.mime.clone());
185            }
186        }
187    }
188    selected_types.sort_unstable();
189    selected_types.dedup();
190    selected_trash_only = selected_trash_only && selected == 1;
191    let context_action_items = |selected: usize, selected_dir: usize| {
192        context_actions
193            .iter()
194            .enumerate()
195            .filter(|(_, action)| action.matches_selection(selected, selected_dir))
196            .map(|(i, action)| menu_item(action.name.clone(), Action::RunContextAction(i)).into())
197            .collect::<Vec<Element<'a, tab::Message>>>()
198    };
199    // Parse the desktop entry if it is the only selection
200    #[cfg(feature = "desktop")]
201    let selected_desktop_entry = selected_desktop_entry.and_then(|path| {
202        if selected == 1 {
203            let lang_id = crate::localize::LANGUAGE_LOADER.current_language();
204            let language = lang_id.language.as_str();
205            // Cache?
206            cosmic::desktop::load_desktop_file(&[language.into()], path.into())
207        } else {
208            None
209        }
210    });
211
212    let mut children: Vec<Element<_>> = Vec::new();
213    match (&tab.mode, &tab.location) {
214        (
215            tab::Mode::App | tab::Mode::Desktop,
216            Location::Desktop(..)
217            | Location::Path(..)
218            | Location::Search(SearchLocation::Path(..), ..)
219            | Location::Search(SearchLocation::Recents, ..)
220            | Location::Recents
221            | Location::Network(_, _, Some(_)),
222        ) => {
223            if selected_trash_only {
224                children.push(menu_item(fl!("open"), Action::Open).into());
225                if !Trash::is_empty() {
226                    children.push(menu_item(fl!("empty-trash"), Action::EmptyTrash).into());
227                }
228            } else if let Some(_entry) = selected_desktop_entry {
229                children.push(menu_item(fl!("open"), Action::Open).into());
230                #[cfg(feature = "desktop")]
231                {
232                    children.extend(entry.desktop_actions.into_iter().enumerate().map(
233                        |(i, action)| menu_item(action.name, Action::ExecEntryAction(i)).into(),
234                    ));
235                }
236                children.push(divider::horizontal::light().into());
237                children.push(menu_item(fl!("rename"), Action::Rename).into());
238                children.push(menu_item(fl!("cut"), Action::Cut).into());
239                if modifiers.shift() && !modifiers.control() {
240                    children.push(menu_item(fl!("copy-path"), Action::CopyPath).into());
241                } else {
242                    children.push(menu_item(fl!("copy"), Action::Copy).into());
243                }
244                // Should this simply bypass trash and remove the shortcut?
245                children.push(menu_item(fl!("move-to-trash"), Action::Delete).into());
246                let action_items = context_action_items(selected, selected_dir);
247                if !action_items.is_empty() {
248                    children.push(divider::horizontal::light().into());
249                    children.extend(action_items);
250                }
251            } else if selected > 0 {
252                if selected_dir == 1 && selected == 1 || selected_dir == 0 {
253                    children.push(menu_item(fl!("open"), Action::Open).into());
254                }
255                if selected == 1 {
256                    children.push(menu_item(fl!("menu-open-with"), Action::OpenWith).into());
257                    if selected_dir == 1 {
258                        children
259                            .push(menu_item(fl!("open-in-terminal"), Action::OpenTerminal).into());
260                    }
261                }
262                if tab.location.is_recents() || matches!(tab.location, Location::Search(..)) {
263                    children.push(
264                        menu_item(fl!("open-item-location"), Action::OpenItemLocation).into(),
265                    );
266                }
267                // All selected items are directories
268                if selected == selected_dir && matches!(tab.mode, tab::Mode::App) {
269                    children.push(menu_item(fl!("open-in-new-tab"), Action::OpenInNewTab).into());
270                    children
271                        .push(menu_item(fl!("open-in-new-window"), Action::OpenInNewWindow).into());
272                }
273                let action_items = context_action_items(selected, selected_dir);
274                if !action_items.is_empty() {
275                    children.push(divider::horizontal::light().into());
276                    children.extend(action_items);
277                }
278                children.push(divider::horizontal::light().into());
279                if selected_mount_point == 0 && selected_client_point == 0 {
280                    children.push(menu_item(fl!("rename"), Action::Rename).into());
281                    children.push(menu_item(fl!("cut"), Action::Cut).into());
282                }
283                if modifiers.shift() && !modifiers.control() {
284                    children.push(menu_item(fl!("copy-path"), Action::CopyPath).into());
285                } else {
286                    children.push(menu_item(fl!("copy"), Action::Copy).into());
287                }
288                if selected_mount_point == 0 {
289                    children.push(menu_item(fl!("move-to"), Action::MoveTo).into());
290                }
291                children.push(menu_item(fl!("copy-to"), Action::CopyTo).into());
292
293                children.push(divider::horizontal::light().into());
294                let supported_archive_types = crate::archive::SUPPORTED_ARCHIVE_TYPES;
295                selected_types.retain(|t| supported_archive_types.iter().copied().all(|m| *t != m));
296                if selected_types.is_empty() {
297                    children.push(menu_item(fl!("extract-here"), Action::ExtractHere).into());
298                    children.push(menu_item(fl!("extract-to"), Action::ExtractTo).into());
299                }
300                children.push(menu_item(fl!("compress"), Action::Compress).into());
301                children.push(divider::horizontal::light().into());
302
303                //TODO: Print?
304                children.push(menu_item(fl!("show-details"), Action::Preview).into());
305                if matches!(tab.mode, tab::Mode::App) {
306                    children.push(divider::horizontal::light().into());
307                    children.push(menu_item(fl!("add-to-sidebar"), Action::AddToSidebar).into());
308                }
309                children.push(divider::horizontal::light().into());
310                if tab.location.is_recents() {
311                    children.push(
312                        menu_item(fl!("remove-from-recents"), Action::RemoveFromRecents).into(),
313                    );
314                    children.push(divider::horizontal::light().into());
315                }
316                if selected_mount_point == 0 && selected_client_point == 0 {
317                    if modifiers.shift() && !modifiers.control() {
318                        children.push(
319                            menu_item(fl!("delete-permanently"), Action::PermanentlyDelete).into(),
320                        );
321                    } else {
322                        children.push(menu_item(fl!("move-to-trash"), Action::Delete).into());
323                    }
324                } else if selected == 1 {
325                    children.push(menu_item(fl!("eject"), Action::Eject).into());
326                }
327            } else {
328                //TODO: need better designs for menu with no selection
329                //TODO: have things like properties but they apply to the folder?
330                if tab.location != Location::Recents {
331                    children.push(menu_item(fl!("new-folder"), Action::NewFolder).into());
332                    children.push(menu_item(fl!("new-file"), Action::NewFile).into());
333                    children.push(menu_item(fl!("open-in-terminal"), Action::OpenTerminal).into());
334                    children.push(divider::horizontal::light().into());
335                }
336
337                if tab.mode.multiple() {
338                    children.push(menu_item(fl!("select-all"), Action::SelectAll).into());
339                }
340                if can_paste {
341                    children.push(menu_item(fl!("paste"), Action::Paste).into());
342                } else {
343                    children.push(menu_item_disabled(fl!("paste"), Action::Paste).into());
344                }
345
346                //TODO: only show if cosmic-settings is found?
347                if matches!(tab.mode, tab::Mode::Desktop) {
348                    children.push(divider::horizontal::light().into());
349                    children.push(
350                        menu_item(fl!("change-wallpaper"), Action::CosmicSettingsWallpaper).into(),
351                    );
352                    children.push(
353                        menu_item(fl!("desktop-appearance"), Action::CosmicSettingsDesktop).into(),
354                    );
355                    children.push(
356                        menu_item(fl!("display-settings"), Action::CosmicSettingsDisplays).into(),
357                    );
358                }
359
360                children.push(divider::horizontal::light().into());
361                // TODO: Nested menu
362                children.push(sort_item(fl!("sort-by-name"), HeadingOptions::Name));
363                children.push(sort_item(fl!("sort-by-modified"), HeadingOptions::Modified));
364                children.push(sort_item(fl!("sort-by-size"), HeadingOptions::Size));
365                if matches!(tab.location, Location::Desktop(..)) {
366                    children.push(divider::horizontal::light().into());
367                    children.push(
368                        menu_item(fl!("desktop-view-options"), Action::DesktopViewOptions).into(),
369                    );
370                }
371            }
372        }
373        (
374            tab::Mode::Dialog(dialog_kind),
375            Location::Desktop(..)
376            | Location::Path(..)
377            | Location::Search(SearchLocation::Path(..), ..)
378            | Location::Search(SearchLocation::Recents, ..)
379            | Location::Recents
380            | Location::Network(_, _, Some(_)),
381        ) => {
382            if selected > 0 {
383                if selected_dir == 1 && selected == 1 || selected_dir == 0 {
384                    children.push(menu_item(fl!("open"), Action::Open).into());
385                }
386                if matches!(tab.location, Location::Search(..)) || tab.location.is_recents() {
387                    children.push(
388                        menu_item(fl!("open-item-location"), Action::OpenItemLocation).into(),
389                    );
390                }
391                children.push(divider::horizontal::light().into());
392                children.push(menu_item(fl!("show-details"), Action::Preview).into());
393            } else {
394                if dialog_kind.save() {
395                    children.push(menu_item(fl!("new-folder"), Action::NewFolder).into());
396                }
397                if tab.mode.multiple() {
398                    children.push(menu_item(fl!("select-all"), Action::SelectAll).into());
399                }
400                if !children.is_empty() {
401                    children.push(divider::horizontal::light().into());
402                }
403                children.push(sort_item(fl!("sort-by-name"), HeadingOptions::Name));
404                children.push(sort_item(fl!("sort-by-modified"), HeadingOptions::Modified));
405                children.push(sort_item(fl!("sort-by-size"), HeadingOptions::Size));
406            }
407        }
408        (_, Location::Network(..)) => {
409            if selected > 0 {
410                if selected_dir == 1 && selected == 1 || selected_dir == 0 {
411                    children.push(menu_item(fl!("open"), Action::Open).into());
412                }
413            } else {
414                if tab.mode.multiple() {
415                    children.push(menu_item(fl!("select-all"), Action::SelectAll).into());
416                }
417                if !children.is_empty() {
418                    children.push(divider::horizontal::light().into());
419                }
420                children.push(sort_item(fl!("sort-by-name"), HeadingOptions::Name));
421                children.push(sort_item(fl!("sort-by-modified"), HeadingOptions::Modified));
422                children.push(sort_item(fl!("sort-by-size"), HeadingOptions::Size));
423            }
424        }
425        (_, Location::Remote(..)) => {
426            if selected > 0 {
427                if selected == selected_remote_paths.len() && selected_dir == 0 {
428                    if config.pair1_suffix.is_empty() || config.pair2_suffix.is_empty() {
429                        children.push(
430                            menu_item(fl!("run-tb-profiler"), Action::TbProfilerConfigError)
431                                .into(),
432                        );
433                    } else if is_valid_fastq_selection(&selected_remote_paths, config) {
434                        children
435                            .push(menu_item(fl!("run-tb-profiler"), Action::RunTbProfiler).into());
436                    }
437                }
438                if matches!(tab.mode, tab::Mode::App) {
439                    children.push(divider::horizontal::light().into());
440                    children.push(menu_item(fl!("add-to-sidebar"), Action::AddToSidebar).into());
441                }
442                if selected == 1 && selected_dir == 0 {
443                    children.push(
444                        menu_item(fl!("delete-remote-file"), Action::DeleteRemoteFiles).into(),
445                    );
446                }
447                if selected > 1 && selected_dir == 0 {
448                    children.push(
449                        menu_item(fl!("delete-remote-files"), Action::DeleteRemoteFiles).into(),
450                    );
451                }
452                children.push(menu_item(fl!("download-to"), Action::DownloadTo).into());
453            } else {
454                if tab.mode.multiple() {
455                    children.push(menu_item(fl!("select-all"), Action::SelectAll).into());
456                }
457                if !children.is_empty() {
458                    children.push(divider::horizontal::light().into());
459                }
460                children.push(sort_item(fl!("sort-by-name"), HeadingOptions::Name));
461                children.push(sort_item(fl!("sort-by-modified"), HeadingOptions::Modified));
462                children.push(sort_item(fl!("sort-by-size"), HeadingOptions::Size));
463            }
464        }
465        (_, Location::Trash | Location::Search(SearchLocation::Trash, ..)) => {
466            if tab.mode.multiple() {
467                children.push(menu_item(fl!("select-all"), Action::SelectAll).into());
468            }
469            if !children.is_empty() {
470                children.push(divider::horizontal::light().into());
471            }
472            if selected > 0 {
473                children.push(menu_item(fl!("show-details"), Action::Preview).into());
474                children.push(divider::horizontal::light().into());
475                children
476                    .push(menu_item(fl!("restore-from-trash"), Action::RestoreFromTrash).into());
477                children.push(divider::horizontal::light().into());
478                children.push(menu_item(fl!("delete-permanently"), Action::Delete).into());
479            } else {
480                // TODO: Nested menu
481                children.push(sort_item(fl!("sort-by-name"), HeadingOptions::Name));
482                children.push(sort_item(fl!("sort-by-trashed"), HeadingOptions::TrashedOn));
483                children.push(sort_item(fl!("sort-by-size"), HeadingOptions::Size));
484            }
485        }
486    }
487
488    container(column::with_children(children))
489        .padding(1)
490        //TODO: move style to libcosmic
491        .style(|theme| {
492            let cosmic = theme.cosmic();
493            let component = &cosmic.background.component;
494            container::Style {
495                icon_color: Some(component.on.into()),
496                text_color: Some(component.on.into()),
497                background: Some(Background::Color(component.base.into())),
498                border: Border {
499                    radius: cosmic.radius_s().map(|x| x + 1.0).into(),
500                    width: 1.0,
501                    color: component.divider.into(),
502                },
503                ..Default::default()
504            }
505        })
506        .width(Length::Fixed(360.0))
507        .into()
508}
509
510pub fn dialog_menu(
511    tab: &Tab,
512    key_binds: &HashMap<KeyBind, Action>,
513    show_details: bool,
514) -> Element<'static, Message> {
515    let (sort_name, sort_direction, _) = tab.sort_options();
516    let sort_item = |label, sort, dir| {
517        menu::Item::CheckBox(
518            label,
519            None,
520            sort_name == sort && sort_direction == dir,
521            Action::SetSort(sort, dir),
522        )
523    };
524    let in_trash = tab.location.is_trash();
525
526    let mut selected_gallery = 0;
527    if let Some(items) = tab.items_opt() {
528        for item in items {
529            if item.selected && item.can_gallery() {
530                selected_gallery += 1;
531            }
532        }
533    }
534
535    MenuBar::new(vec![
536        menu::Tree::with_children(
537            Element::from(
538                widget::button::icon(widget::icon::from_name(match tab.config.view {
539                    tab::View::Grid => "view-grid-symbolic",
540                    tab::View::List => "view-list-symbolic",
541                }))
542                // This prevents the button from being shown as insensitive
543                .on_press(Message::None)
544                .padding(8),
545            ),
546            menu::items(
547                key_binds,
548                vec![
549                    menu::Item::CheckBox(
550                        fl!("grid-view"),
551                        None,
552                        matches!(tab.config.view, tab::View::Grid),
553                        Action::TabViewGrid,
554                    ),
555                    menu::Item::CheckBox(
556                        fl!("list-view"),
557                        None,
558                        matches!(tab.config.view, tab::View::List),
559                        Action::TabViewList,
560                    ),
561                ],
562            ),
563        ),
564        menu::Tree::with_children(
565            Element::from(
566                widget::button::icon(widget::icon::from_name(if sort_direction {
567                    "view-sort-ascending-symbolic"
568                } else {
569                    "view-sort-descending-symbolic"
570                }))
571                // This prevents the button from being shown as insensitive
572                .on_press(Message::None)
573                .padding(8),
574            ),
575            menu::items(
576                key_binds,
577                vec![
578                    sort_item(fl!("sort-a-z"), tab::HeadingOptions::Name, true),
579                    sort_item(fl!("sort-z-a"), tab::HeadingOptions::Name, false),
580                    sort_item(
581                        fl!("sort-newest-first"),
582                        if in_trash {
583                            tab::HeadingOptions::TrashedOn
584                        } else {
585                            tab::HeadingOptions::Modified
586                        },
587                        false,
588                    ),
589                    sort_item(
590                        fl!("sort-oldest-first"),
591                        if in_trash {
592                            tab::HeadingOptions::TrashedOn
593                        } else {
594                            tab::HeadingOptions::Modified
595                        },
596                        true,
597                    ),
598                    sort_item(
599                        fl!("sort-smallest-to-largest"),
600                        tab::HeadingOptions::Size,
601                        true,
602                    ),
603                    sort_item(
604                        fl!("sort-largest-to-smallest"),
605                        tab::HeadingOptions::Size,
606                        false,
607                    ),
608                    //TODO: sort by type
609                ],
610            ),
611        ),
612        menu::Tree::with_children(
613            Element::from(
614                widget::button::icon(widget::icon::from_name("view-more-symbolic"))
615                    // This prevents the button from being shown as insensitive
616                    .on_press(Message::None)
617                    .padding(8),
618            ),
619            menu::items(
620                key_binds,
621                vec![
622                    menu::Item::Button(fl!("zoom-in"), None, Action::ZoomIn),
623                    menu::Item::Button(fl!("default-size"), None, Action::ZoomDefault),
624                    menu::Item::Button(fl!("zoom-out"), None, Action::ZoomOut),
625                    menu::Item::Divider,
626                    menu::Item::CheckBox(
627                        fl!("show-hidden-files"),
628                        None,
629                        tab.config.show_hidden,
630                        Action::ToggleShowHidden,
631                    ),
632                    menu::Item::CheckBox(
633                        fl!("list-directories-first"),
634                        None,
635                        tab.config.folders_first,
636                        Action::ToggleFoldersFirst,
637                    ),
638                    menu::Item::CheckBox(fl!("show-details"), None, show_details, Action::Preview),
639                    menu::Item::Divider,
640                    menu_button_optional(
641                        fl!("gallery-preview"),
642                        Action::Gallery,
643                        selected_gallery > 0,
644                    ),
645                ],
646            ),
647        ),
648    ])
649    .item_height(ItemHeight::Dynamic(40))
650    .item_width(ItemWidth::Uniform(360))
651    .spacing(theme::spacing().space_xxxs.into())
652    .into()
653}
654
655pub fn menu_bar<'a>(
656    core: &Core,
657    tab_opt: Option<&Tab>,
658    config: &Config,
659    modifiers: &Modifiers,
660    key_binds: &HashMap<KeyBind, Action>,
661    clipboard_paste_available: bool,
662) -> Element<'a, Message> {
663    let sort_options = tab_opt.map(Tab::sort_options);
664    let sort_item = |label, sort, dir| {
665        menu::Item::CheckBox(
666            label,
667            None,
668            sort_options.is_some_and(|(sort_name, sort_direction, _)| {
669                sort_name == sort && sort_direction == dir
670            }),
671            Action::SetSort(sort, dir),
672        )
673    };
674    let in_trash = tab_opt.is_some_and(|tab| tab.location.is_trash());
675
676    let mut selected_dir = 0;
677    let mut selected = 0;
678    let mut selected_gallery = 0;
679    if let Some(items) = tab_opt.and_then(|tab| tab.items_opt()) {
680        for item in items {
681            if item.selected {
682                selected += 1;
683                if item.metadata.is_dir() {
684                    selected_dir += 1;
685                }
686                if item.can_gallery() {
687                    selected_gallery += 1;
688                }
689            }
690        }
691    }
692
693    // Allow paste when clipboard has data and we're in a location that supports it
694    let can_paste =
695        clipboard_paste_available && tab_opt.is_some_and(|tab| tab.location.supports_paste());
696
697    let (delete_item, delete_item_action) = if in_trash || modifiers.shift() {
698        (fl!("delete-permanently"), Action::Delete)
699    } else {
700        (fl!("move-to-trash"), Action::Delete)
701    };
702
703    responsive_menu_bar()
704        .item_height(ItemHeight::Dynamic(40))
705        .item_width(ItemWidth::Uniform(360))
706        .spacing(theme::spacing().space_xxxs.into())
707        .into_element(
708            core,
709            key_binds,
710            MENU_ID.clone(),
711            Message::Surface,
712            vec![
713                (
714                    fl!("file"),
715                    vec![
716                        menu::Item::Button(fl!("new-tab"), None, Action::TabNew),
717                        menu::Item::Button(fl!("new-window"), None, Action::WindowNew),
718                        menu::Item::Button(fl!("new-folder"), None, Action::NewFolder),
719                        menu::Item::Button(fl!("new-file"), None, Action::NewFile),
720                        menu_button_optional(
721                            fl!("open"),
722                            Action::Open,
723                            (selected > 0 && selected_dir == 0)
724                                || (selected_dir == 1 && selected == 1),
725                        ),
726                        menu_button_optional(
727                            fl!("menu-open-with"),
728                            Action::OpenWith,
729                            selected == 1,
730                        ),
731                        menu::Item::Divider,
732                        menu_button_optional(fl!("rename"), Action::Rename, selected > 0),
733                        menu::Item::Divider,
734                        menu::Item::Button(fl!("reload-folder"), None, Action::Reload),
735                        menu::Item::Divider,
736                        menu_button_optional(
737                            fl!("add-to-sidebar"),
738                            Action::AddToSidebar,
739                            selected > 0,
740                        ),
741                        menu::Item::Divider,
742                        menu_button_optional(
743                            fl!("restore-from-trash"),
744                            Action::RestoreFromTrash,
745                            selected > 0 && in_trash,
746                        ),
747                        menu_button_optional(delete_item, delete_item_action, selected > 0),
748                        menu::Item::Divider,
749                        menu::Item::Button(fl!("close-tab"), None, Action::TabClose),
750                        menu::Item::Button(fl!("quit"), None, Action::WindowClose),
751                    ],
752                ),
753                (
754                    (fl!("edit")),
755                    vec![
756                        menu_button_optional(fl!("cut"), Action::Cut, selected > 0),
757                        menu_button_optional(fl!("copy"), Action::Copy, selected > 0),
758                        menu_button_optional(fl!("move-to"), Action::MoveTo, selected > 0),
759                        menu_button_optional(fl!("copy-to"), Action::CopyTo, selected > 0),
760                        menu_button_optional(fl!("paste"), Action::Paste, can_paste),
761                        menu::Item::Button(fl!("select-all"), None, Action::SelectAll),
762                        menu::Item::Divider,
763                        menu::Item::Button(fl!("history"), None, Action::EditHistory),
764                    ],
765                ),
766                (
767                    (fl!("view")),
768                    vec![
769                        menu::Item::Button(fl!("zoom-in"), None, Action::ZoomIn),
770                        menu::Item::Button(fl!("default-size"), None, Action::ZoomDefault),
771                        menu::Item::Button(fl!("zoom-out"), None, Action::ZoomOut),
772                        menu::Item::Divider,
773                        menu::Item::CheckBox(
774                            fl!("grid-view"),
775                            None,
776                            tab_opt.is_some_and(|tab| matches!(tab.config.view, tab::View::Grid)),
777                            Action::TabViewGrid,
778                        ),
779                        menu::Item::CheckBox(
780                            fl!("list-view"),
781                            None,
782                            tab_opt.is_some_and(|tab| matches!(tab.config.view, tab::View::List)),
783                            Action::TabViewList,
784                        ),
785                        menu::Item::Divider,
786                        menu::Item::CheckBox(
787                            fl!("show-hidden-files"),
788                            None,
789                            tab_opt.is_some_and(|tab| tab.config.show_hidden),
790                            Action::ToggleShowHidden,
791                        ),
792                        menu::Item::CheckBox(
793                            fl!("show-susceptible-samples"),
794                            None,
795                            tab_opt.is_some_and(|tab| tab.config.show_susceptible),
796                            Action::ToggleShowSusceptible,
797                        ),
798                        menu::Item::CheckBox(
799                            fl!("list-directories-first"),
800                            None,
801                            tab_opt.is_some_and(|tab| tab.config.folders_first),
802                            Action::ToggleFoldersFirst,
803                        ),
804                        menu::Item::CheckBox(
805                            fl!("show-details"),
806                            None,
807                            config.show_details,
808                            Action::Preview,
809                        ),
810                        menu::Item::CheckBox(
811                            fl!("show-as-samples"),
812                            None,
813                            config.tab.show_as_samples,
814                            Action::ToggleShowAsSamples,
815                        ),
816                        menu::Item::Divider,
817                        menu_button_optional(
818                            fl!("gallery-preview"),
819                            Action::Gallery,
820                            selected_gallery > 0,
821                        ),
822                        menu::Item::Divider,
823                        menu::Item::Button(fl!("menu-settings"), None, Action::Settings),
824                        menu::Item::Button(
825                            fl!("menu-tb-profiler-settings"),
826                            None,
827                            Action::TBSettings,
828                        ),
829                        menu::Item::Divider,
830                        menu::Item::Button(fl!("menu-about"), None, Action::About),
831                    ],
832                ),
833                (
834                    (fl!("sort")),
835                    vec![
836                        sort_item(fl!("sort-a-z"), tab::HeadingOptions::Name, true),
837                        sort_item(fl!("sort-z-a"), tab::HeadingOptions::Name, false),
838                        sort_item(
839                            fl!("sort-newest-first"),
840                            if in_trash {
841                                tab::HeadingOptions::TrashedOn
842                            } else {
843                                tab::HeadingOptions::Modified
844                            },
845                            false,
846                        ),
847                        sort_item(
848                            fl!("sort-oldest-first"),
849                            if in_trash {
850                                tab::HeadingOptions::TrashedOn
851                            } else {
852                                tab::HeadingOptions::Modified
853                            },
854                            true,
855                        ),
856                        sort_item(
857                            fl!("sort-smallest-to-largest"),
858                            tab::HeadingOptions::Size,
859                            true,
860                        ),
861                        sort_item(
862                            fl!("sort-largest-to-smallest"),
863                            tab::HeadingOptions::Size,
864                            false,
865                        ),
866                        //TODO: sort by type
867                    ],
868                ),
869            ],
870        )
871}
872
873pub fn location_context_menu<'a>(ancestor_index: usize) -> Element<'a, tab::Message> {
874    //TODO: only add some of these when in App mode
875    let children = [
876        menu_button!(text::body(fl!("open-in-new-tab")))
877            .on_press(tab::Message::LocationMenuAction(
878                LocationMenuAction::OpenInNewTab(ancestor_index),
879            ))
880            .into(),
881        menu_button!(text::body(fl!("open-in-new-window")))
882            .on_press(tab::Message::LocationMenuAction(
883                LocationMenuAction::OpenInNewWindow(ancestor_index),
884            ))
885            .into(),
886        divider::horizontal::light().into(),
887        menu_button!(text::body(fl!("show-details")))
888            .on_press(tab::Message::LocationMenuAction(
889                LocationMenuAction::Preview(ancestor_index),
890            ))
891            .into(),
892        divider::horizontal::light().into(),
893        menu_button!(text::body(fl!("add-to-sidebar")))
894            .on_press(tab::Message::LocationMenuAction(
895                LocationMenuAction::AddToSidebar(ancestor_index),
896            ))
897            .into(),
898    ];
899
900    container(column::with_children(children))
901        .padding(1)
902        .style(|theme| {
903            let cosmic = theme.cosmic();
904            let component = &cosmic.background.component;
905            container::Style {
906                icon_color: Some(component.on.into()),
907                text_color: Some(component.on.into()),
908                background: Some(Background::Color(component.base.into())),
909                border: Border {
910                    radius: cosmic.radius_s().map(|x| x + 1.0).into(),
911                    width: 1.0,
912                    color: component.divider.into(),
913                },
914                ..Default::default()
915            }
916        })
917        .width(Length::Fixed(360.0))
918        .into()
919}