Skip to content

Commit b9956ce

Browse files
committed
improve how JABS handles multiple windows
1 parent 388f315 commit b9956ce

File tree

6 files changed

+224
-34
lines changed

6 files changed

+224
-34
lines changed

src/jabs/resources/docs/user_guide/gui.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,11 @@ Clicking the Brightness or Contrast controls will reset the brightness or contra
190190
- **Features→Enable Lixit Features:** toggle using lixit features (v5+ projects with lixit static object)
191191
- **Features→Enable Food_hopper Features:** toggle using food hopper features (v5+ projects with food hopper static object)
192192
- **Features→Enable Segmentation Features:** toggle using segmentation features (v6+ projects)
193+
- **Window:** Menu for managing JABS windows.
194+
- **Window→Minimize:** Minimize the main window (⌘M on macOS, Ctrl+M on other platforms)
195+
- **Window→Zoom:** Toggle between normal and maximized window state
196+
- **Window→Bring All to Front:** Bring all JABS windows to the front
197+
- The Window menu also displays a list of all open JABS windows (main window, user guide, training reports, etc.) with a checkmark (✓) next to the currently active window. Click any window in the list to activate and bring it to the front.
193198

194199
## Overlays
195200

src/jabs/resources/docs/user_guide/keyboard-shortcuts.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22

33
## Application
44

5-
| Action | Shortcut |
6-
| --------------------------- | -------------- |
7-
| Quit JABS | Ctrl+Q / Cmd+Q |
8-
| Export training data | Ctrl+T / Cmd+T |
9-
| Open behavior search dialog | Ctrl+F / Cmd+F |
5+
| Action | Shortcut |
6+
| --------------------------- | ------------- |
7+
| Quit JABS | Ctrl+Q / ⌘Q |
8+
| Export training data | Ctrl+T / ⌘T |
9+
| Open behavior search dialog | Ctrl+F / ⌘F |
1010

1111
## Video Navigation
1212

@@ -26,7 +26,7 @@
2626
| Clear labels from selection | x |
2727
| Label selection as not behavior | c |
2828
| Exit select mode without making a change | Esc |
29-
| Select all frames | Ctrl+A / Cmd+A |
29+
| Select all frames | Ctrl+A / ⌘A |
3030

3131
## Identity & Overlays
3232

@@ -36,6 +36,6 @@
3636
| Toggle track overlay | t |
3737
| Toggle pose overlay | p |
3838
| Toggle landmark overlay | l |
39-
| Toggle Minimalist Identity Labels | Ctrl+I / Cmd+I |
39+
| Toggle Minimalist Identity Labels | Ctrl+I / ⌘I |
4040

4141
> **Note:** Next/previous subject order follows the "Identity Selection" dropdown ordering.

src/jabs/ui/central_widget.py

Lines changed: 91 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import numpy as np
77
from PySide6 import QtCore, QtWidgets
88
from PySide6.QtCore import Qt
9-
from PySide6.QtWidgets import QDialog
9+
from PySide6.QtWidgets import QApplication, QDialog
1010
from shapely.geometry import Point
1111

