Skip to content

Commit 10762da

Browse files
tejjgvcalixtuskopporSiedlerchr
authored
Refine tab to move focus even when the last item is a button (#13938)
* Fix TAB navigation in EntryEditor to move focus to next tab's field * - Fixed TAB navigation in the EntryEditor so that pressing TAB now moves focus to the next tab's field * - Fixed TAB navigation in the EntryEditor so that pressing TAB now moves focus to the next tab's field * -Fixed TAB navigation in the EntryEditor so that pressing TAB now moves focus to the next tab's field * Replace null returns with Optional * Update jabgui/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java Co-authored-by: Carl Christian Snethlage <[email protected]> * Replace null returns with Optional * chore: trigger CI rerun * chore: remove dummy.txt * Use KeyCode.TAB instead of text comparison for tab key detection * Update CHANGELOG.md * Update jabgui/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java * Reformat * Refine tab to move focus even when the last item is a button * Refine tab to move focus even when the last item is a button * Refine tab to move focus even when the last item is a button * added supress warning * removed static injection * removed static injection * reformatting * update CHANGELOG.md * checkstyle * fix import order * Update jabgui/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java Co-authored-by: Oliver Kopp <[email protected]> * Update jabgui/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java Co-authored-by: Oliver Kopp <[email protected]> * Update jabgui/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java Co-authored-by: Oliver Kopp <[email protected]> * remove duplicated code * Fix CHANGELOG.md --------- Co-authored-by: Carl Christian Snethlage <[email protected]> Co-authored-by: Oliver Kopp <[email protected]> Co-authored-by: Carl Christian Snethlage <[email protected]> Co-authored-by: Siedlerchr <[email protected]>
1 parent aa92a8f commit 10762da

File tree

7 files changed

+221
-13
lines changed

7 files changed

+221
-13
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv
3939
- <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>L</kbd> now opens the terminal in the active library directory. [#14130](https://github.com/JabRef/jabref/issues/14130)
4040
- After importing, now all imported entries are marked. [#13535](https://github.com/JabRef/jabref/pull/13535)
4141
- The URL integrity check now checks the complete URL syntax. [#14370](https://github.com/JabRef/jabref/pull/14370)
42+
- <kbd>Tab</kbd> in the last text field of a tab moves the focus to the next tab in the entry editor. [#11937](https://github.com/JabRef/jabref/issues/11937)
4243
- We changed fixed-value ComboBoxes to SearchableComboBox for better usability. [#14083](https://github.com/JabRef/jabref/issues/14083)
4344

4445
### Fixed

jabgui/src/main/java/org/jabref/gui/entryeditor/CommentsTab.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ protected void setupPanel(BibDatabaseContext bibDatabaseContext, BibEntry entry,
136136
if (!entry.hasField(userSpecificCommentField)) {
137137
if (shouldShowHideButton) {
138138
Button hideDefaultOwnerCommentButton = new Button(Localization.lang("Hide user-specific comments field"));
139+
hideDefaultOwnerCommentButton.setId("HIDE_COMMENTS_BUTTON");
139140
hideDefaultOwnerCommentButton.setOnAction(e -> {
140141
gridPane.getChildren().removeIf(node ->
141142
(node instanceof FieldNameLabel fieldNameLabel && fieldNameLabel.getText().equals(userSpecificCommentField.getName()))
@@ -152,6 +153,7 @@ protected void setupPanel(BibDatabaseContext bibDatabaseContext, BibEntry entry,
152153
} else {
153154
// Show "Show" button when user comments field is hidden
154155
Button showDefaultOwnerCommentButton = new Button(Localization.lang("Show user-specific comments field"));
156+
showDefaultOwnerCommentButton.setId("SHOW_COMMENTS_BUTTON");
155157
showDefaultOwnerCommentButton.setOnAction(e -> {
156158
shouldShowHideButton = true;
157159
setupPanel(bibDatabaseContext, entry, false);

jabgui/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java

Lines changed: 210 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import java.io.File;
44
import java.nio.file.Path;
55
import java.util.ArrayList;
6+
import java.util.Collection;
67
import java.util.HashMap;
78
import java.util.LinkedList;
89
import java.util.List;
@@ -18,13 +19,17 @@
1819
import javafx.beans.InvalidationListener;
1920
import javafx.fxml.FXML;
2021
import javafx.geometry.Side;
22+
import javafx.scene.Node;
23+
import javafx.scene.Parent;
2124
import javafx.scene.control.Button;
2225
import javafx.scene.control.ContextMenu;
2326
import javafx.scene.control.Label;
2427
import javafx.scene.control.MenuItem;
2528
import javafx.scene.control.Tab;
2629
import javafx.scene.control.TabPane;
30+
import javafx.scene.control.TextInputControl;
2731
import javafx.scene.input.DataFormat;
32+
import javafx.scene.input.KeyCode;
2833
import javafx.scene.input.KeyEvent;
2934
import javafx.scene.input.TransferMode;
3035
import javafx.scene.layout.BorderPane;
@@ -168,6 +173,9 @@ public EntryEditor(Supplier<LibraryTab> tabSupplier, UndoAction undoAction, Redo
168173
EntryEditorTab activeTab = (EntryEditorTab) tab;
169174
if (activeTab != null) {
170175
activeTab.notifyAboutFocus(currentlyEditedEntry);
176+
if (activeTab instanceof FieldsEditorTab fieldsTab) {
177+
Platform.runLater(() -> setupNavigationForTab(fieldsTab));
178+
}
171179
}
172180
});
173181

@@ -222,6 +230,31 @@ private void setupDragAndDrop() {
222230
});
223231
}
224232

233+
private void setupNavigationForTab(FieldsEditorTab tab) {
234+
Node content = tab.getContent();
235+
if (content instanceof Parent parent) {
236+
findAndSetupTabNavigableNodes(parent);
237+
}
238+
}
239+
240+
private void findAndSetupTabNavigableNodes(Parent parent) {
241+
for (Node child : parent.getChildrenUnmodifiable()) {
242+
// Generic handler for other focusable controls (e.g., Button, ComboBox, CheckBox, etc.)
243+
child.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
244+
if (event.getCode() == KeyCode.TAB && !event.isShiftDown()) {
245+
if (isLastFieldInCurrentTab(child)) {
246+
moveToNextTabAndFocus();
247+
event.consume();
248+
}
249+
}
250+
});
251+
252+
if (child instanceof Parent childParent) {
253+
findAndSetupTabNavigableNodes(childParent);
254+
}
255+
}
256+
}
257+
225258
/**
226259
* Set up key bindings specific for the entry editor.
227260
*/
@@ -466,6 +499,13 @@ public void setCurrentlyEditedEntry(@NonNull BibEntry currentlyEditedEntry) {
466499
if (preferences.getEntryEditorPreferences().showSourceTabByDefault()) {
467500
tabbed.getSelectionModel().select(sourceTab);
468501
}
502+
Platform.runLater(() -> {
503+
for (Tab tab : tabbed.getTabs()) {
504+
if (tab instanceof FieldsEditorTab fieldsTab) {
505+
setupNavigationForTab(fieldsTab);
506+
}
507+
}
508+
});
469509

470510
EntryEditorTab selectedTab = getSelectedTab();
471511
if (selectedTab != null) {
@@ -525,15 +565,13 @@ public void selectField(String fieldName) {
525565
}
526566

527567
public void setFocusToField(Field field) {
528-
UiTaskExecutor.runInJavaFXThread(() -> {
529-
getTabContainingField(field).ifPresentOrElse(
530-
tab -> selectTabAndField(tab, field),
531-
() -> {
532-
Field aliasField = EntryConverter.FIELD_ALIASES.get(field);
533-
getTabContainingField(aliasField).ifPresent(tab -> selectTabAndField(tab, aliasField));
534-
}
535-
);
536-
});
568+
UiTaskExecutor.runInJavaFXThread(() -> getTabContainingField(field).ifPresentOrElse(
569+
tab -> selectTabAndField(tab, field),
570+
() -> {
571+
Field aliasField = EntryConverter.FIELD_ALIASES.get(field);
572+
getTabContainingField(aliasField).ifPresent(tab -> selectTabAndField(tab, aliasField));
573+
}
574+
));
537575
}
538576

539577
private void selectTabAndField(FieldsEditorTab tab, Field field) {
@@ -562,4 +600,167 @@ public void nextPreviewStyle() {
562600
public void previousPreviewStyle() {
563601
this.previewPanel.previousPreviewStyle();
564602
}
603+
604+
/**
605+
* Checks if the given TextField is the last field in the currently selected tab.
606+
*
607+
* @param node the Node to check
608+
* @return true if this is the last field in the current tab, false otherwise
609+
*/
610+
boolean isLastFieldInCurrentTab(Node node) {
611+
if (node == null || tabbed.getSelectionModel().getSelectedItem() == null) {
612+
return false;
613+
}
614+
615+
Tab selectedTab = tabbed.getSelectionModel().getSelectedItem();
616+
if (!(selectedTab instanceof FieldsEditorTab currentTab)) {
617+
return false;
618+
}
619+
620+
Collection<Field> shownFields = currentTab.getShownFields();
621+
// Try field-based check first (preferred for standard field editors)
622+
if (!shownFields.isEmpty() && node.getId() != null) {
623+
Optional<Field> lastField = shownFields.stream()
624+
.reduce((first, second) -> second);
625+
626+
boolean matchesLastFieldId = lastField.map(Field::getName)
627+
.map(displayName -> displayName.equalsIgnoreCase(node.getId()))
628+
.orElse(false);
629+
if (matchesLastFieldId) {
630+
return true;
631+
}
632+
}
633+
634+
// Fallback: determine if the node is the last focusable control within the editor grid of the current tab
635+
if (currentTab.getContent() instanceof Parent parent) {
636+
Parent searchRoot = findEditorGridParent(parent).orElse(parent);
637+
Optional<Node> lastFocusable = findLastFocusableNode(searchRoot);
638+
return lastFocusable.map(n -> n == node).orElse(false);
639+
}
640+
641+
return false;
642+
}
643+
644+
/**
645+
* Moves to the next tab and focuses on its first field.
646+
*/
647+
void moveToNextTabAndFocus() {
648+
tabbed.getSelectionModel().selectNext();
649+
650+
Platform.runLater(() -> {
651+
Tab selectedTab = tabbed.getSelectionModel().getSelectedItem();
652+
if (selectedTab instanceof FieldsEditorTab currentTab) {
653+
focusFirstFieldInTab(currentTab);
654+
}
655+
});
656+
}
657+
658+
private void focusFirstFieldInTab(FieldsEditorTab tab) {
659+
Node tabContent = tab.getContent();
660+
if (tabContent instanceof Parent parent) {
661+
// First try to find field by ID (preferred method)
662+
Collection<Field> shownFields = tab.getShownFields();
663+
if (!shownFields.isEmpty()) {
664+
Field firstField = shownFields.iterator().next();
665+
String firstFieldId = firstField.getName();
666+
Optional<TextInputControl> firstTextInput = findTextInputById(parent, firstFieldId);
667+
if (firstTextInput.isPresent()) {
668+
firstTextInput.get().requestFocus();
669+
return;
670+
}
671+
}
672+
673+
Optional<TextInputControl> anyTextInput = findAnyTextInput(parent);
674+
if (anyTextInput.isPresent()) {
675+
anyTextInput.get().requestFocus();
676+
return;
677+
}
678+
679+
// Final fallback: focus first focusable node within the editor grid (e.g., a button-only tab)
680+
Parent searchRoot = findEditorGridParent(parent).orElse(parent);
681+
findFirstFocusableNode(searchRoot).ifPresent(Node::requestFocus);
682+
}
683+
}
684+
685+
/// Recursively searches for a TextInputControl (TextField or TextArea) with the given ID.
686+
private Optional<TextInputControl> findTextInputById(Parent parent, String id) {
687+
for (Node child : parent.getChildrenUnmodifiable()) {
688+
if (child instanceof TextInputControl textInput && id.equalsIgnoreCase(textInput.getId())) {
689+
return Optional.of(textInput);
690+
} else if (child instanceof Parent childParent) {
691+
Optional<TextInputControl> found = findTextInputById(childParent, id);
692+
if (found.isPresent()) {
693+
return found;
694+
}
695+
}
696+
}
697+
return Optional.empty();
698+
}
699+
700+
private Optional<TextInputControl> findAnyTextInput(Parent parent) {
701+
for (Node child : parent.getChildrenUnmodifiable()) {
702+
if (child instanceof TextInputControl textInput) {
703+
return Optional.of(textInput);
704+
} else if (child instanceof Parent childParent) {
705+
Optional<TextInputControl> found = findAnyTextInput(childParent);
706+
if (found.isPresent()) {
707+
return found;
708+
}
709+
}
710+
}
711+
return Optional.empty();
712+
}
713+
714+
/// Returns the first focusable, visible, managed, and enabled node in depth-first order
715+
private Optional<Node> findFirstFocusableNode(Parent parent) {
716+
for (Node child : parent.getChildrenUnmodifiable()) {
717+
if (isNodeFocusable(child)) {
718+
return Optional.of(child);
719+
} else if (child instanceof Parent childParent) {
720+
Optional<Node> found = findFirstFocusableNode(childParent);
721+
if (found.isPresent()) {
722+
return found;
723+
}
724+
}
725+
}
726+
return Optional.empty();
727+
}
728+
729+
/// Returns the last focusable, visible, managed, and enabled node in depth-first order
730+
private Optional<Node> findLastFocusableNode(Parent parent) {
731+
Optional<Node> last = Optional.empty();
732+
for (Node child : parent.getChildrenUnmodifiable()) {
733+
if (child instanceof Parent childParent) {
734+
Optional<Node> sub = findLastFocusableNode(childParent);
735+
if (sub.isPresent()) {
736+
last = sub;
737+
}
738+
}
739+
if (isNodeFocusable(child)) {
740+
last = Optional.of(child);
741+
}
742+
}
743+
return last;
744+
}
745+
746+
private boolean isNodeFocusable(Node node) {
747+
return node.isFocusTraversable() && node.isVisible() && !node.isDisabled() && node.isManaged();
748+
}
749+
750+
/// Tries to locate the editor grid (with style class "editorPane") inside the tab content to avoid
751+
/// including preview or other sibling panels when determining focus order boundaries.
752+
private Optional<Parent> findEditorGridParent(Parent root) {
753+
if (root.getStyleClass().contains("editorPane")) {
754+
return Optional.of(root);
755+
}
756+
for (Node child : root.getChildrenUnmodifiable()) {
757+
if (child instanceof Parent p) {
758+
Optional<Parent> found = findEditorGridParent(p);
759+
if (found.isPresent()) {
760+
return found;
761+
}
762+
}
763+
}
764+
return Optional.empty();
765+
}
565766
}

jabgui/src/main/java/org/jabref/gui/fieldeditors/EditorTextField.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
public class EditorTextField extends TextField implements Initializable, ContextMenuAddable {
2222

2323
private final ContextMenu contextMenu = new ContextMenu();
24+
2425
private Runnable additionalPasteActionHandler = () -> {
2526
// No additional paste behavior
2627
};

jabgui/src/main/java/org/jabref/gui/fieldeditors/MarkdownEditor.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public MarkdownEditor(Field field, SuggestionProvider<?> suggestionProvider, Fie
2222
}
2323

2424
@Override
25-
protected TextInputControl createTextInputControl() {
25+
protected TextInputControl createTextInputControl(@SuppressWarnings("unused") Field field) {
2626
return new EditorTextArea() {
2727
@Override
2828
public void paste() {

jabgui/src/main/java/org/jabref/gui/fieldeditors/PersonsEditor.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ public PersonsEditor(final Field field,
3838

3939
this.viewModel = new PersonsEditorViewModel(field, suggestionProvider, preferences.getAutoCompletePreferences(), fieldCheckers, undoManager);
4040
textInput = isMultiLine ? new EditorTextArea() : new EditorTextField();
41+
textInput.setId(field.getName());
4142
decoratedStringProperty = new UiThreadStringProperty(viewModel.textProperty());
4243
establishBinding(textInput, decoratedStringProperty, keyBindingRepository, undoAction, redoAction);
4344
((ContextMenuAddable) textInput).initContextMenu(EditorMenus.getNameMenu(textInput), keyBindingRepository);

jabgui/src/main/java/org/jabref/gui/fieldeditors/SimpleEditor.java

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public SimpleEditor(final Field field,
3535
this.viewModel = new SimpleEditorViewModel(field, suggestionProvider, fieldCheckers, undoManager);
3636
this.isMultiLine = isMultiLine;
3737

38-
textInput = createTextInputControl();
38+
textInput = createTextInputControl(field);
3939
HBox.setHgrow(textInput, Priority.ALWAYS);
4040

4141
establishBinding(textInput, viewModel.textProperty(), preferences.getKeyBindingRepository(), undoAction, redoAction);
@@ -54,8 +54,10 @@ public SimpleEditor(final Field field,
5454
new EditorValidator(preferences).configureValidation(viewModel.getFieldValidator().getValidationStatus(), textInput);
5555
}
5656

57-
protected TextInputControl createTextInputControl() {
58-
return isMultiLine ? new EditorTextArea() : new EditorTextField();
57+
protected TextInputControl createTextInputControl(Field field) {
58+
TextInputControl inputControl = isMultiLine ? new EditorTextArea() : new EditorTextField();
59+
inputControl.setId(field.getName());
60+
return inputControl;
5961
}
6062

6163
@Override

0 commit comments

Comments
 (0)