diff --git a/i18n/en/cosmic_files.ftl b/i18n/en/cosmic_files.ftl index f0b7264..8e4f54e 100644 --- a/i18n/en/cosmic_files.ftl +++ b/i18n/en/cosmic_files.ftl @@ -140,6 +140,9 @@ owner = Owner group = Group other = Other toolbar = Toolbar +toolbar-available = Available +toolbar-empty-hint = No buttons. Drag or add from below. +toolbar-reset = Reset to defaults parent-directory = Parent directory mixed = Mixed ### Mode 0 diff --git a/i18n/fr/cosmic_files.ftl b/i18n/fr/cosmic_files.ftl index c7b0675..214781e 100644 --- a/i18n/fr/cosmic_files.ftl +++ b/i18n/fr/cosmic_files.ftl @@ -132,6 +132,9 @@ owner = Propriétaire group = Groupe other = Autre toolbar = Barre d'outils +toolbar-available = Disponibles +toolbar-empty-hint = Aucun bouton. Glisser-déposer ou ajouter depuis la liste ci-dessous. +toolbar-reset = Rétablir par défaut parent-directory = Dossier parent ### Mode 0 diff --git a/src/app.rs b/src/app.rs index cd2058a..868fad7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -77,7 +77,7 @@ use crate::{ }, config::{ AppTheme, Config, DesktopConfig, Favorite, IconSizes, State, TIME_CONFIG_ID, TabConfig, - TimeConfig, ToolbarItems, TypeToSearch, + TimeConfig, ToolbarAction, TypeToSearch, default_toolbar, }, context_action, dialog::{Dialog, DialogKind, DialogMessage, DialogResult, DialogSettings}, @@ -145,6 +145,105 @@ pub struct Flags { pub uris: Vec, } +/// Yoda phase 3: MIME for the DnD payload carried when a user drags a +/// toolbar row in the Settings editor. A single byte = ToolbarAction +/// discriminant (see `ToolbarAction::to_u8`). +const TOOLBAR_MIME: &str = "application/x-cosmic-files-toolbar-action"; + +/// Yoda phase 3: DnD payload wrapping a ToolbarAction discriminant. +#[derive(Clone, Debug)] +pub struct ToolbarActionPayload(pub u8); + +impl cosmic::iced::clipboard::mime::AsMimeTypes for ToolbarActionPayload { + fn available(&self) -> std::borrow::Cow<'static, [String]> { + std::borrow::Cow::Owned(vec![TOOLBAR_MIME.to_owned()]) + } + fn as_bytes(&self, mime_type: &str) -> Option> { + if mime_type == TOOLBAR_MIME { + Some(std::borrow::Cow::Owned(vec![self.0])) + } else { + None + } + } +} + +impl cosmic::iced::clipboard::mime::AllowedMimeTypes for ToolbarActionPayload { + fn allowed() -> std::borrow::Cow<'static, [String]> { + std::borrow::Cow::Owned(vec![TOOLBAR_MIME.to_owned()]) + } +} + +impl TryFrom<(Vec, String)> for ToolbarActionPayload { + type Error = (); + fn try_from((data, _mime): (Vec, String)) -> Result { + if data.len() == 1 { Ok(Self(data[0])) } else { Err(()) } + } +} + +/// Yoda phase 3 helper: map a ToolbarAction to its button UI (icon name, +/// localized label, app Message). Shared by the toolbar renderer in +/// `view()` and by the Settings page row renderer so the two stay in +/// sync. +fn toolbar_action_ui(a: ToolbarAction) -> (&'static str, String, Message) { + match a { + ToolbarAction::LocationUp => ( + "go-up-symbolic", + fl!("parent-directory"), + Action::LocationUp.message(None), + ), + ToolbarAction::Reload => ( + "view-refresh-symbolic", + fl!("reload-folder"), + Action::Reload.message(None), + ), + ToolbarAction::NewFolder => ( + "folder-new-symbolic", + fl!("new-folder"), + Action::NewFolder.message(None), + ), + ToolbarAction::NewFile => ( + "document-new-symbolic", + fl!("new-file"), + Action::NewFile.message(None), + ), + ToolbarAction::Rename => ( + "pencil-symbolic", + fl!("rename"), + Action::Rename.message(None), + ), + ToolbarAction::Delete => ( + "edit-delete-symbolic", + fl!("delete"), + Action::Delete.message(None), + ), + ToolbarAction::Cut => ( + "edit-cut-symbolic", + fl!("cut"), + Action::Cut.message(None), + ), + ToolbarAction::Copy => ( + "edit-copy-symbolic", + fl!("copy"), + Action::Copy.message(None), + ), + ToolbarAction::Paste => ( + "edit-paste-symbolic", + fl!("paste"), + Action::Paste.message(None), + ), + ToolbarAction::ToggleShowHidden => ( + "view-reveal-symbolic", + fl!("show-hidden-files"), + Action::ToggleShowHidden.message(None), + ), + ToolbarAction::OpenTerminal => ( + "utilities-terminal-symbolic", + fl!("open-in-terminal"), + Action::OpenTerminal.message(None), + ), + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum Action { About, @@ -448,8 +547,11 @@ pub enum Message { SearchInput(String), SetShowDetails(bool), SetShowRecents(bool), - /// Yoda: toggle a single toolbar button visibility. - SetToolbar(ToolbarItems), + /// Yoda phase 3 — toolbar editing messages. + ToolbarAdd(ToolbarAction), + ToolbarRemove(ToolbarAction), + ToolbarReorder { src: ToolbarAction, target: ToolbarAction }, + ToolbarReset, SetTypeToSearch(TypeToSearch), SystemThemeModeChange, Size(window::Id, Size), @@ -2286,53 +2388,115 @@ impl App { .toggler(self.config.show_recents, Message::SetShowRecents) }) .into(), - // Yoda: configure which quick-action buttons show in the - // toolbar under the tab bar. Each toggle maps to one button; - // layout order inside the toolbar is fixed (file ops → - // clipboard → view). - { - let tb = self.config.toolbar; - widget::settings::section() - .title(fl!("toolbar")) - .add(widget::settings::item::builder(fl!("new-folder")) - .toggler(tb.new_folder, move |v| - Message::SetToolbar(ToolbarItems { new_folder: v, ..tb }))) - .add(widget::settings::item::builder(fl!("new-file")) - .toggler(tb.new_file, move |v| - Message::SetToolbar(ToolbarItems { new_file: v, ..tb }))) - .add(widget::settings::item::builder(fl!("rename")) - .toggler(tb.rename, move |v| - Message::SetToolbar(ToolbarItems { rename: v, ..tb }))) - .add(widget::settings::item::builder(fl!("delete")) - .toggler(tb.delete, move |v| - Message::SetToolbar(ToolbarItems { delete: v, ..tb }))) - .add(widget::settings::item::builder(fl!("cut")) - .toggler(tb.cut, move |v| - Message::SetToolbar(ToolbarItems { cut: v, ..tb }))) - .add(widget::settings::item::builder(fl!("copy")) - .toggler(tb.copy, move |v| - Message::SetToolbar(ToolbarItems { copy: v, ..tb }))) - .add(widget::settings::item::builder(fl!("paste")) - .toggler(tb.paste, move |v| - Message::SetToolbar(ToolbarItems { paste: v, ..tb }))) - .add(widget::settings::item::builder(fl!("reload-folder")) - .toggler(tb.reload, move |v| - Message::SetToolbar(ToolbarItems { reload: v, ..tb }))) - .add(widget::settings::item::builder(fl!("show-hidden-files")) - .toggler(tb.toggle_show_hidden, move |v| - Message::SetToolbar(ToolbarItems { toggle_show_hidden: v, ..tb }))) - .add(widget::settings::item::builder(fl!("open-in-terminal")) - .toggler(tb.open_terminal, move |v| - Message::SetToolbar(ToolbarItems { open_terminal: v, ..tb }))) - .add(widget::settings::item::builder(fl!("parent-directory")) - .toggler(tb.location_up, move |v| - Message::SetToolbar(ToolbarItems { location_up: v, ..tb }))) - .into() - }, + // Yoda phase 3: toolbar editor. Two stacked lists: + // - top: enabled buttons in their current order (drag to reorder) + // - bottom: available (not-yet-enabled) buttons + // Each row's toggle adds/removes; enabled rows are also + // drag sources + drop targets. + self.toolbar_settings_section(), ]) .into() } + /// Yoda phase 3: build the Toolbar settings section. + fn toolbar_settings_section(&self) -> Element<'_, Message> { + use iced::clipboard::dnd::DndAction; + let enabled = &self.config.toolbar; + let disabled: Vec = ToolbarAction::ALL + .iter() + .copied() + .filter(|a| !enabled.contains(a)) + .collect(); + + let space_xxs = theme::active().cosmic().spacing.space_xxs; + + let drag_icon = |size: u16| -> Element<'static, Message> { + widget::icon::from_name("list-drag-handle-symbolic") + .size(size) + .into() + }; + + let row_enabled = |action: ToolbarAction| -> Element<'_, Message> { + let (icon, label, _msg) = toolbar_action_ui(action); + let row_content: Element<_> = widget::row::with_children(vec![ + drag_icon(14), + widget::icon::from_name(icon).size(16).into(), + widget::text::body(label).width(Length::Fill).into(), + widget::button::icon(widget::icon::from_name("list-remove-symbolic").size(14)) + .on_press(Message::ToolbarRemove(action)) + .into(), + ]) + .spacing(space_xxs) + .align_y(Alignment::Center) + .into(); + + let row_container = widget::container(row_content) + .width(Length::Fill) + .padding(space_xxs); + + // Wrap as DnD source (drags itself) + DnD destination (accepts + // drops from other enabled rows; on drop, move the src before + // this row). + let source = widget::dnd_source::(row_container) + .drag_content(move || ToolbarActionPayload(action.to_u8())); + widget::dnd_destination( + source, + vec![std::borrow::Cow::Borrowed(TOOLBAR_MIME)], + ) + .data_received_for::(move |payload: Option| { + match payload.and_then(|p| ToolbarAction::from_u8(p.0)) { + Some(src) if src != action => { + Message::ToolbarReorder { src, target: action } + } + // No-op if payload missing / malformed / same row. + _ => Message::ToolbarReorder { src: action, target: action }, + } + }) + .action(DndAction::Move) + .into() + }; + + let row_disabled = |action: ToolbarAction| -> Element<'_, Message> { + let (icon, label, _msg) = toolbar_action_ui(action); + widget::row::with_children(vec![ + widget::icon::from_name(icon).size(16).into(), + widget::text::body(label).width(Length::Fill).into(), + widget::button::icon(widget::icon::from_name("list-add-symbolic").size(14)) + .on_press(Message::ToolbarAdd(action)) + .into(), + ]) + .spacing(space_xxs) + .align_y(Alignment::Center) + .padding(space_xxs) + .into() + }; + + let mut section = widget::settings::section().title(fl!("toolbar")); + if enabled.is_empty() { + section = section + .add(widget::text::body(fl!("toolbar-empty-hint"))); + } else { + for a in enabled.iter().copied() { + section = section.add(row_enabled(a)); + } + } + + let mut col = widget::column::with_capacity(3).spacing(space_xxs); + col = col.push(section); + if !disabled.is_empty() { + let mut avail = widget::settings::section().title(fl!("toolbar-available")); + for a in disabled { + avail = avail.add(row_disabled(a)); + } + col = col.push(avail); + } + col = col.push( + widget::button::standard(fl!("toolbar-reset")) + .on_press(Message::ToolbarReset), + ); + col.into() + } + fn get_apps_for_mime(&self, mime_type: &Mime) -> Vec<(&MimeApp, MimeAppMatch)> { let mut results = Vec::new(); @@ -4422,8 +4586,37 @@ impl Application for App { config_set!(show_recents, show_recents); return self.update_config(); } - Message::SetToolbar(toolbar) => { - config_set!(toolbar, toolbar); + Message::ToolbarAdd(action) => { + let mut tb = self.config.toolbar.clone(); + if !tb.contains(&action) { + tb.push(action); + } + config_set!(toolbar, tb); + return self.update_config(); + } + Message::ToolbarRemove(action) => { + let mut tb = self.config.toolbar.clone(); + tb.retain(|a| a != &action); + config_set!(toolbar, tb); + return self.update_config(); + } + Message::ToolbarReorder { src, target } => { + let mut tb = self.config.toolbar.clone(); + if let (Some(src_idx), Some(tgt_idx)) = ( + tb.iter().position(|a| a == &src), + tb.iter().position(|a| a == &target), + ) && src_idx != tgt_idx { + // Pull src out, then insert before the target's new position. + let item = tb.remove(src_idx); + let new_tgt = if src_idx < tgt_idx { tgt_idx - 1 } else { tgt_idx }; + tb.insert(new_tgt, item); + config_set!(toolbar, tb); + return self.update_config(); + } + return Task::none(); + } + Message::ToolbarReset => { + config_set!(toolbar, default_toolbar()); return self.update_config(); } Message::SetTypeToSearch(type_to_search) => { @@ -6570,22 +6763,21 @@ impl Application for App { ); } - // Yoda: Dolphin-style quick actions toolbar under the headerbar. - // Items are rendered from self.config.toolbar (ToolbarItems). Order - // is fixed (file ops / clipboard / view toggles); visibility per - // item is configurable from the Settings page. - // Dispatch goes through Action::message so keybindings and toolbar - // share exactly the same code path. - { + // Yoda phase 3: Dolphin-style quick actions toolbar. Items are + // rendered from self.config.toolbar (Vec) — the user + // picks the set AND the order via drag-drop in Settings. Dispatch + // goes through Action::message so keybinding and toolbar share the + // same code path. + if !self.config.toolbar.is_empty() { let clipboard_has = self.clipboard_has_content(); - let tb = self.config.toolbar; - let mut buttons: Vec> = Vec::new(); - - let tb_btn = - |icon_name: &'static str, label: String, msg: Message, enabled: bool| -> Element<_> { - let btn = widget::button::icon( - widget::icon::from_name(icon_name).size(16), - ); + let buttons: Vec> = self + .config + .toolbar + .iter() + .map(|a| { + let (icon, label, msg) = toolbar_action_ui(*a); + let enabled = !matches!(a, ToolbarAction::Paste) || clipboard_has; + let btn = widget::button::icon(widget::icon::from_name(icon).size(16)); let btn = if enabled { btn.on_press(msg) } else { btn }; widget::tooltip( btn, @@ -6593,90 +6785,16 @@ impl Application for App { widget::tooltip::Position::Bottom, ) .into() - }; - let divider = || -> Element<_> { - widget::divider::vertical::light().height(16).into() - }; - - // Group 1: location - let mut added_any = false; - if tb.location_up { - buttons.push(tb_btn("go-up-symbolic", fl!("parent-directory"), - Action::LocationUp.message(None), true)); - added_any = true; - } - if tb.reload { - buttons.push(tb_btn("view-refresh-symbolic", fl!("reload-folder"), - Action::Reload.message(None), true)); - added_any = true; - } - - // Group 2: create / edit - let mut group_started = false; - for (enabled, icon, label, msg) in [ - (tb.new_folder, "folder-new-symbolic", fl!("new-folder"), - Action::NewFolder.message(None)), - (tb.new_file, "document-new-symbolic", fl!("new-file"), - Action::NewFile.message(None)), - (tb.rename, "pencil-symbolic", fl!("rename"), - Action::Rename.message(None)), - (tb.delete, "edit-delete-symbolic", fl!("delete"), - Action::Delete.message(None)), - ] { - if enabled { - if !group_started && added_any { buttons.push(divider()); } - buttons.push(tb_btn(icon, label, msg, true)); - group_started = true; - added_any = true; - } - } - - // Group 3: clipboard - let mut group_started = false; - for (enabled, icon, label, msg, avail) in [ - (tb.cut, "edit-cut-symbolic", fl!("cut"), - Action::Cut.message(None), true), - (tb.copy, "edit-copy-symbolic", fl!("copy"), - Action::Copy.message(None), true), - (tb.paste, "edit-paste-symbolic", fl!("paste"), - Action::Paste.message(None), clipboard_has), - ] { - if enabled { - if !group_started && added_any { buttons.push(divider()); } - buttons.push(tb_btn(icon, label, msg, avail)); - group_started = true; - added_any = true; - } - } - - // Group 4: view toggles + misc - let mut group_started = false; - for (enabled, icon, label, msg) in [ - (tb.toggle_show_hidden, "view-reveal-symbolic", - fl!("show-hidden-files"), - Action::ToggleShowHidden.message(None)), - (tb.open_terminal, "utilities-terminal-symbolic", - fl!("open-in-terminal"), - Action::OpenTerminal.message(None)), - ] { - if enabled { - if !group_started && added_any { buttons.push(divider()); } - buttons.push(tb_btn(icon, label, msg, true)); - group_started = true; - added_any = true; - } - } - - if added_any { - let toolbar = widget::row::with_children(buttons) - .spacing(space_xxs) - .align_y(Alignment::Center); - tab_column = tab_column.push( - widget::container(toolbar) - .width(Length::Fill) - .padding([space_xxs, space_s]), - ); - } + }) + .collect(); + let toolbar = widget::row::with_children(buttons) + .spacing(space_xxs) + .align_y(Alignment::Center); + tab_column = tab_column.push( + widget::container(toolbar) + .width(Length::Fill) + .padding([space_xxs, space_s]), + ); } let entity = self.tab_model.active(); diff --git a/src/config.rs b/src/config.rs index b773d93..6d89367 100644 --- a/src/config.rs +++ b/src/config.rs @@ -172,10 +172,11 @@ pub struct Config { pub show_details: bool, pub show_recents: bool, pub tab: TabConfig, - /// Yoda: Dolphin-style quick actions toolbar under the tab bar. - /// Each bool toggles one button; order in the UI is fixed (logical - /// grouping file-ops then clipboard then view toggles). - pub toolbar: ToolbarItems, + /// Yoda phase 3: Dolphin-style quick actions toolbar. An ordered list + /// of enabled buttons — position in the vec drives the toolbar order. + /// Reorder in Settings via drag-drop; items not in the vec are + /// hidden. Default = the minimal-6 set from phase 1. + pub toolbar: Vec, pub type_to_search: TypeToSearch, } @@ -240,46 +241,95 @@ impl Default for Config { show_details: false, show_recents: true, tab: TabConfig::default(), - toolbar: ToolbarItems::default(), + toolbar: default_toolbar(), type_to_search: TypeToSearch::Recursive, } } } -/// Yoda: visibility toggles for each quick-action toolbar button. -/// Default = the original "minimal 6" set (new_folder, rename, delete, -/// cut, copy, paste). Other items default to false so users opt in. -#[derive(Clone, Copy, CosmicConfigEntry, Debug, Deserialize, Eq, PartialEq, Serialize)] -pub struct ToolbarItems { - pub new_folder: bool, - pub new_file: bool, - pub rename: bool, - pub delete: bool, - pub cut: bool, - pub copy: bool, - pub paste: bool, - pub reload: bool, - pub toggle_show_hidden: bool, - pub open_terminal: bool, - pub location_up: bool, +/// Yoda phase 3: ordered enum of quick-action toolbar buttons. +/// The Config stores `Vec` so the user can pick BOTH +/// visibility (just include/exclude the variant) AND order (position in +/// the vec). Drag-drop reorder in the Settings page moves items around. +#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub enum ToolbarAction { + LocationUp, + Reload, + NewFolder, + NewFile, + Rename, + Delete, + Cut, + Copy, + Paste, + ToggleShowHidden, + OpenTerminal, } -impl Default for ToolbarItems { - fn default() -> Self { - Self { - new_folder: true, - new_file: false, - rename: true, - delete: true, - cut: true, - copy: true, - paste: true, - reload: false, - toggle_show_hidden: false, - open_terminal: false, - location_up: false, +impl ToolbarAction { + /// Stable list of every supported action. Ordered roughly by logical + /// grouping (location → create/edit → clipboard → view/misc) so that + /// the default enabled set follows a sensible shape and the Settings + /// row for a not-yet-enabled action lands in a predictable spot. + pub const ALL: &'static [Self] = &[ + Self::LocationUp, + Self::Reload, + Self::NewFolder, + Self::NewFile, + Self::Rename, + Self::Delete, + Self::Cut, + Self::Copy, + Self::Paste, + Self::ToggleShowHidden, + Self::OpenTerminal, + ]; + + /// u8 discriminant used to carry the action over a DnD mime payload. + pub const fn to_u8(self) -> u8 { + match self { + Self::LocationUp => 0, + Self::Reload => 1, + Self::NewFolder => 2, + Self::NewFile => 3, + Self::Rename => 4, + Self::Delete => 5, + Self::Cut => 6, + Self::Copy => 7, + Self::Paste => 8, + Self::ToggleShowHidden => 9, + Self::OpenTerminal => 10, } } + + pub const fn from_u8(v: u8) -> Option { + match v { + 0 => Some(Self::LocationUp), + 1 => Some(Self::Reload), + 2 => Some(Self::NewFolder), + 3 => Some(Self::NewFile), + 4 => Some(Self::Rename), + 5 => Some(Self::Delete), + 6 => Some(Self::Cut), + 7 => Some(Self::Copy), + 8 => Some(Self::Paste), + 9 => Some(Self::ToggleShowHidden), + 10 => Some(Self::OpenTerminal), + _ => None, + } + } +} + +/// Default set shown on a fresh install — same "minimal 6" as phase 1/2. +pub fn default_toolbar() -> Vec { + vec![ + ToolbarAction::NewFolder, + ToolbarAction::Rename, + ToolbarAction::Delete, + ToolbarAction::Cut, + ToolbarAction::Copy, + ToolbarAction::Paste, + ] } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, CosmicConfigEntry, Deserialize, Serialize)]