1212
import jabs.feature_extraction
@@ -271,6 +271,21 @@ def id_overlay_mode(self, mode: PlayerWidget.IdentityOverlayMode) -> None:
271271
"""
272272
self._player_widget.id_overlay_mode = mode
273273

274+
def get_open_dialogs(self) -> list[tuple[str, QtWidgets.QWidget]]:
275+
"""Get a list of open dialogs managed by this widget.
276+
277+
Returns:
278+
List of (title, widget) tuples for all visible dialogs.
279+
"""
280+
dialogs = []
281+
282+
if self._training_report_dialog is not None and self._training_report_dialog.isVisible():
283+
dialogs.append(
284+
(self._training_report_dialog.windowTitle(), self._training_report_dialog)
285+
)
286+
287+
return dialogs
288+
274289
def set_project(self, project: Project) -> None:
275290
"""set the currently opened project"""
276291
self._project = project
@@ -736,31 +751,86 @@ def _training_thread_complete(self, elapsed_ms) -> None:
736751

737752
# Display training report if available
738753
if self._training_report_markdown:
739-
# Close any existing training report dialog before showing new one
740-
if self._training_report_dialog is not None:
754+
# Check if any JABS window has focus (main window or any child)
755+
jabs_app_has_focus = QApplication.activeWindow() is not None
756+
757+
# If any JABS window has focus and a dialog exists, close it and create new one
758+
# This is the only reliable way to switch focus on macOS
759+
if jabs_app_has_focus and self._training_report_dialog is not None:
760+
# Save the old position and size before closing
761+
old_geometry = self._training_report_dialog.geometry()
741762
self._training_report_dialog.close()
742763
self._training_report_dialog = None
743764

744-
self._training_report_dialog = TrainingReportDialog(
745-
self._training_report_markdown,
746-
title=f"Training Report: {self._controls.current_behavior}",
747-
parent=self,
748-
)
749-
# Connect to cleanup when dialog is closed
750-
self._training_report_dialog.finished.connect(
751-
lambda: setattr(self, "_training_report_dialog", None)
752-
)
765+
# Create new dialog
766+
self._training_report_dialog = TrainingReportDialog(
767+
self._training_report_markdown,
768+
title=f"Training Report: {self._controls.current_behavior}",
769+
parent=self,
770+
)
753771

754-
# Temporarily set stay-on-top to ensure it appears in front
755-
self._training_report_dialog.setWindowFlags(
756-
self._training_report_dialog.windowFlags() | Qt.WindowType.WindowStaysOnTopHint
757-
)
758-
self._training_report_dialog.show()
759-
self._training_report_dialog.raise_()
760-
self._training_report_dialog.activateWindow()
772+
# Connect to cleanup when dialog is closed
773+
self._training_report_dialog.finished.connect(
774+
lambda: setattr(self, "_training_report_dialog", None)
775+
)
776+
777+
# Show with aggressive focus-stealing
778+
self._training_report_dialog.setWindowFlags(
779+
self._training_report_dialog.windowFlags() | Qt.WindowType.WindowStaysOnTopHint
780+
)
781+
self._training_report_dialog.show()
782+
# Restore geometry AFTER show() to preserve position
783+
self._training_report_dialog.setGeometry(old_geometry)
784+
# Force processing of events to ensure window system updates
785+
QtCore.QCoreApplication.processEvents()
786+
self._training_report_dialog.raise_()
787+
self._training_report_dialog.activateWindow()
788+
# Try to force repaint/update
789+
self._training_report_dialog.repaint()
790+
QtCore.QCoreApplication.processEvents()
791+
792+
# Remove stay-on-top flag after a brief delay
793+
QtCore.QTimer.singleShot(100, lambda: self._remove_stay_on_top_flag())
794+
795+
elif self._training_report_dialog is not None:
796+
# JABS doesn't have focus - just update existing dialog quietly
797+
self._training_report_dialog.update_content(
798+
self._training_report_markdown,
799+
title=f"Training Report: {self._controls.current_behavior}",
800+
)
801+
# Ensure dialog is visible (in case it was minimized or hidden)
802+
if self._training_report_dialog.isMinimized():
803+
self._training_report_dialog.showNormal()
804+
elif not self._training_report_dialog.isVisible():
805+
self._training_report_dialog.show()
806+
else:
807+
# No existing dialog - create new one
808+
self._training_report_dialog = TrainingReportDialog(
809+
self._training_report_markdown,
810+
title=f"Training Report: {self._controls.current_behavior}",
811+
parent=self,
812+
)
813+
# Connect to cleanup when dialog is closed
814+
self._training_report_dialog.finished.connect(
815+
lambda: setattr(self, "_training_report_dialog", None)
816+
)
761817

762-
# Remove stay-on-top flag after a brief delay so user can manage windows normally
763-
QtCore.QTimer.singleShot(100, lambda: self._remove_stay_on_top_flag())
818+
# Show the dialog
819+
self._training_report_dialog.show()
820+
821+
# Only bring to front and activate if JABS has focus
822+
if jabs_app_has_focus:
823+
# Temporarily set stay-on-top to ensure it appears in front
824+
self._training_report_dialog.setWindowFlags(
825+
self._training_report_dialog.windowFlags()
826+
| Qt.WindowType.WindowStaysOnTopHint
827+
)
828+
self._training_report_dialog.show() # Need to call show() again after changing flags
829+
self._training_report_dialog.raise_()
830+
self._training_report_dialog.activateWindow()
831+
832+
# Remove stay-on-top flag after a brief delay so user can manage windows normally
833+
QtCore.QTimer.singleShot(100, lambda: self._remove_stay_on_top_flag())
764834

765835
self._training_report_markdown = None # Clear after displaying
766836

src/jabs/ui/main_window.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ def __init__(self, app_name: str, app_name_long: str, *args, **kwargs) -> None:
112112
file_menu = menu.addMenu("File")
113113
view_menu = menu.addMenu("View")
114114
feature_menu = menu.addMenu("Features")
115+
self._window_menu = menu.addMenu("Window")
115116

116117
# Setup App Menu
117118
# about app
@@ -401,6 +402,29 @@ def __init__(self, app_name: str, app_name_long: str, *args, **kwargs) -> None:
401402
)
402403
feature_menu.addAction(self.enable_segmentation_features)
403404

405+
# Setup Window Menu
406+
# Minimize action
407+
minimize_action = QtGui.QAction("Minimize", self)
408+
minimize_action.setShortcut(QtGui.QKeySequence("Ctrl+M"))
409+
minimize_action.triggered.connect(self.showMinimized)
410+
self._window_menu.addAction(minimize_action)
411+
412+
# Zoom action
413+
zoom_action = QtGui.QAction("Zoom", self)
414+
zoom_action.triggered.connect(self._toggle_zoom)
415+
self._window_menu.addAction(zoom_action)
416+
417+
# Bring All to Front action
418+
bring_all_to_front_action = QtGui.QAction("Bring All to Front", self)
419+
bring_all_to_front_action.triggered.connect(self._bring_all_windows_to_front)
420+
self._window_menu.addAction(bring_all_to_front_action)
421+
422+
self._window_menu.addSeparator()
423+
424+
# Dynamic window list will be added here
425+
# Connect aboutToShow to update the window list when menu is opened
426+
self._window_menu.aboutToShow.connect(self._update_window_menu)
427+
404428
# select all action
405429
select_all_action = QtGui.QAction(self)
406430
select_all_action.setShortcut(QtGui.QKeySequence.StandardKey.SelectAll)
@@ -1082,3 +1106,67 @@ def _view_license(self) -> None:
10821106
"""View the license agreement (JABS->View License Agreement menu action)"""
10831107
dialog = LicenseAgreementDialog(self, view_only=True)
10841108
dialog.exec_()
1109+
1110+
def _toggle_zoom(self) -> None:
1111+
"""Toggle between normal and maximized window state."""
1112+
if self.isMaximized():
1113+
self.showNormal()
1114+
else:
1115+
self.showMaximized()
1116+
1117+
def _bring_all_windows_to_front(self) -> None:
1118+
"""Bring all JABS windows to the front."""
1119+
for widget in QtWidgets.QApplication.topLevelWidgets():
1120+
if widget.isVisible() and not widget.isMinimized():
1121+
widget.raise_()
1122+
widget.activateWindow()
1123+
1124+
def _update_window_menu(self) -> None:
1125+
"""Update the Window menu with the current list of open windows."""
1126+
# Remove all dynamic window items (everything after the separator)
1127+
actions = self._window_menu.actions()
1128+
separator_found = False
1129+
items_to_remove = []
1130+
1131+
for action in actions:
1132+
if separator_found:
1133+
items_to_remove.append(action)
1134+
elif action.isSeparator():
1135+
separator_found = True
1136+
1137+
for action in items_to_remove:
1138+
self._window_menu.removeAction(action)
1139+
1140+
# Add Main Window
1141+
main_window_action = QtGui.QAction("Main Window", self)
1142+
main_window_action.setCheckable(True)
1143+
main_window_action.setChecked(self.isActiveWindow())
1144+
main_window_action.triggered.connect(lambda: self._activate_window(self))
1145+
self._window_menu.addAction(main_window_action)
1146+
1147+
# Add User Guide window if open
1148+
if self._user_guide_window is not None and self._user_guide_window.isVisible():
1149+
guide_action = QtGui.QAction(self._user_guide_window.windowTitle(), self)
1150+
guide_action.setCheckable(True)
1151+
guide_action.setChecked(self._user_guide_window.isActiveWindow())
1152+
guide_action.triggered.connect(lambda: self._activate_window(self._user_guide_window))
1153+
self._window_menu.addAction(guide_action)
1154+
1155+
# Add any open dialogs from the central widget
1156+
for title, dialog in self._central_widget.get_open_dialogs():
1157+
dialog_action = QtGui.QAction(title, self)
1158+
dialog_action.setCheckable(True)
1159+
dialog_action.setChecked(dialog.isActiveWindow())
1160+
dialog_action.triggered.connect(lambda checked, w=dialog: self._activate_window(w))
1161+
self._window_menu.addAction(dialog_action)
1162+
1163+
def _activate_window(self, window: QtWidgets.QWidget) -> None:
1164+
"""Activate and bring a window to the front.
1165+
1166+
Args:
1167+
window: The window to activate
1168+
"""
1169+
if window.isMinimized():
1170+
window.showNormal()
1171+
window.raise_()
1172+
window.activateWindow()

src/jabs/ui/training_report.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from textwrap import dedent
44

55
import markdown2
6+
from PySide6 import QtCore
67
from PySide6.QtGui import QIcon
78
from PySide6.QtWebEngineWidgets import QWebEngineView
89
from PySide6.QtWidgets import (
@@ -39,10 +40,14 @@ def __init__(self, markdown_content: str, title: str = "Training Report", parent
3940
main_layout.setContentsMargins(0, 0, 0, 0)
4041
main_layout.setSpacing(0)
4142

42-
# Convert markdown to HTML and create web view
43-
html_content = self._markdown_to_html(markdown_content)
43+
# Create web view for content
4444
self.web_view = QWebEngineView()
45-
self.web_view.setHtml(html_content)
45+
46+
# Defer HTML content setting to avoid "Compositor returned null texture" errors
47+
# (even though these are harmless because Qt will retry rendering)
48+
# The compositor needs the window to be shown before it can create rendering surfaces
49+
html_content = self._markdown_to_html(markdown_content)
50+
QtCore.QTimer.singleShot(0, lambda: self.web_view.setHtml(html_content))
4651

4752
main_layout.addWidget(self.web_view)
4853

@@ -70,6 +75,28 @@ def _copy_markdown_to_clipboard(self):
7075
clipboard = QApplication.clipboard()
7176
clipboard.setText(self._markdown_content)
7277

78+
def update_content(self, markdown_content: str, title: str | None = None):
79+
"""Update the dialog with new markdown content.
80+
81+
This allows reusing an existing dialog instead of creating a new one,
82+
which preserves the user's window position and size.
83+
84+
Args:
85+
markdown_content: New markdown-formatted training report content
86+
title: Optional new window title
87+
"""
88+
# Update stored markdown content
89+
self._markdown_content = markdown_content
90+
91+
# Update title if provided
92+
if title:
93+
self.setWindowTitle(title)
94+
95+
# Convert new markdown to HTML and update web view
96+
# Defer to avoid compositor errors
97+
html_content = self._markdown_to_html(markdown_content)
98+
QtCore.QTimer.singleShot(0, lambda: self.web_view.setHtml(html_content))
99+
73100
def _markdown_to_html(self, markdown_text: str) -> str:
74101
"""Convert markdown text to HTML.
75102

src/jabs/ui/user_guide_dialog.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,6 @@ def __init__(self, app_name: str, *args: Any, **kwargs: Any) -> None:
107107
super().__init__(*args, **kwargs)
108108
self.setWindowTitle(f"{app_name} User Guide")
109109
self.setWindowModality(QtCore.Qt.WindowModality.NonModal)
110-
self.setWindowFlag(QtCore.Qt.WindowType.Tool)
111110
self.resize(1200, 700)
112111

113112
# Navigation history tracking
@@ -155,9 +154,10 @@ def __init__(self, app_name: str, *args: Any, **kwargs: Any) -> None:
155154

156155
self.setLayout(layout)
157156

158-
# Build tree and load initial content
157+
# Build tree and defer initial content loading to avoid "Compositor returned null texture" errors
158+
# The compositor needs the window to be shown before it can create rendering surfaces
159159
self._build_tree()
160-
self._load_content_from_path("overview.md")
160+
QtCore.QTimer.singleShot(0, lambda: self._load_content_from_path("overview.md"))
161161

162162
def _build_tree(self) -> None:
163163
"""Build the hierarchical tree structure for documentation navigation.

0 commit comments

Comments
 (0)