Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add changelog/announcements to the GUI #1563

Open
15 tasks
roomrys opened this issue Oct 19, 2023 Discussed in #1492 · 0 comments
Open
15 tasks

Add changelog/announcements to the GUI #1563

roomrys opened this issue Oct 19, 2023 Discussed in #1492 · 0 comments
Assignees
Labels
2023-hackathon PRs created for the 2023 intra-lab SLEAP hackathon (very detailed) enhancement New feature or request

Comments

@roomrys
Copy link
Collaborator

roomrys commented Oct 19, 2023

Discussed in #1492

Originally posted by roomrys January 5, 2023

Problem background

  • Many users download SLEAP once and never look back.
  • Very rarely will users keep tabs on the new releases of SLEAP and rush to get the latest version.

Feature proposal

  • We should keep track of the latest SLEAP changelog (and other announcements) on the sleap.ai page.
  • Then, we can reference the url to this webpage from within the SLEAP codebase to update messages to the user.
  • New messages can then be displayed without needing to update the version of SLEAP.

Implementation details

For this feature, we will just be retrieving the changelog and displaying this message in SLEAP. Checkout this example repository which shows Proof of Concept.

PR 1 (Skip for Hackathon MVP): Add a changelog/announcements page to sleap.ai

SLEAP uses sphinx to generate documentation (the sleap.ai page) for the codebase. We are currently in the process of converting all our documentation from reStructuredTest to Markdown. SLEAP uses MyST, "a rich and extensible flavour of Markdown", which has some cool features that regular markdown doesn't have.

1. Create a bulletin.md file in the docs folder

This file will be used to display any news (and new release info) the developers wish to convey to the user.

  • Use extended MyST syntax where needed/useful
  • Copy over the SLEAP changelog for the latest version from the Github releases page.
  • The first line should be the heading: # Bulletin (this is used as the link name in the next step)

2. Add a new link to the main sleap.ai page called "Bulletin"

  • List the relative path to bulletin.md inside index.rst.

PR 2: Check for and grab new announcements in SLEAP

1. Add a variable to be stored past the program runtime which remembers when the user has last seen an announcement

Ever wonder how SLEAP remembers user preferences, such as which color scheme to use, even after the user closes (and reopens) the program? SLEAP stores these preferences in a hidden folder called .sleap at the User's root directory. Whenever a new version of SLEAP is installed on a user's computer, a new folder is created within the .sleap directory which stores the user's preferences for that version of SLEAP. If the files preferences file does not exist yet (a fresh install of the latest SLEAP version), then SLEAP will create the preferences based on the defaults set in the singleton class Preferences:

sleap/sleap/prefs.py

Lines 10 to 74 in c4861e3

class Preferences(object):
"""Class for accessing SLEAP preferences."""
_prefs = None
_defaults = {
"medium step size": 10,
"large step size": 100,
"color predicted": False,
"propagate track labels": True,
"palette": "standard",
"bold lines": False,
"trail length": 0,
"trail width": 4.0,
"trail node count": 1,
"marker size": 4,
"edge style": "Line",
"window state": b"",
"node label size": 12,
"show non-visible nodes": True,
"share usage data": True,
}
_filename = "preferences.yaml"
def __init__(self):
self.load()
def load(self):
"""Load preferences from file, if not already loaded."""
if self._prefs is None:
self.load_()
def load_(self):
"""Load preferences from file (regardless of whether loaded already)."""
try:
self._prefs = util.get_config_yaml(self._filename)
if not hasattr(self._prefs, "get"):
self._prefs = self._defaults
except FileNotFoundError:
self._prefs = self._defaults
def save(self):
"""Save preferences to file."""
util.save_config_yaml(self._filename, self._prefs)
def reset_to_default(self):
"""Reset preferences to default."""
util.save_config_yaml(self._filename, self._defaults)
self.load()
def _validate_key(self, key):
if key not in self._defaults:
raise KeyError(f"No preference matching '{key}'")
def __contains__(self, item) -> bool:
return item in self._defaults
def __getitem__(self, key):
self.load()
self._validate_key(key)
return self._prefs.get(key, self._defaults[key])
def __setitem__(self, key, value):
self.load()
self._validate_key(key)
self._prefs[key] = value

  • Add a new "preference" to the _defaults dictionary which will store the date of last seen announcement in the format YYYYMMDD.
  • Update the app's state using the preference as done with other state/pref pairs below:

    sleap/sleap/gui/app.py

    Lines 148 to 161 in c4861e3

    self.state["skeleton"] = Skeleton()
    self.state["labeled_frame"] = None
    self.state["last_interacted_frame"] = None
    self.state["filename"] = None
    self.state["show non-visible nodes"] = prefs["show non-visible nodes"]
    self.state["show instances"] = True
    self.state["show labels"] = True
    self.state["show edges"] = True
    self.state["edge style"] = prefs["edge style"]
    self.state["fit"] = False
    self.state["color predicted"] = prefs["color predicted"]
    self.state["marker size"] = prefs["marker size"]
    self.state["propagate track labels"] = prefs["propagate track labels"]
    self.state["node label size"] = prefs["node label size"]
  • Save the preference upon closing the MainWindow

    sleap/sleap/gui/app.py

    Lines 211 to 220 in c4861e3

    def closeEvent(self, event):
    """Close application window, prompting for saving as needed."""
    # Save window state.
    prefs["window state"] = self.saveState()
    prefs["marker size"] = self.state["marker size"]
    prefs["show non-visible nodes"] = self.state["show non-visible nodes"]
    prefs["node label size"] = self.state["node label size"]
    prefs["edge style"] = self.state["edge style"]
    prefs["propagate track labels"] = self.state["propagate track labels"]
    prefs["color predicted"] = self.state["color predicted"]

