diff --git a/src/app/central_panel.rs b/src/app/central_panel.rs index 13e8f52..e60feb9 100644 --- a/src/app/central_panel.rs +++ b/src/app/central_panel.rs @@ -21,13 +21,14 @@ use crate::state::{SpecialTabKind, TabContent, TabKind}; use crate::theme::ThemeColors; use crate::ui::phosphor_icons::{phosphor_font, X}; use crate::ui::{ - set_overlay_blocks_nav_buttons, FileOperationResult, FormatToolbar, GoToLineResult, + render_action_menu_with_shortcuts, set_overlay_blocks_nav_buttons, ActionContext, + ActionRegistry, ContextActionId, FileOperationResult, FormatToolbar, GoToLineResult, RibbonAction, }; use eframe::egui; use log::{debug, info, trace, warn}; use rust_i18n::t; -use std::path::Path; +use std::path::{Path, PathBuf}; // ───────────────────────────────────────────────────────────────────────────── // Image Viewer Texture Cache @@ -263,14 +264,20 @@ impl FerriteApp { // Hidden in Zen Mode for distraction-free editing let mut tab_to_close: Option = None; let mut tab_swap: Option<(usize, usize)> = None; + let mut tab_context_copy_path: Option = None; + let mut tab_context_reveal_path: Option = None; + let mut tab_context_new_tab = false; + let mut tab_context_menu_opened_this_frame = false; if !zen_mode { // Collect tab info first to avoid borrow issues let tab_count = self.state.tab_count(); let active_index = self.state.active_tab_index(); - let tab_titles: Vec<(usize, String, bool)> = (0..tab_count) + let tab_titles: Vec<(usize, usize, String, bool, Option)> = (0..tab_count) .filter_map(|i| { - self.state.tab(i).map(|tab| (i, tab.title(), i == active_index)) + self.state + .tab(i) + .map(|tab| (i, tab.id, tab.title(), i == active_index, tab.path.clone())) }) .collect(); @@ -286,7 +293,7 @@ impl FerriteApp { // This ensures consistent sizing between layout and render passes let tab_widths: Vec = tab_titles .iter() - .map(|(_, title, _)| { + .map(|(_, _, title, _, _)| { let text_galley = ui.fonts_mut(|f| { f.layout_no_wrap( title.clone(), @@ -339,11 +346,11 @@ impl FerriteApp { }; let text_color = ui.visuals().text_color(); - for (idx, (((tab_idx, title, selected), (x_pos, row)), tab_width)) in tab_titles + for (((tab_idx, tab_id, title, selected, tab_path), (x_pos, row)), tab_width) in tab_titles .iter() .zip(tab_positions.iter()) .zip(tab_widths.iter()) - .enumerate() { + { // Use pre-calculated tab width for consistency let tab_width = *tab_width; @@ -352,14 +359,21 @@ impl FerriteApp { egui::vec2(tab_width, tab_height) ); - // Tab interaction - support both click and drag for reordering - let tab_response = ui.interact( + // Split click/right-click from drag handling so the context + // menu stays responsive while drag reorder still works. + let tab_click_response = ui.interact( tab_rect, - egui::Id::new("tab").with(idx), - egui::Sense::click_and_drag() + egui::Id::new("tab_click").with(*tab_id), + egui::Sense::click() ); + let tab_drag_response = ui.interact( + tab_rect, + egui::Id::new("tab_drag").with(*tab_id), + egui::Sense::drag() + ); + let tab_hovered = tab_click_response.hovered() || tab_drag_response.hovered(); - if tab_response.double_clicked() { + if tab_click_response.double_clicked() { if let Some(tab) = self.state.tab(*tab_idx) { if matches!(tab.kind, TabKind::Document) && tab.path.is_none() @@ -373,8 +387,28 @@ impl FerriteApp { } } + let tab_secondary_clicked = tab_click_response.secondary_clicked() + || ui.input(|i| { + i.pointer.button_clicked(egui::PointerButton::Secondary) + && i.pointer + .interact_pos() + .is_some_and(|pos| tab_rect.contains(pos)) + }); + + if tab_secondary_clicked { + self.state.set_active_tab(*tab_idx); + self.pending_cjk_check = true; + let popup_pos = ui + .ctx() + .input(|i| i.pointer.interact_pos()) + .unwrap_or(tab_rect.left_bottom()); + self.state.ui.tab_context_menu = Some((*tab_idx, popup_pos)); + tab_context_menu_opened_this_frame = true; + ui.ctx().request_repaint(); + } + // Handle drag-and-drop for tab reordering - if tab_response.dragged() { + if tab_drag_response.dragged() { egui::DragAndDrop::set_payload(ui.ctx(), *tab_idx); // Show drag cursor ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); @@ -382,7 +416,7 @@ impl FerriteApp { // Check if another tab is being dropped on this one let mut is_drop_target = false; - if tab_response.hovered() && ui.ctx().input(|i| i.pointer.any_released()) { + if tab_hovered && ui.ctx().input(|i| i.pointer.any_released()) { if let Some(dragged_tab_idx) = egui::DragAndDrop::payload::( ui.ctx() @@ -394,8 +428,8 @@ impl FerriteApp { } } } - if tab_response.hovered() { - if let Some(_) = egui::DragAndDrop::payload::(ui.ctx()) { + if tab_hovered { + if egui::DragAndDrop::payload::(ui.ctx()).is_some() { is_drop_target = true; } } @@ -411,7 +445,7 @@ impl FerriteApp { ui.painter().rect_filled(tab_rect, 4.0, indicator_color); } else if *selected { ui.painter().rect_filled(tab_rect, 4.0, selected_bg); - } else if tab_response.hovered() { + } else if tab_hovered { ui.painter().rect_filled(tab_rect, 4.0, hover_bg); } @@ -436,7 +470,7 @@ impl FerriteApp { ); let close_response = ui.interact( close_rect, - egui::Id::new("tab_close").with(idx), + egui::Id::new("tab_close").with(*tab_id), egui::Sense::click() ); @@ -454,18 +488,41 @@ impl FerriteApp { ); // Handle interactions - if tab_response.clicked() && !close_response.hovered() { + let primary_pressed_in_tab = ui.input(|i| { + i.pointer.button_pressed(egui::PointerButton::Primary) + && i.pointer.interact_pos().is_some_and(|pos| { + tab_rect.contains(pos) && !close_rect.contains(pos) + }) + }); + if primary_pressed_in_tab + || (tab_click_response.clicked() && !close_response.hovered()) + { self.state.set_active_tab(*tab_idx); self.pending_cjk_check = true; } - if close_response.clicked() || tab_response.middle_clicked() { + if close_response.clicked() || tab_click_response.middle_clicked() { tab_to_close = Some(*tab_idx); } if close_response.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } else if tab_response.hovered() { + } else if tab_hovered { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } + + if tab_hovered && !close_response.hovered() { + if let Some(path) = tab_path { + egui::Tooltip::always_open( + ui.ctx().clone(), + ui.layer_id(), + egui::Id::new("tab_path_tooltip").with(*tab_id), + egui::PopupAnchor::Pointer, + ) + .show(|ui| { + ui.set_max_width(480.0); + ui.label(path.display().to_string()); + }); + } + } } // Draw + button - use pre-calculated tab widths for consistency @@ -511,9 +568,68 @@ impl FerriteApp { text_color ); if plus_response.clicked() { + tab_context_new_tab = true; + } + plus_response + .clone() + .on_hover_text(t!("tooltip.new_tab").to_string()); + + if let Some((tab_idx, popup_pos)) = self.state.ui.tab_context_menu { + let menu_id = ui.make_persistent_id("tab_strip_context_menu"); + let tab_path = self.state.tab(tab_idx).and_then(|tab| tab.path.clone()); + let mut selected_action: Option = None; + let actions = ActionRegistry::actions_for(ActionContext::Tab { + has_file_path: tab_path.is_some(), + }); + + let area_response = egui::Area::new(menu_id) + .order(egui::Order::Foreground) + .fixed_pos(popup_pos) + .interactable(true) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_min_width(230.0); + selected_action = render_action_menu_with_shortcuts( + ui, + &actions, + Some(&self.state.settings.keyboard_shortcuts), + ); + }); + }); + + match selected_action { + Some(ContextActionId::NewTab) => tab_context_new_tab = true, + Some(ContextActionId::CloseTab) => tab_to_close = Some(tab_idx), + Some(ContextActionId::CopyPath) => { + tab_context_copy_path = tab_path.clone(); + } + Some(ContextActionId::RevealInExplorer) => { + tab_context_reveal_path = tab_path.clone(); + } + None => {} + } + + let action_clicked = tab_context_new_tab + || tab_to_close == Some(tab_idx) + || tab_context_copy_path.is_some() + || tab_context_reveal_path.is_some(); + let escape_pressed = ui.input(|i| i.key_pressed(egui::Key::Escape)); + let outside_pressed = !tab_context_menu_opened_this_frame + && ui.input(|i| { + i.pointer.any_pressed() + && i.pointer + .interact_pos() + .is_some_and(|pos| !area_response.response.rect.contains(pos)) + }); + + if action_clicked || escape_pressed || outside_pressed { + self.state.ui.tab_context_menu = None; + } + } + + if tab_context_new_tab { self.state.new_tab(); } - plus_response.on_hover_text(t!("tooltip.new_tab").to_string()); // Handle tab swap (drag-and-drop reorder) if let Some((from_idx, to_idx)) = tab_swap { @@ -524,6 +640,7 @@ impl FerriteApp { // Handle tab close action if let Some(index) = tab_to_close { + self.state.ui.tab_context_menu = None; // Get tab_id before closing for viewer state cleanup let tab_id = self.state .tabs() @@ -535,6 +652,20 @@ impl FerriteApp { } } + if let Some(path) = tab_context_copy_path { + ui.ctx().copy_text(path.display().to_string()); + } + + if let Some(path) = tab_context_reveal_path { + if let Err(e) = open::that(&path) { + warn!("Failed to reveal tab in explorer: {}", e); + self.state + .show_error(t!("error.explorer_failed", error = e.to_string()).to_string()); + } else { + debug!("Revealed tab in explorer: {}", path.display()); + } + } + // Draw a visible separator line between tabs and editor // Uses stronger contrast than default egui separator for accessibility ui.add_space(2.0); @@ -552,6 +683,8 @@ impl FerriteApp { ); } ui.add_space(3.0); + } else { + self.state.ui.tab_context_menu = None; } // End of tab bar (hidden in Zen Mode) // Check if active tab is a special tab (settings, about, etc.) diff --git a/src/app/navigation.rs b/src/app/navigation.rs index 9d715a4..436ed88 100644 --- a/src/app/navigation.rs +++ b/src/app/navigation.rs @@ -20,6 +20,7 @@ impl FerriteApp { /// Handle closing the current tab (with unsaved prompt if needed). pub(crate) fn handle_close_current_tab(&mut self, ctx: &egui::Context) { let index = self.state.active_tab_index(); + self.state.ui.tab_context_menu = None; // Get tab_id before closing for viewer state cleanup let tab_id = self.state.tabs().get(index).map(|t| t.id); self.state.close_tab(index); diff --git a/src/state.rs b/src/state.rs index aa24d75..b1e1d48 100644 --- a/src/state.rs +++ b/src/state.rs @@ -3204,6 +3204,8 @@ pub struct UiState { pub code_execution_consent_focus_cancel: bool, /// Rename a pathless document tab: `(tab_index, text buffer)`. pub rename_untitled_tab: Option<(usize, String)>, + /// Explicit foreground menu state for the tab-strip context menu: `(tab_index, anchor_pos)`. + pub tab_context_menu: Option<(usize, egui::Pos2)>, } /// True when a persisted session title matches a special tab (Settings, About, Welcome). diff --git a/src/ui/action_registry.rs b/src/ui/action_registry.rs new file mode 100644 index 0000000..017a8a2 --- /dev/null +++ b/src/ui/action_registry.rs @@ -0,0 +1,157 @@ +//! Shared metadata for small context menus. +//! +//! App code owns side effects; this module only renders action rows and returns +//! the clicked action id. + +use crate::config::{KeyboardShortcuts, ShortcutCommand}; +use eframe::egui::{self, RichText, Ui}; +use rust_i18n::t; + +/// Stable ids for context-menu actions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ContextActionId { + NewTab, + CloseTab, + CopyPath, + RevealInExplorer, +} + +/// The surface requesting actions. +#[derive(Debug, Clone, Copy)] +pub enum ActionContext { + Tab { has_file_path: bool }, +} + +/// Context-menu action metadata. +#[derive(Debug, Clone)] +pub struct ActionDefinition { + pub id: ContextActionId, + pub display_name: String, + pub group: u8, + pub shortcut_command: Option, +} + +/// Registry for context-menu actions. +pub struct ActionRegistry; + +impl ActionRegistry { + pub fn actions_for(context: ActionContext) -> Vec { + match context { + ActionContext::Tab { has_file_path } => { + let mut actions = vec![ + ActionDefinition::new( + ContextActionId::NewTab, + localized_label(t!("tab.new_tab").to_string(), "New Tab"), + 0, + Some(ShortcutCommand::NewTab), + ), + ActionDefinition::new( + ContextActionId::CloseTab, + localized_label(t!("tab.close").to_string(), "Close Tab"), + 0, + Some(ShortcutCommand::CloseTab), + ), + ]; + + if has_file_path { + actions.extend([ + ActionDefinition::new( + ContextActionId::CopyPath, + localized_label(t!("tree_viewer.copy_path").to_string(), "Copy Path"), + 1, + None, + ), + ActionDefinition::new( + ContextActionId::RevealInExplorer, + localized_label( + t!("tab.reveal_in_explorer").to_string(), + "Reveal in Explorer", + ), + 1, + None, + ), + ]); + } + + actions + } + } + } +} + +impl ActionDefinition { + fn new( + id: ContextActionId, + display_name: impl Into, + group: u8, + shortcut_command: Option, + ) -> Self { + Self { + id, + display_name: display_name.into(), + group, + shortcut_command, + } + } +} + +/// Render a context menu with optional right-aligned shortcut hints. +pub fn render_action_menu_with_shortcuts( + ui: &mut Ui, + actions: &[ActionDefinition], + shortcuts: Option<&KeyboardShortcuts>, +) -> Option { + let mut clicked = None; + let mut last_group = None; + + for action in actions { + if last_group.is_some() && last_group != Some(action.group) { + ui.separator(); + } + last_group = Some(action.group); + + let shortcut_label = shortcuts + .and_then(|shortcuts| action.shortcut_command.map(|cmd| shortcuts.get(cmd))) + .filter(|binding| binding.has_modifiers()) + .map(|binding| binding.display_string()); + + let row = ui + .horizontal(|ui| { + ui.set_min_width(210.0); + ui.label(&action.display_name); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if let Some(shortcut_label) = &shortcut_label { + ui.label( + RichText::new(shortcut_label) + .small() + .monospace() + .color(ui.visuals().weak_text_color()), + ); + } + }); + }) + .response; + + let response = ui.interact( + row.rect, + ui.id().with(("context_action", action.id)), + egui::Sense::click(), + ); + + if response.clicked() { + clicked = Some(action.id); + ui.close(); + break; + } + } + + clicked +} + +fn localized_label(label: String, fallback: &'static str) -> String { + if label.trim().is_empty() { + fallback.to_string() + } else { + label + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ab8df52..bb763ed 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -3,6 +3,7 @@ //! This module contains reusable UI widgets and components. //! mod about; +mod action_registry; mod backlinks_panel; mod command_palette; mod dialogs; @@ -27,6 +28,9 @@ mod welcome; mod window; pub use about::AboutPanel; +pub use action_registry::{ + render_action_menu_with_shortcuts, ActionContext, ActionRegistry, ContextActionId, +}; pub use backlinks_panel::BacklinksPanel; pub use command_palette::CommandPalette; pub use dialogs::{FileOperationDialog, FileOperationResult, GoToLineDialog, GoToLineResult};