diff --git a/io.github.dvlv.boxbuddyrs.gschema.xml b/io.github.dvlv.boxbuddyrs.gschema.xml index 19e8785..414460d 100644 --- a/io.github.dvlv.boxbuddyrs.gschema.xml +++ b/io.github.dvlv.boxbuddyrs.gschema.xml @@ -8,5 +8,14 @@ The terminal which should be checked for first when performing an action which spawns a terminal window. + + + {} + Per-box menu label overrides for exported applications + + Maps a box name to the label distrobox-export puts after an exported app's + name in the host menu. Empty means the distrobox default, "(on <box>)". + + diff --git a/src/distrobox_handler.rs b/src/distrobox_handler.rs index 22c6c82..e92b0b2 100644 --- a/src/distrobox_handler.rs +++ b/src/distrobox_handler.rs @@ -232,23 +232,45 @@ pub fn open_terminal_in_box(box_name: String) { } } -/// Exports the desktop file from a box. -pub fn export_app_from_box(app_name: &str, box_name: &str) -> String { - get_command_output( - "distrobox", - Some(&[ - "enter", - box_name, - "--", - "distrobox-export", - "--app", - app_name, - ]), - ) +/// The in-container path of an application's desktop file, built from the +/// desktop-file id that `get_apps_in_box` records (the file's basename). +/// distrobox reads a box's apps from `/usr/share/applications`. +fn desktop_file_path(desktop_file: &str) -> String { + format!("/usr/share/applications/{desktop_file}.desktop") } -/// Unexports a desktop file from the host. -pub fn remove_app_from_host(app_name: &str, box_name: &str) -> String { +/// Exports an application's desktop file from a box to the host menu. +/// +/// The app is identified by its desktop-file id, not its display name. `--app` +/// matches against the desktop file, and a display name can be empty, repeated +/// across apps, or match several files - which is how a single click could +/// export more than the one app. Handing distrobox the exact file path exports +/// precisely that app, and keeps export in step with how the host copy is +/// detected (`{box}-{id}.desktop`) and removed. +/// +/// `label` overrides the text distrobox puts after the app's name in the menu +/// (`--export-label`); `None` leaves distrobox's own default, `(on )`. +pub fn export_app_from_box(desktop_file: &str, box_name: &str, label: Option<&str>) -> String { + let app_path = desktop_file_path(desktop_file); + let mut args: Vec<&str> = vec![ + "enter", + box_name, + "--", + "distrobox-export", + "--app", + app_path.as_str(), + ]; + if let Some(label) = label { + args.push("--export-label"); + args.push(label); + } + get_command_output("distrobox", Some(&args)) +} + +/// Unexports an application's desktop file from the host. Identified by the same +/// desktop-file id used to export it, so removal always targets the right app. +pub fn remove_app_from_host(desktop_file: &str, box_name: &str) -> String { + let app_path = desktop_file_path(desktop_file); get_command_output( "distrobox", Some(&[ @@ -257,7 +279,7 @@ pub fn remove_app_from_host(app_name: &str, box_name: &str) -> String { "--", "distrobox-export", "--app", - app_name, + &app_path, "--delete", ]), ) @@ -937,3 +959,21 @@ mod stream_tests { assert!(exists, "streaming create did not produce a listable box"); } } + +#[cfg(test)] +mod export_tests { + use super::desktop_file_path; + + #[test] + fn builds_the_in_container_desktop_path_from_an_id() { + assert_eq!( + desktop_file_path("org.gnome.TextEditor"), + "/usr/share/applications/org.gnome.TextEditor.desktop" + ); + // A plain, single-word id is handled the same way. + assert_eq!( + desktop_file_path("gimp"), + "/usr/share/applications/gimp.desktop" + ); + } +} diff --git a/src/main.rs b/src/main.rs index a20908a..577ff60 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,7 +3,9 @@ use std::path::Path; use std::thread; use adw::{ - prelude::{ActionRowExt, MessageDialogExt, PreferencesGroupExt, PreferencesRowExt}, + prelude::{ + ActionRowExt, EntryRowExt, MessageDialogExt, PreferencesGroupExt, PreferencesRowExt, + }, ActionRow, Application, StyleManager, ToastOverlay, }; use gtk::{ @@ -27,13 +29,14 @@ use distrobox_handler::{ mod utils; use utils::{ get_assemble_icon, get_available_app_icon_name, get_available_icon_name, get_cpu_and_mem_usage, - get_deb_distros, get_distro_img, get_download_dir_path, get_my_deb_boxes, get_my_rpm_boxes, - get_rpm_distros, get_supported_terminals, get_supported_terminals_list, - get_terminal_and_separator_arg, has_distrobox_installed, has_file_extension, has_host_access, - has_podman_or_docker_installed, set_up_localisation, ADD_ICON_NAMES, APPLICATIONS_ICON_NAMES, - ASSEMBLE_FALLBACK_ICON_NAMES, COPY_ICON_NAMES, INFO_ICON_NAMES, INSTALL_PACKAGE_ICON_NAMES, - MENU_ICON_NAMES, OPEN_FILE_ICON_NAMES, REMOVE_ICON_NAMES, STOP_ICON_NAMES, TERMINAL_ICON_NAMES, - TRASH_ICON_NAMES, UPGRADE_ICON_NAMES, WARNING_ICON_NAMES, + get_deb_distros, get_distro_img, get_download_dir_path, get_exported_app_label, + get_my_deb_boxes, get_my_rpm_boxes, get_rpm_distros, get_supported_terminals, + get_supported_terminals_list, get_terminal_and_separator_arg, has_distrobox_installed, + has_file_extension, has_host_access, has_podman_or_docker_installed, set_exported_app_label, + set_up_localisation, ADD_ICON_NAMES, APPLICATIONS_ICON_NAMES, ASSEMBLE_FALLBACK_ICON_NAMES, + COPY_ICON_NAMES, INFO_ICON_NAMES, INSTALL_PACKAGE_ICON_NAMES, MENU_ICON_NAMES, + MENU_LABEL_ICON_NAMES, OPEN_FILE_ICON_NAMES, REMOVE_ICON_NAMES, STOP_ICON_NAMES, + TERMINAL_ICON_NAMES, TRASH_ICON_NAMES, UPGRADE_ICON_NAMES, WARNING_ICON_NAMES, }; const APP_ID: &str = "io.github.dvlv.boxbuddyrs"; @@ -515,6 +518,26 @@ fn make_box_tab(dbox: &DBox, window: &ApplicationWindow, tab_num: u32) -> gtk::B on_show_applications_clicked(&win_clone, show_bn_clone.clone()); }); + // Menu-label row: sets the "(on …)" label used for this box's exported apps. + let menu_label_icon = + gtk::Image::from_icon_name(&get_available_icon_name(MENU_LABEL_ICON_NAMES)); + let menu_label_row = ActionRow::new(); + // TRANSLATORS: Row Label - opens a dialog to set the menu label for exported apps + menu_label_row.set_title(&gettext("Menu Label")); + menu_label_row.set_subtitle(&format!( + "{} \"{}\"", + // TRANSLATORS: Row subtitle prefix, followed by the current menu label + gettext("Exported apps show"), + menu_label_for_export(&box_name).unwrap_or_else(|| format!("(on {box_name})")) + )); + menu_label_row.add_suffix(&menu_label_icon); + menu_label_row.set_activatable(true); + let ml_bn_clone = box_name.clone(); + let ml_win = window.clone(); + menu_label_row.connect_activated(move |_row| { + show_menu_label_dialog(&ml_win, ml_bn_clone.clone(), tab_num); + }); + // Install Deb Icon let deb_bn_clone = box_name.clone(); let install_deb_icon = @@ -555,6 +578,7 @@ fn make_box_tab(dbox: &DBox, window: &ApplicationWindow, tab_num: u32) -> gtk::B boxed_list.append(&open_terminal_row); boxed_list.append(&upgrade_row); boxed_list.append(&show_applications_row); + boxed_list.append(&menu_label_row); // Make deb / rpm row if applicable let deb_distros = get_deb_distros(); @@ -1439,14 +1463,79 @@ fn on_show_applications_clicked(window: &ApplicationWindow, box_name: String) { )); } +fn show_menu_label_dialog(window: &ApplicationWindow, box_name: String, tab_num: u32) { + let dialog = adw::MessageDialog::new( + Some(window), + // TRANSLATORS: Title of the dialog that sets a box's exported-app menu label + Some(&gettext("Menu Label")), + // TRANSLATORS: Body of the menu-label dialog + Some(&gettext( + "Set the name shown in the menu after each exported app, as \"(on …)\". Leave empty to use the box name.", + )), + ); + + let entry = adw::EntryRow::new(); + // TRANSLATORS: Entry field label in the menu-label dialog + entry.set_title(&gettext("Menu label")); + entry.set_activates_default(true); + if let Some(current) = get_exported_app_label(&box_name) { + entry.set_text(¤t); + } + + let group = adw::PreferencesGroup::new(); + group.add(&entry); + dialog.set_extra_child(Some(&group)); + + // TRANSLATORS: Button + dialog.add_response("cancel", &gettext("Cancel")); + // TRANSLATORS: Button + dialog.add_response("apply", &gettext("Apply")); + dialog.set_response_appearance("apply", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("apply")); + dialog.set_close_response("cancel"); + + let window_clone = window.clone(); + dialog.connect_response(Some("apply"), move |_dialog, _res| { + set_exported_app_label(&box_name, &entry.text()); + // Bring the entries already in the menu up to date with the new label. + reexport_box_apps(&box_name); + delayed_rerender(&window_clone, Some(tab_num)); + }); + + dialog.present(); +} + fn add_app_to_menu(app: &DBoxApp, box_name: &str, success_lbl: >k::Label) { - let _ = export_app_from_box(&app.name, box_name); + // Export by the desktop-file id, not the display name, so exactly this one + // app is exported and it matches how the host copy is detected and removed. + let label = menu_label_for_export(box_name); + let _ = export_app_from_box(&app.desktop_file, box_name, label.as_deref()); //TRANSLATORS: Success Message success_lbl.set_text(&gettext("App Exported!")); } +/// The `--export-label` to hand distrobox for a box, or `None` for its default. +/// A custom alias is wrapped in the same `(on …)` shape distrobox uses, so a +/// box with no alias set behaves exactly as before. +fn menu_label_for_export(box_name: &str) -> Option { + get_exported_app_label(box_name).map(|alias| format!("(on {alias})")) +} + +/// Re-applies the current menu label to every app already exported from a box, +/// by unexporting and re-exporting each one. Used after the alias changes so the +/// entries already in the menu pick up the new label too. +fn reexport_box_apps(box_name: &str) { + let label = menu_label_for_export(box_name); + for app in get_apps_in_box(box_name) { + if app.is_on_host { + let _ = remove_app_from_host(&app.desktop_file, box_name); + let _ = export_app_from_box(&app.desktop_file, box_name, label.as_deref()); + } + } +} + fn remove_app_from_menu(app: &DBoxApp, box_name: &str, success_lbl: >k::Label) { - let _ = remove_app_from_host(&app.name, box_name); + let _ = remove_app_from_host(&app.desktop_file, box_name); //TRANSLATORS: Success Message success_lbl.set_text(&gettext("App Removed!")); } diff --git a/src/utils.rs b/src/utils.rs index 20f3743..d65b9e0 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,7 +1,7 @@ use adw::StyleManager; use gettextrs::{bind_textdomain_codeset, setlocale, textdomain, LocaleCategory}; use gtk::gio::Settings; -use gtk::prelude::SettingsExt; +use gtk::prelude::{SettingsExt, SettingsExtManual}; use std::collections::HashMap; use std::env; use std::path::Path; @@ -139,6 +139,16 @@ pub const UPGRADE_ICON_NAMES: &[&str] = &[ ]; pub const WARNING_ICON_NAMES: &[&str] = &["dialog-warning-symbolic", "dialog-warning"]; pub const INFO_ICON_NAMES: &[&str] = &["dialog-information-symbolic", "dialog-information"]; +// The Menu Label row edits a label, so an edit icon fits better than an +// info one - and Breeze has no dialog-information-symbolic, which made the +// row fall back to a full-colour icon in an otherwise monochrome list. +// document-edit-symbolic exists in both Adwaita and Breeze. +pub const MENU_LABEL_ICON_NAMES: &[&str] = &[ + "document-edit-symbolic", + "tag-symbolic", + "dialog-information-symbolic", + "dialog-information", +]; pub const APPLICATIONS_ICON_NAMES: &[&str] = &[ "application-x-executable-symbolic", "application-x-executable", @@ -890,6 +900,31 @@ pub fn get_download_dir_path() -> String { }) } +/// The custom menu-label alias the user set for a box, or `None` for the +/// distrobox default. Stored per box in GSettings, keyed by box name. +pub fn get_exported_app_label(box_name: &str) -> Option { + let settings = Settings::new(APP_ID); + let labels: HashMap = settings.get("exported-app-labels"); + labels + .get(box_name) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Sets (or, for an empty value, clears) the custom menu-label alias for a box. +/// Clearing it means exports fall back to distrobox's own "(on )" label. +pub fn set_exported_app_label(box_name: &str, label: &str) { + let settings = Settings::new(APP_ID); + let mut labels: HashMap = settings.get("exported-app-labels"); + let trimmed = label.trim(); + if trimmed.is_empty() { + labels.remove(box_name); + } else { + labels.insert(box_name.to_string(), trimmed.to_string()); + } + let _ = settings.set("exported-app-labels", &labels); +} + #[cfg(test)] mod tests { use super::{detect_pkg_manager, PkgManager};