2. Create a AnnouncementChecker class to check for and fetch new announcements

The sleap/gui/web.py module contains all web related interactions handled by SLEAP. We will modify this module to check for new announcements.
The AnnouncementChecker class should:

  • Check if there has been a new announcement since the last SEEN announcement
  • Fetch the announcement if there is a new UNSEEN announcement
  • Update the app's state of when an announcement was last seen.

PR 3: Display changelog/announcement page in SLEAP GUI

After we've fetched the announcement, we now need to display it in the GUI.

1. Create a simple dialog that displays the announcement.

All dialogs/pop-ups are created in the sleap/gui/dialogs folder as their own modules. For example, checkout the ShortcutDialog class:

class ShortcutDialog(QtWidgets.QDialog):
"""
Dialog window for reviewing and modifying the keyboard shortcuts.
"""
_column_len = 14
def __init__(self, *args, **kwargs):
super(ShortcutDialog, self).__init__(*args, **kwargs)
self.setWindowTitle("Keyboard Shortcuts")
self.load_shortcuts()
self.make_form()
def accept(self):
"""Triggered when form is accepted; saves the shortcuts."""
for action, widget in self.key_widgets.items():
self.shortcuts[action] = widget.keySequence().toString()
self.shortcuts.save()
self.info_msg()
super(ShortcutDialog, self).accept()
def info_msg(self):
"""Display information about changes."""
msg = QtWidgets.QMessageBox()
msg.setText(
"Application must be restarted before changes to keyboard shortcuts take "
"effect."
)
msg.exec_()
def reset(self):
"""Reset to defaults."""
self.shortcuts.reset_to_default()
self.info_msg()
super(ShortcutDialog, self).accept()
def load_shortcuts(self):
"""Load shortcuts object."""
self.shortcuts = Shortcuts()
def make_form(self):
"""Creates the form with fields for all shortcuts."""
self.key_widgets = dict() # dict to store QKeySequenceEdit widgets
layout = QtWidgets.QVBoxLayout()
layout.addWidget(self.make_shortcuts_widget())
layout.addWidget(self.make_buttons_widget())
self.setLayout(layout)
def make_buttons_widget(self) -> QtWidgets.QDialogButtonBox:
"""Make the form buttons."""
buttons = QtWidgets.QDialogButtonBox()
save = QtWidgets.QPushButton("Save")
save.clicked.connect(self.accept)
buttons.addButton(save, QtWidgets.QDialogButtonBox.AcceptRole)
cancel = QtWidgets.QPushButton("Cancel")
cancel.clicked.connect(self.reject)
buttons.addButton(cancel, QtWidgets.QDialogButtonBox.RejectRole)
reset = QtWidgets.QPushButton("Reset to defaults")
reset.clicked.connect(self.reset)
buttons.addButton(reset, QtWidgets.QDialogButtonBox.ActionRole)
return buttons
def make_shortcuts_widget(self) -> QtWidgets.QWidget:
"""Make the widget will fields for all shortcuts."""
shortcuts = self.shortcuts
widget = QtWidgets.QWidget()
layout = QtWidgets.QHBoxLayout()
# show shortcuts in columns
for a in range(0, len(shortcuts), self._column_len):
b = min(len(shortcuts), a + self._column_len)
column_widget = self.make_column_widget(shortcuts[a:b])
layout.addWidget(column_widget)
widget.setLayout(layout)
return widget
def make_column_widget(self, shortcuts: List) -> QtWidgets.QWidget:
"""Make a single column of shortcut fields.
Args:
shortcuts: The list of shortcuts to include in this column.
Returns:
The widget.
"""
column_widget = QtWidgets.QWidget()
column_layout = QtWidgets.QFormLayout()
for action in shortcuts:
item = QtWidgets.QKeySequenceEdit(shortcuts[action])
column_layout.addRow(action.title(), item)
self.key_widgets[action] = item
column_widget.setLayout(column_layout)
return column_widget

  • Create a new file bulletin.py and class BulletinDialog to display the announcement.
  • Create an instance of this BulletinDialog class while initializing the MainWindow IF there is an UNSEEN announcement.

