Skip to content

Commit

Permalink
Keyboard focus fixes (#211)
Browse files Browse the repository at this point in the history
* Fix crash after calling `DockState::remove_tab`. (#208)

* Remove surface after removing the last remaining tab, if the surface isn't `Main`.

* Add explicit panic in `Tree::remove_leaf` if the `Tree` is empty.

* Bump patch version, update changelog.

* Include instructions on how to run examples in Readme. (#209)

* Fix keyboard focus not working inside `DockArea`s

* Add visual indicators when tab buttons are focused

* Allow current tab to be switched using the keyboard

* Remove extra focusable areas

I removed focus from two widgets that are not supposed to be focusable.
Also, I added highlighting to the separators when they are focused
because apparently I can't remove the ability to focus them without
breaking the ability to drag the separators.

* Separators can now be moved with the arrow keys

* Add highlighting to active tabs that are keyboard focused

* Update changelog

* Add tab styles for keyboard-focused tabs

* Don't move separators unless Ctrl or Shift is down

This is so that using the arrow keys to change the keyboard focus will
still work normally.

* Clippy

---------

Co-authored-by: Adam Gąsior <adanos020@gmail.com>
  • Loading branch information
white-axe and Adanos020 committed Dec 31, 2023
1 parent 2b77fa0 commit ed990ed
Show file tree
Hide file tree
Showing 7 changed files with 161 additions and 24 deletions.
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# 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
- Fix crash after calling `DockState::remove_tab`. ([#208](https://github.com/Adanos020/egui_dock/pull/208))

## 0.9.0 - 2023-11-23

### Added
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,13 @@ Then proceed by setting up `egui`, following its [quick start guide](https://git
Once that's done, you can start using `egui_dock` – more details on that can be found in the
[documentation](https://docs.rs/egui_dock/latest/egui_dock/).

## Examples

The Git repository of this crate contains some example applications demonstrating how to achieve certain effects.
You can find all of them in the [`examples`](examples) folder.

You can run them with Cargo from the crate's root directory, for example: `cargo run --example hello`.

## Demo

![demo](images/demo.gif "Demo")
Expand Down
6 changes: 5 additions & 1 deletion src/dock_state/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,11 @@ impl<Tab> DockState<Tab> {
&mut self,
(surface_index, node_index, tab_index): (SurfaceIndex, NodeIndex, TabIndex),
) -> Option<Tab> {
self[surface_index].remove_tab((node_index, tab_index))
let removed_tab = self[surface_index].remove_tab((node_index, tab_index));
if !surface_index.is_main() && self[surface_index].is_empty() {
self.remove_surface(surface_index);
}
removed_tab
}

/// Creates two new nodes by splitting a given `parent` node and assigns them as its children. The first (old) node
Expand Down
4 changes: 3 additions & 1 deletion src/dock_state/tree/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -580,8 +580,10 @@ impl<Tab> Tree<Tab> {
///
/// # Panics
///
/// If the node at index `node` is not a [`Leaf`](Node::Leaf).
/// - If the tree is empty.
/// - If the node at index `node` is not a [`Leaf`](Node::Leaf).
pub fn remove_leaf(&mut self, node: NodeIndex) {
assert!(!self.is_empty());
assert!(self[node].is_leaf());

let Some(parent) = node.parent() else {
Expand Down
66 changes: 66 additions & 0 deletions src/style.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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 {
Expand Down
50 changes: 34 additions & 16 deletions src/widgets/dock_area/show/leaf.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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| {
Expand All @@ -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);
Expand All @@ -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,
Expand All @@ -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()))
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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).
Expand All @@ -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));
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
};
Expand Down Expand Up @@ -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;
Expand Down
36 changes: 30 additions & 6 deletions src/widgets/dock_area/show/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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::[<Resize orientation>]});

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,
Expand All @@ -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
Expand All @@ -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)
Expand Down

0 comments on commit ed990ed

Please sign in to comment.