diff --git a/CHANGELOG.md b/CHANGELOG.md index cfeaa81..0b312d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # egui_dock changelog +## 0.10.0 - TBD + +### Added +From ([#211](https://github.com/Adanos020/egui_dock/pull/211)): + - Tabs, the close tab buttons and the add tab buttons are now focusable with the keyboard and interactable with the enter key and space bar. + - Separators are now focusable with the keyboard and movable using the arrow keys while control or shift is held. + - `TabStyle::active_with_kb_focus`, `TabStyle::inactive_with_kb_focus` and `TabStyle::focused_with_kb_focus` for style of tabs that are focused with the keyboard. + +### Fixed +- Widgets inside tabs are now focusable with the tab key on the keyboard. ([#211](https://github.com/Adanos020/egui_dock/pull/211)) + ## 0.9.1 - 2023-12-10 ### Fixed diff --git a/src/style.rs b/src/style.rs index 2fe7c68..93042ad 100644 --- a/src/style.rs +++ b/src/style.rs @@ -151,6 +151,15 @@ pub struct TabStyle { /// Style of the tab when it is hovered. pub hovered: TabInteractionStyle, + /// Style of the tab when it is inactive and has keyboard focus. + pub inactive_with_kb_focus: TabInteractionStyle, + + /// Style of the tab when it is active and has keyboard focus. + pub active_with_kb_focus: TabInteractionStyle, + + /// Style of the tab when it is focused and has keyboard focus. + pub focused_with_kb_focus: TabInteractionStyle, + /// Style for the tab body. pub tab_body: TabBodyStyle, @@ -357,6 +366,15 @@ impl Default for TabStyle { text_color: Color32::BLACK, ..Default::default() }, + active_with_kb_focus: TabInteractionStyle::default(), + inactive_with_kb_focus: TabInteractionStyle { + text_color: Color32::DARK_GRAY, + ..Default::default() + }, + focused_with_kb_focus: TabInteractionStyle { + text_color: Color32::BLACK, + ..Default::default() + }, tab_body: TabBodyStyle::default(), hline_below_active_tab_name: false, minimum_width: None, @@ -532,6 +550,9 @@ impl TabStyle { inactive: TabInteractionStyle::from_egui_inactive(style), focused: TabInteractionStyle::from_egui_focused(style), hovered: TabInteractionStyle::from_egui_hovered(style), + active_with_kb_focus: TabInteractionStyle::from_egui_active_with_kb_focus(style), + inactive_with_kb_focus: TabInteractionStyle::from_egui_inactive_with_kb_focus(style), + focused_with_kb_focus: TabInteractionStyle::from_egui_focused_with_kb_focus(style), tab_body: TabBodyStyle::from_egui(style), ..Default::default() } @@ -609,6 +630,51 @@ impl TabInteractionStyle { ..TabInteractionStyle::from_egui_inactive(style) } } + + /// Derives relevant fields from `egui::Style` for an active tab with keyboard focus and sets the remaining fields to their default values. + /// + /// Fields overwritten by [`egui::Style`] are: + /// - [`TabInteractionStyle::outline_color`] + /// - [`TabInteractionStyle::bg_fill`] + /// - [`TabInteractionStyle::text_color`] + /// - [`TabInteractionStyle::rounding`] + pub fn from_egui_active_with_kb_focus(style: &egui::Style) -> Self { + Self { + text_color: style.visuals.strong_text_color(), + outline_color: style.visuals.widgets.hovered.bg_stroke.color, + ..TabInteractionStyle::from_egui_active(style) + } + } + + /// Derives relevant fields from `egui::Style` for an inactive tab with keyboard focus and sets the remaining fields to their default values. + /// + /// Fields overwritten by [`egui::Style`] are: + /// - [`TabInteractionStyle::outline_color`] + /// - [`TabInteractionStyle::bg_fill`] + /// - [`TabInteractionStyle::text_color`] + /// - [`TabInteractionStyle::rounding`] + pub fn from_egui_inactive_with_kb_focus(style: &egui::Style) -> Self { + Self { + text_color: style.visuals.strong_text_color(), + outline_color: style.visuals.widgets.hovered.bg_stroke.color, + ..TabInteractionStyle::from_egui_inactive(style) + } + } + + /// Derives relevant fields from `egui::Style` for a focused tab with keyboard focus and sets the remaining fields to their default values. + /// + /// Fields overwritten by [`egui::Style`] are: + /// - [`TabInteractionStyle::outline_color`] + /// - [`TabInteractionStyle::bg_fill`] + /// - [`TabInteractionStyle::text_color`] + /// - [`TabInteractionStyle::rounding`] + pub fn from_egui_focused_with_kb_focus(style: &egui::Style) -> Self { + Self { + text_color: style.visuals.strong_text_color(), + outline_color: style.visuals.widgets.hovered.bg_stroke.color, + ..TabInteractionStyle::from_egui_focused(style) + } + } } impl TabBodyStyle { diff --git a/src/widgets/dock_area/show/leaf.rs b/src/widgets/dock_area/show/leaf.rs index 9c43b77..1aedd37 100644 --- a/src/widgets/dock_area/show/leaf.rs +++ b/src/widgets/dock_area/show/leaf.rs @@ -1,9 +1,9 @@ use std::ops::RangeInclusive; use egui::{ - epaint::TextShape, lerp, pos2, vec2, Align, Align2, Button, CursorIcon, Frame, Id, LayerId, - Layout, NumExt, Order, PointerButton, Rect, Response, Rounding, ScrollArea, Sense, Stroke, - TextStyle, Ui, Vec2, WidgetText, + epaint::TextShape, lerp, pos2, vec2, Align, Align2, Button, CursorIcon, Frame, Id, Key, + LayerId, Layout, NumExt, Order, PointerButton, Rect, Response, Rounding, ScrollArea, Sense, + Stroke, TextStyle, Ui, Vec2, WidgetText, }; use crate::{ @@ -81,7 +81,7 @@ impl<'tree, Tab> DockArea<'tree, Tab> { let style = fade_style.unwrap_or_else(|| self.style.as_ref().unwrap()); let (tabbar_outer_rect, tabbar_response) = ui.allocate_exact_size( vec2(ui.available_width(), style.tab_bar.height), - Sense::click(), + Sense::hover(), ); ui.painter().rect_filled( tabbar_outer_rect, @@ -235,7 +235,7 @@ impl<'tree, Tab> DockArea<'tree, Tab> { let show_close_button = self.show_close_buttons && closeable; - let response = if is_being_dragged { + let (response, title_id) = if is_being_dragged { let layer_id = LayerId::new(Order::Tooltip, id); let response = tabs_ui .with_layer_id(layer_id, |ui| { @@ -253,6 +253,7 @@ impl<'tree, Tab> DockArea<'tree, Tab> { ) }) .response; + let title_id = response.id; let sense = Sense::click_and_drag(); let response = tabs_ui.interact(response.rect, id, sense); @@ -272,7 +273,7 @@ impl<'tree, Tab> DockArea<'tree, Tab> { } } - response + (response, title_id) } else { let (mut response, close_response) = self.tab_title( tabs_ui, @@ -286,6 +287,7 @@ impl<'tree, Tab> DockArea<'tree, Tab> { show_close_button, fade, ); + let title_id = response.id; let (close_hovered, close_clicked) = close_response .map(|res| (res.hovered(), res.clicked())) @@ -322,7 +324,8 @@ impl<'tree, Tab> DockArea<'tree, Tab> { }; let tab = &mut tabs[tab_index.0]; - response = response.context_menu(|ui| { + let response = tabs_ui.interact(response.rect, id, Sense::click()); + response.context_menu(|ui| { tab_viewer.context_menu(ui, tab, surface_index, node_index); if (surface_index.is_main() || !is_lonely_tab) && tab_viewer.allowed_in_windows(tab) @@ -374,7 +377,7 @@ impl<'tree, Tab> DockArea<'tree, Tab> { } } - response + (response, title_id) }; // Paint hline below each tab unless its active (or option says otherwise). @@ -399,7 +402,10 @@ impl<'tree, Tab> DockArea<'tree, Tab> { ); } - if response.clicked() { + if response.clicked() + || (tabs_ui.memory(|m| m.has_focus(title_id)) + && tabs_ui.input(|i| i.key_pressed(Key::Enter) || i.key_pressed(Key::Space))) + { *active = tab_index; self.new_focused = Some((surface_index, node_index)); } @@ -445,7 +451,7 @@ impl<'tree, Tab> DockArea<'tree, Tab> { response = response.on_hover_cursor(CursorIcon::PointingHand); let style = fade_style.unwrap_or_else(|| self.style.as_ref().unwrap()); - let color = if response.hovered() { + let color = if response.hovered() || response.has_focus() { ui.painter() .rect_filled(rect, Rounding::ZERO, style.buttons.add_tab_bg_fill); style.buttons.add_tab_active_color @@ -524,18 +530,30 @@ impl<'tree, Tab> DockArea<'tree, Tab> { .at_least(text_width + close_button_size); let tab_width = prefered_width.unwrap_or(0.0).at_least(minimum_width); - let (rect, mut response) = - ui.allocate_exact_size(vec2(tab_width, ui.available_height()), Sense::hover()); + let (rect, mut response) = ui.allocate_exact_size( + vec2(tab_width, ui.available_height()), + Sense::focusable_noninteractive(), + ); if !ui.memory(|mem| mem.is_anything_being_dragged()) && self.draggable_tabs { response = response.on_hover_cursor(CursorIcon::PointingHand); } let tab_style = if focused || is_being_dragged { - &tab_style.focused + if response.has_focus() { + &tab_style.focused_with_kb_focus + } else { + &tab_style.focused + } } else if active { - &tab_style.active + if response.has_focus() { + &tab_style.active_with_kb_focus + } else { + &tab_style.active + } } else if response.hovered() { &tab_style.hovered + } else if response.has_focus() { + &tab_style.inactive_with_kb_focus } else { &tab_style.inactive }; @@ -590,13 +608,13 @@ impl<'tree, Tab> DockArea<'tree, Tab> { .interact(close_button_rect, id, Sense::click()) .on_hover_cursor(CursorIcon::PointingHand); - let color = if response.hovered() { + let color = if response.hovered() || response.has_focus() { style.buttons.close_tab_active_color } else { style.buttons.close_tab_color }; - if response.hovered() { + if response.hovered() || response.has_focus() { let mut rounding = tab_style.rounding; rounding.nw = 0.0; rounding.sw = 0.0; diff --git a/src/widgets/dock_area/show/mod.rs b/src/widgets/dock_area/show/mod.rs index 94c661c..298d9c6 100644 --- a/src/widgets/dock_area/show/mod.rs +++ b/src/widgets/dock_area/show/mod.rs @@ -1,6 +1,6 @@ use egui::{ - CentralPanel, Color32, Context, CursorIcon, Frame, LayerId, Order, Pos2, Rect, Rounding, Sense, - Ui, Vec2, + CentralPanel, Color32, Context, CursorIcon, EventFilter, Frame, Key, LayerId, Order, Pos2, + Rect, Rounding, Sense, Ui, Vec2, }; use duplicate::duplicate; @@ -311,7 +311,7 @@ impl<'tree, Tab> DockArea<'tree, Tab> { if surface == SurfaceIndex::main() { rect = rect.expand(-style.main_surface_border_stroke.width / 2.0); } - ui.allocate_rect(rect, Sense::click()); + ui.allocate_rect(rect, Sense::hover()); if self.dock_state[surface].is_empty() { return rect; @@ -395,6 +395,30 @@ impl<'tree, Tab> DockArea<'tree, Tab> { let response = ui.allocate_rect(interact_rect, Sense::click_and_drag()) .on_hover_and_drag_cursor(paste!{ CursorIcon::[]}); + let should_respond_to_arrow_keys = ui.input(|i| i.modifiers.command || i.modifiers.shift); + + if response.has_focus() { + // Prevent the default behaviour of removing focus from the separators when the + // arrow keys are pressed + ui.memory_mut(|m| m.set_focus_lock_filter(response.id, EventFilter { arrows: should_respond_to_arrow_keys, tab: false, escape: false })); + } + + let arrow_key_offset = if response.has_focus() && should_respond_to_arrow_keys { + if ui.input(|i| i.key_pressed(Key::ArrowUp)) { + Some(egui::vec2(0., -16.)) + } else if ui.input(|i| i.key_pressed(Key::ArrowDown)) { + Some(egui::vec2(0., 16.)) + } else if ui.input(|i| i.key_pressed(Key::ArrowLeft)) { + Some(egui::vec2(-16., 0.)) + } else if ui.input(|i| i.key_pressed(Key::ArrowRight)) { + Some(egui::vec2(16., 0.)) + } else { + None + } + } else { + None + }; + let midpoint = rect.min.dim_point + rect.dim_size() * *fraction; separator.min.dim_point = map_to_pixel( midpoint - style.separator.width * 0.5, @@ -409,7 +433,7 @@ impl<'tree, Tab> DockArea<'tree, Tab> { let color = if response.dragged() { style.separator.color_dragged - } else if response.hovered() { + } else if response.hovered() || response.has_focus() { style.separator.color_hovered } else { style.separator.color_idle @@ -420,9 +444,9 @@ impl<'tree, Tab> DockArea<'tree, Tab> { // Update 'fraction' interaction after drawing separator, // otherwise it may overlap on other separator / bodies when // shrunk fast. - if let Some(pos) = response.interact_pointer_pos() { + if let Some(pos) = response.interact_pointer_pos().or(arrow_key_offset.map(|v| separator.center() + v)) { let dim_point = pos.dim_point; - let delta = response.drag_delta().dim_point; + let delta = arrow_key_offset.unwrap_or(response.drag_delta()).dim_point; if (delta > 0. && dim_point > midpoint && dim_point < rect.max.dim_point) || (delta < 0. && dim_point < midpoint && dim_point > rect.min.dim_point)