PR 4: Add a Help Menu item which allows users to pop-up the announcement on demand.

Whenever a user calls upon any GUI commands, SLEAP's CommandContext in conjunction with the AppCommand class handles the call using a Command design pattern.

sleap/sleap/gui/commands.py

Lines 176 to 574 in c4861e3

class CommandContext:
"""
Context within in which commands are executed.
When you create a new command, you should both create a class for the
command (which inherits from `CommandClass`) and add a distinct method
for the command in the `CommandContext` class. This method is what should
be connected/called from other code to invoke the command.
Attributes:
state: The `GuiState` object used to store state and pass messages.
app: The `MainWindow`, available for commands that modify the app.
update_callback: A callback to receive update notifications.
This function should accept a list of `UpdateTopic` items.
"""
state: GuiState
app: "MainWindow"
update_callback: Optional[Callable] = None
_change_stack: List = attr.ib(default=attr.Factory(list))
@classmethod
def from_labels(cls, labels: Labels) -> "CommandContext":
"""Creates a command context for use independently of GUI app."""
state = GuiState()
app = FakeApp(labels)
return cls(state=state, app=app)
@property
def labels(self) -> Labels:
"""Alias to app.labels."""
return self.app.labels
def signal_update(self, what: List[UpdateTopic]):
"""Calls the update callback after data has been changed."""
if callable(self.update_callback):
self.update_callback(what)
def changestack_push(self, change: str = ""):
"""Adds to stack of changes made by user."""
# Currently the change doesn't store any data, and we're only using this
# to determine if there are unsaved changes. Eventually we could use this
# to support undo/redo.
self._change_stack.append(change)
# print(len(self._change_stack))
self.state["has_changes"] = True
def changestack_savepoint(self):
"""Marks that project was just saved."""
self.changestack_push("SAVE")
self.state["has_changes"] = False
def changestack_clear(self):
"""Clears stack of changes."""
self._change_stack = list()
self.state["has_changes"] = False
@property
def has_any_changes(self):
return len(self._change_stack) > 0
def execute(self, command: Type[AppCommand], **kwargs):
"""Execute command in this context, passing named arguments."""
command().execute(context=self, params=kwargs)
# File commands
def newProject(self):
"""Create a new project in a new window."""
self.execute(NewProject)
def openProject(self, filename: Optional[str] = None, first_open: bool = False):
"""
Allows use to select and then open a saved project.
Args:
filename: Filename of the project to be opened. If None, a file browser
dialog will prompt the user for a path.
first_open: Whether this is the first window opened. If True,
then the new project is loaded into the current window
rather than a new application window.
Returns:
None.
"""
self.execute(OpenProject, filename=filename, first_open=first_open)
def importAT(self):
"""Imports AlphaTracker datasets."""
self.execute(ImportAlphaTracker)
def importNWB(self):
"""Imports NWB datasets."""
self.execute(ImportNWB)
def importDPK(self):
"""Imports DeepPoseKit datasets."""
self.execute(ImportDeepPoseKit)
def importCoco(self):
"""Imports COCO datasets."""
self.execute(ImportCoco)
def importDLC(self):
"""Imports DeepLabCut datasets."""
self.execute(ImportDeepLabCut)
def importDLCFolder(self):
"""Imports multiple DeepLabCut datasets."""
self.execute(ImportDeepLabCutFolder)
def importLEAP(self):
"""Imports LEAP matlab datasets."""
self.execute(ImportLEAP)
def importAnalysisFile(self):
"""Imports SLEAP analysis hdf5 files."""
self.execute(ImportAnalysisFile)
def saveProject(self):
"""Show gui to save project (or save as if not yet saved)."""
self.execute(SaveProject)
def saveProjectAs(self):
"""Show gui to save project as a new file."""
self.execute(SaveProjectAs)
def exportAnalysisFile(self, all_videos: bool = False):
"""Shows gui for exporting analysis h5 file."""
self.execute(ExportAnalysisFile, all_videos=all_videos)
def exportNWB(self):
"""Show gui for exporting nwb file."""
self.execute(SaveProjectAs, adaptor=NDXPoseAdaptor())
def exportLabeledClip(self):
"""Shows gui for exporting clip with visual annotations."""
self.execute(ExportLabeledClip)
def exportUserLabelsPackage(self):
"""Gui for exporting the dataset with user-labeled images."""
self.execute(ExportUserLabelsPackage)
def exportTrainingPackage(self):
"""Gui for exporting the dataset with user-labeled images and suggestions."""
self.execute(ExportTrainingPackage)
def exportFullPackage(self):
"""Gui for exporting the dataset with any labeled frames and suggestions."""
self.execute(ExportFullPackage)
# Navigation Commands
def previousLabeledFrame(self):
"""Goes to labeled frame prior to current frame."""
self.execute(GoPreviousLabeledFrame)
def nextLabeledFrame(self):
"""Goes to labeled frame after current frame."""
self.execute(GoNextLabeledFrame)
def nextUserLabeledFrame(self):
"""Goes to next labeled frame with user instances."""
self.execute(GoNextUserLabeledFrame)
def lastInteractedFrame(self):
"""Goes to last frame that user interacted with."""
self.execute(GoLastInteractedFrame)
def nextSuggestedFrame(self):
"""Goes to next suggested frame."""
self.execute(GoNextSuggestedFrame)
def prevSuggestedFrame(self):
"""Goes to previous suggested frame."""
self.execute(GoPrevSuggestedFrame)
def addCurrentFrameAsSuggestion(self):
"""Add current frame as a suggestion."""
self.execute(AddSuggestion)
def removeSuggestion(self):
"""Remove the selected frame from suggestions."""
self.execute(RemoveSuggestion)
def clearSuggestions(self):
"""Clear all suggestions."""
self.execute(ClearSuggestions)
def nextTrackFrame(self):
"""Goes to next frame on which a track starts."""
self.execute(GoNextTrackFrame)
def gotoFrame(self):
"""Shows gui to go to frame by number."""
self.execute(GoFrameGui)
def selectToFrame(self):
"""Shows gui to go to frame by number."""
self.execute(SelectToFrameGui)
def gotoVideoAndFrame(self, video: Video, frame_idx: int):
"""Activates video and goes to frame."""
NavCommand.go_to(self, frame_idx, video)
# Editing Commands
def toggleGrayscale(self):
"""Toggles grayscale setting for current video."""
self.execute(ToggleGrayscale)
def addVideo(self):
"""Shows gui for adding videos to project."""
self.execute(AddVideo)
def showImportVideos(self, filenames: List[str]):
"""Show video importer GUI without the file browser."""
self.execute(ShowImportVideos, filenames=filenames)
def replaceVideo(self):
"""Shows gui for replacing videos to project."""
self.execute(ReplaceVideo)
def removeVideo(self):
"""Removes selected video from project."""
self.execute(RemoveVideo)
def openSkeleton(self):
"""Shows gui for loading saved skeleton into project."""
self.execute(OpenSkeleton)
def saveSkeleton(self):
"""Shows gui for saving skeleton from project."""
self.execute(SaveSkeleton)
def newNode(self):
"""Adds new node to skeleton."""
self.execute(NewNode)
def deleteNode(self):
"""Removes (currently selected) node from skeleton."""
self.execute(DeleteNode)
def setNodeName(self, skeleton, node, name):
"""Changes name of node in skeleton."""
self.execute(SetNodeName, skeleton=skeleton, node=node, name=name)
def setNodeSymmetry(self, skeleton, node, symmetry: str):
"""Sets node symmetry in skeleton."""
self.execute(SetNodeSymmetry, skeleton=skeleton, node=node, symmetry=symmetry)
def updateEdges(self):
"""Called when edges in skeleton have been changed."""
self.signal_update([UpdateTopic.skeleton])
def newEdge(self, src_node, dst_node):
"""Adds new edge to skeleton."""
self.execute(NewEdge, src_node=src_node, dst_node=dst_node)
def deleteEdge(self):
"""Removes (currently selected) edge from skeleton."""
self.execute(DeleteEdge)
def deletePredictions(self):
"""Deletes all predicted instances in project."""
self.execute(DeleteAllPredictions)
def deleteFramePredictions(self):
"""Deletes all predictions on current frame."""
self.execute(DeleteFramePredictions)
def deleteClipPredictions(self):
"""Deletes all predictions within selected range of video frames."""
self.execute(DeleteClipPredictions)
def deleteAreaPredictions(self):
"""Gui for deleting instances within some rect on frame images."""
self.execute(DeleteAreaPredictions)
def deleteLowScorePredictions(self):
"""Gui for deleting instances below some score threshold."""
self.execute(DeleteLowScorePredictions)
def deleteFrameLimitPredictions(self):
"""Gui for deleting instances beyond some number in each frame."""
self.execute(DeleteFrameLimitPredictions)
def completeInstanceNodes(self, instance: Instance):
"""Adds missing nodes to given instance."""
self.execute(AddMissingInstanceNodes, instance=instance)
def newInstance(
self,
copy_instance: Optional[Instance] = None,
init_method: str = "best",
location: Optional[QtCore.QPoint] = None,
mark_complete: bool = False,
):
"""Creates a new instance, copying node coordinates as appropriate.
Args:
copy_instance: The :class:`Instance` (or
:class:`PredictedInstance`) which we want to copy.
init_method: Method to use for positioning nodes.
location: The location where instance should be added (if node init
method supports custom location).
"""
self.execute(
AddInstance,
copy_instance=copy_instance,
init_method=init_method,
location=location,
mark_complete=mark_complete,
)
def setPointLocations(
self, instance: Instance, nodes_locations: Dict[Node, Tuple[int, int]]
):
"""Sets locations for node(s) for an instance."""
self.execute(
SetInstancePointLocations,
instance=instance,
nodes_locations=nodes_locations,
)
def setInstancePointVisibility(self, instance: Instance, node: Node, visible: bool):
"""Toggles visibility set for a node for an instance."""
self.execute(
SetInstancePointVisibility, instance=instance, node=node, visible=visible
)
def addUserInstancesFromPredictions(self):
self.execute(AddUserInstancesFromPredictions)
def deleteSelectedInstance(self):
"""Deletes currently selected instance."""
self.execute(DeleteSelectedInstance)
def deleteSelectedInstanceTrack(self):
"""Deletes all instances from track of currently selected instance."""
self.execute(DeleteSelectedInstanceTrack)
def deleteDialog(self):
"""Deletes using options selected in a dialog."""
self.execute(DeleteDialogCommand)
def addTrack(self):
"""Creates new track and moves selected instance into this track."""
self.execute(AddTrack)
def setInstanceTrack(self, new_track: "Track"):
"""Sets track for selected instance."""
self.execute(SetSelectedInstanceTrack, new_track=new_track)
def deleteTrack(self, track: "Track"):
"""Delete a track and remove from all instances."""
self.execute(DeleteTrack, track=track)
def deleteAllTracks(self):
"""Delete all tracks."""
self.execute(DeleteAllTracks)
def setTrackName(self, track: "Track", name: str):
"""Sets name for track."""
self.execute(SetTrackName, track=track, name=name)
def transposeInstance(self):
"""Transposes tracks for two instances.
If there are only two instances, then this swaps tracks.
Otherwise, it allows user to select the instances for which we want
to swap tracks.
"""
self.execute(TransposeInstances)
def mergeProject(self, filenames: Optional[List[str]] = None):
"""Starts gui for importing another dataset into currently one."""
self.execute(MergeProject, filenames=filenames)
def generateSuggestions(self, params: Dict):
"""Generates suggestions using given params dictionary."""
self.execute(GenerateSuggestions, **params)
def openWebsite(self, url):
"""Open a website from URL using the native system browser."""
self.execute(OpenWebsite, url=url)
def checkForUpdates(self):
"""Check for updates online."""
self.execute(CheckForUpdates)
def openStableVersion(self):
"""Open the current stable version."""
self.execute(OpenStableVersion)
def openPrereleaseVersion(self):
"""Open the current prerelease version."""
self.execute(OpenPrereleaseVersion)

class AppCommand:
"""Base class for specific commands.
Note that this is not an abstract base class. For specific commands, you
should override `ask` and/or `do_action` methods, or add an `ask_and_do`
method. In many cases you'll want to override the `topics` and `does_edits`
attributes. That said, these are not virtual methods/attributes and have
are implemented in the base class with default behaviors (i.e., doing
nothing).
You should not override `execute` or `do_with_signal`.
Attributes:
topics: List of `UpdateTopic` items. Override this to indicate what
should be updated after command is executed.
does_edits: Whether command will modify data that could be saved.
"""
topics: List[UpdateTopic] = []
does_edits: bool = False
def execute(self, context: "CommandContext", params: dict = None):
"""Entry point for running command.
This calls internal methods to gather information required for
execution, perform the action, and notify about changes.
Ideally, any information gathering should be performed in the `ask`
method, and be added to the `params` dictionary which then gets
passed to `do_action`. The `ask` method should not modify state.
(This will make it easier to add support for undo,
using an `undo_action` which will be given the same `params`
dictionary.)
If it's not possible to easily separate information gathering from
performing the action, the child class should implement `ask_and_do`,
which it turn should call `do_with_signal` to notify about changes.
Args:
context: This is the `CommandContext` in which the command will
execute. Commands will use this to access `MainWindow`,
`GuiState`, and `Labels`.
params: Dictionary of any params for command.
"""
params = params or dict()
if hasattr(self, "ask_and_do") and callable(self.ask_and_do):
self.ask_and_do(context, params)
else:
okay = self.ask(context, params)
if okay:
self.do_with_signal(context, params)
@staticmethod
def ask(context: "CommandContext", params: dict) -> bool:
"""Method for information gathering.
Returns:
Whether to perform action. By default returns True, but this is
where we should return False if we prompt user for confirmation
and they abort.
"""
return True
@staticmethod
def do_action(context: "CommandContext", params: dict):
"""Method for performing action."""
pass
@classmethod
def do_with_signal(cls, context: "CommandContext", params: dict):
"""Wrapper to perform action and notify/track changes.
Don't override this method!
"""
cls.do_action(context, params)
if cls.topics:
context.signal_update(cls.topics)
if cls.does_edits:
context.changestack_push(cls.__name__)

1. Create a new class ShowBulletin(AppCommand) which displays the bulletin when ShowBulletin.execute() is run.

  • Create a do_action method which creates an instance of BulletinDialog
  • Create a CommandContext.bulletinDialog() method which executes BulletinDialog

2. Add a "Show Bulletin" button to the help menu

The help menu is created here:

sleap/sleap/gui/app.py

Lines 889 to 927 in c4861e3

helpMenu = self.menuBar().addMenu("Help")
helpMenu.addAction(
"Documentation", lambda: self.commands.openWebsite("https://sleap.ai")
)
helpMenu.addAction(
"GitHub",
lambda: self.commands.openWebsite("https://github.com/talmolab/sleap"),
)
helpMenu.addAction(
"Releases",
lambda: self.commands.openWebsite(
"https://github.com/talmolab/sleap/releases"
),
)
helpMenu.addSeparator()
helpMenu.addAction("Latest versions:", self.commands.checkForUpdates)
self.state["stable_version_menu"] = helpMenu.addAction(
" Stable: N/A", self.commands.openStableVersion
)
self.state["stable_version_menu"].setEnabled(False)
self.state["prerelease_version_menu"] = helpMenu.addAction(
" Prerelease: N/A", self.commands.openPrereleaseVersion
)
self.state["prerelease_version_menu"].setEnabled(False)
self.commands.checkForUpdates()
helpMenu.addSeparator()
usageMenu = helpMenu.addMenu("Improve SLEAP")
add_menu_check_item(usageMenu, "share usage data", "Share usage data")
usageMenu.addAction(
"What is usage data?",
lambda: self.commands.openWebsite("https://sleap.ai/help.html#usage-data"),
)
helpMenu.addSeparator()
helpMenu.addAction("Keyboard Shortcuts", self._show_keyboard_shortcuts_window)

  • Add an action to the helpMenu with title "Show Bulletin" and action self.commands.showBulletin
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
2023-hackathon PRs created for the 2023 intra-lab SLEAP hackathon (very detailed) enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

2 participants