class AlgorithmProgressWidget(QWidget): """ Widget consisting of a progress bar and a button. """ def __init__(self, parent=None): super(AlgorithmProgressWidget, self).__init__(parent) self.progress_bar = None self.details_button = QPushButton('Details') self.details_button.clicked.connect(self.show_dialog) self.layout = QHBoxLayout() self.layout.addStretch() self.layout.addWidget(self.details_button) self.setLayout(self.layout) self.presenter = AlgorithmProgressPresenter(self) def show_progress_bar(self): if self.progress_bar is None: self.progress_bar = QProgressBar() self.progress_bar.setAlignment(Qt.AlignHCenter) self.layout.insertWidget(0, self.progress_bar) self.layout.removeItem(self.layout.takeAt(1)) def hide_progress_bar(self): if self.progress_bar is not None: self.layout.insertStretch(0) self.layout.removeWidget(self.progress_bar) self.progress_bar.close() self.progress_bar = None def show_dialog(self): dialog = AlgorithmMonitorDialog(self, self.presenter.model) dialog.show()
class SpotifyWebPrompt(QWidget): """ This widget handles all the interaction with the user to obtain the credentials for the Spotify Web API. """ done = Signal(RefreshingToken) def __init__(self, client_id: str, client_secret: str, redirect_uri: str, *args) -> None: """ Starts the API initialization flow, which is the following: 1. The user inputs the credentials. ** self.on_submit_creds is called ** 2. The user logs in. ** self.on_login is called ** 3. If the credentials were correct, the Web API is set up and started from outside this widget. Otherwise, go back to step 1. ** self.done is emitted ** """ super().__init__(*args) logging.info("Initializing the Spotify Web API prompt interface") self.redirect_uri = redirect_uri # Creating the layout self.layout = QHBoxLayout(self) self.layout.setContentsMargins(0, 0, 0, 0) self.layout.setSpacing(0) # The web form for the user to input the credentials. self.web_form = SpotifyWebForm(client_id=client_id, client_secret=client_secret) # on_submit_spotify_web creds will be called once the credentials have # been input. self.web_form.button.clicked.connect(self.on_submit_creds) self.layout.addWidget(self.web_form) # The web browser for the user to login and grant access. # It's hidden at the beggining and will appear once the credentials # are input. self.browser = WebBrowser() self.browser.hide() # The initial screen with the web form will be shown if the user # clicks on the Go Back button. self.browser.go_back_button.pressed.connect( lambda: (self.browser.hide(), self.web_form.show())) # Any change in the browser URL will redirect to on__login to check if # the login was succesful. self.browser.web_view.urlChanged.connect(self.on_login) self.layout.addWidget(self.browser) @property def client_id(self) -> str: return self.web_form.client_id @property def client_secret(self) -> str: return self.web_form.client_secret @Slot() def on_submit_creds(self) -> None: """ Checking if the submitted credentials were correct, and starting the logging-in step. """ # Obtaining the input data form_client_id = self.web_form.client_id form_client_secret = self.web_form.client_secret logging.info("Input creds: '%s' & '%s'", form_client_id, form_client_secret) # Checking that the data isn't empty empty_field = False if form_client_id == '': self.web_form.input_client_id.highlight() empty_field = True else: self.web_form.input_client_id.undo_highlight() if form_client_secret == '': self.web_form.input_client_secret.highlight() empty_field = True else: self.web_form.input_client_secret.undo_highlight() if empty_field: return # Hiding the form and showing the web browser for the next step self.web_form.hide() self.browser.show() # Creating the request URL to obtain the authorization token self.creds = RefreshingCredentials( form_client_id, form_client_secret, self.redirect_uri) self.scope = scopes.user_read_currently_playing url = self.creds.user_authorisation_url(self.scope) self.browser.url = url @Slot() def on_login(self) -> None: """ This function is called once the user has logged into Spotify to obtain the access token. Part of this function is a reimplementation of `tekore.prompt_user_token`. It does the same thing but in a more automatized way, because Qt has access over the web browser too. """ url = self.browser.url logging.info("Now at: %s", url) # If the URL isn't the Spotify response URI (localhost), do nothing if url.find(self.redirect_uri) == -1: return # Trying to get the auth token from the URL with Tekore's # parse_code_from_url(), which throws a KeyError if the URL doesn't # contain an auth token or if it contains more than one. try: code = parse_code_from_url(url) except KeyError as e: logging.info("ERROR: %s", str(e)) return # Now the user token has to be requested to Spotify, while # checking for errors to make sure the credentials were correct. # This will only happen with the client secret because it's only # checked when requesting the user token. try: # A RefreshingToken is used instead of a regular Token so that # it's automatically refreshed before it expires. self.creds is # of type `RefreshingCredentials`, so it returns always a # RefreshingToken. token = self.creds.request_user_token(code) except OAuthError as e: self.browser.hide() self.web_form.show() self.web_form.show_error(str(e)) return # Removing the GUI elements used to obtain the credentials self.layout.removeWidget(self.web_form) self.web_form.hide() self.layout.removeWidget(self.browser) self.browser.hide() # Finally starting the Web API self.done.emit(token)
class MainWindow(QWidget): def __init__(self, config: Config) -> None: """ Main window with the GUI and whatever player is being used. """ super().__init__() self.setWindowTitle('vidify') # Setting the window to stay on top if config.stay_on_top: self.setWindowFlags(Qt.WindowStaysOnTopHint) # Setting the fullscreen and window size if config.fullscreen: self.showFullScreen() else: self.resize(config.width or 800, config.height or 600) # Loading the used fonts (Inter) font_db = QFontDatabase() for font in Res.fonts: font_db.addApplicationFont(font) # Initializing the player and saving the config object in the window. self.layout = QHBoxLayout(self) self.layout.setContentsMargins(0, 0, 0, 0) self.layout.setSpacing(0) self.player = initialize_player(config.player, config) logging.info("Using %s as the player", config.player) self.config = config # The API initialization is more complex. For more details, please # check the flow diagram in vidify.api. First we have to check if # the API is saved in the config: try: api_data = get_api_data(config.api) except KeyError: # Otherwise, the user is prompted for an API. After choosing one, # it will be initialized from outside this function. logging.info("API not found: prompting the user") self.API_selection = APISelection() self.layout.addWidget(self.API_selection) self.API_selection.api_chosen.connect(self.on_api_selection) else: logging.info("Using %s as the API", config.api) self.initialize_api(api_data) @Slot(str) def on_api_selection(self, api_str: str) -> None: """ Method called when the API is selected with APISelection. The provided api string must be an existent entry inside the APIData enumeration. """ # Removing the widget used to obtain the API string self.layout.removeWidget(self.API_selection) self.API_selection.setParent(None) self.API_selection.hide() del self.API_selection # Saving the API in the config self.config.api = api_str # Starting the API initialization self.initialize_api(APIData[api_str]) def initialize_api(self, api_data: APIData) -> None: """ Initializes an API with the information from APIData. """ # The API may need interaction with the user to obtain credentials # or similar data. This function will already take care of the # rest of the initialization. if api_data.gui_init_fn is not None: fn = getattr(self, api_data.gui_init_fn) fn() return # Initializing the API with dependency injection. mod = importlib.import_module(api_data.module) cls = getattr(mod, api_data.class_name) self.api = cls() self.wait_for_connection( self.api.connect_api, message=api_data.connect_msg, event_loop_interval=api_data.event_loop_interval) def wait_for_connection(self, conn_fn: Callable[[], None], message: Optional[str] = None, event_loop_interval: int = 1000) -> None: """ Creates an APIConnecter instance and waits for the API to be available, or times out otherwise. """ self.event_loop_interval = event_loop_interval self.api_connecter = APIConnecter( conn_fn, message or "Waiting for connection") self.api_connecter.success.connect(self.on_conn_success) self.api_connecter.fail.connect(self.on_conn_fail) self.layout.addWidget(self.api_connecter) self.api_connecter.start() @Slot() def on_conn_fail(self) -> None: """ If the API failed to connect, the app will be closed. """ print("Timed out waiting for the connection") QCoreApplication.exit(1) @Slot(float) def on_conn_success(self, start_time: float) -> None: """ Once the connection has been established correctly, the API can be started properly. """ logging.info("Succesfully connected to the API") self.layout.removeWidget(self.api_connecter) del self.api_connecter # Initializing the optional audio synchronization extension, now # that there's access to the API's data. Note that this feature # is only available on Linux. if self.config.audiosync: from vidify.audiosync import AudiosyncWorker self.audiosync = AudiosyncWorker(self.api.player_name) self.audiosync.success.connect(self.on_audiosync_success) self.audiosync.failed.connect(self.on_audiosync_fail) # Loading the player self.setStyleSheet(f"background-color:{Colors.black};") self.layout.addWidget(self.player) self.play_video(self.api.artist, self.api.title, start_time) # Connecting to the signals generated by the API self.api.new_song_signal.connect(self.play_video) self.api.position_signal.connect(self.change_video_position) self.api.status_signal.connect(self.change_video_status) # Starting the event loop if it was initially passed as # a parameter. if self.event_loop_interval is not None: self.start_event_loop(self.api.event_loop, self.event_loop_interval) def start_event_loop(self, event_loop: Callable[[], None], ms: int) -> None: """ Starts a "manual" event loop with a timer every `ms` milliseconds. This is used with the SwSpotify API and the Web API to check every `ms` seconds if a change has happened, like if the song was paused. """ logging.info("Starting event loop") timer = QTimer(self) # Qt doesn't accept a method as the parameter so it's converted # to a function. if isinstance(event_loop, types.MethodType): timer.timeout.connect(lambda: event_loop()) else: timer.timeout.connect(event_loop) timer.start(ms) @Slot(bool) def change_video_status(self, is_playing: bool) -> None: """ Slot used for API updates of the video status. """ self.player.pause = not is_playing # If there is an audiosync thread running, this will pause the sound # recording and youtube downloading. if self.config.audiosync and self.audiosync.status != 'idle': self.audiosync.is_running = is_playing @Slot(int) def change_video_position(self, ms: int) -> None: """ Slot used for API updates of the video position. """ if not self.config.audiosync: self.player.position = ms # Audiosync is aborted if the position of the video changed, since # the audio being recorded won't make sense. if self.config.audiosync and self.audiosync.status != 'idle': self.audiosync.abort() @Slot(str, str, float) def play_video(self, artist: str, title: str, start_time: float) -> None: """ Slot used to play a video. This is called when the API is first initialized from this GUI, and afterwards from the event loop handler whenever a new song is detected. If an error was detected when downloading the video, the default one is shown instead. Both audiosync and youtubedl work in separate threads to avoid blocking the GUI. This method will start both of them. """ # Checking that the artist and title are valid first of all if self.api.artist in (None, '') and self.api.title in (None, ''): logging.info("The provided artist and title are empty.") self.on_youtubedl_fail() if self.config.audiosync: self.on_audiosync_fail() return # This delay is used to know the elapsed time until the video # actually starts playing, used in the audiosync feature. self.timestamp = start_time query = f"ytsearch:{format_name(artist, title)} Official Video" if self.config.audiosync: self.launch_audiosync(query) self.launch_youtubedl(query) def launch_audiosync(self, query: str) -> None: """ Starts the audiosync thread, that will call either self.on_audiosync_success, or self.on_audiosync_fail once it's finished. First trying to stop the previous audiosync thread, as only one audiosync thread can be running at once. Note: QThread.start() is guaranteed to work once QThread.run() has returned. Thus, this will wait until it's done and launch the new one. """ self.audiosync.abort() self.audiosync.wait() self.audiosync.youtube_title = query self.audiosync.start() logging.info("Started a new audiosync job") def launch_youtubedl(self, query: str) -> None: """ Starts a YoutubeDL thread that will call either self.on_youtubedl_success or self.on_youtubedl_fail once it's done. """ logging.info("Starting the youtube-dl thread") self.youtubedl = YouTubeDLWorker( query, self.config.debug, self.config.width, self.config.height) self.yt_thread = QThread() self.youtubedl.moveToThread(self.yt_thread) self.yt_thread.started.connect(self.youtubedl.get_url) self.youtubedl.success.connect(self.on_yt_success) self.youtubedl.fail.connect(self.on_youtubedl_fail) self.youtubedl.finish.connect(self.yt_thread.exit) self.yt_thread.start() @Slot() def on_youtubedl_fail(self) -> None: """ If Youtube-dl for whatever reason failed to load the video, a fallback error video is shown, along with a message to let the user know what happened. """ self.player.start_video(Res.default_video, self.api.is_playing) print("The video wasn't found, either because of an issue with your" " internet connection or because the provided data was invalid." " For more information, enable the debug mode.") @Slot(str) def on_yt_success(self, url: str) -> None: """ Obtains the video URL from the Youtube-dl thread and starts playing the video. Also shows the lyrics if enabled. The position of the video isn't set if it's using audiosync, because this is done by the AudiosyncWorker thread. """ self.player.start_video(url, self.api.is_playing) if not self.config.audiosync: try: self.player.position = self.api.position except NotImplementedError: self.player.position = 0 # Finally, the lyrics are displayed. If the video wasn't found, an # error message is shown. if self.config.lyrics: print(get_lyrics(self.api.artist, self.api.title)) @Slot() def on_audiosync_fail(self) -> None: """ Currently, when audiosync fails, nothing happens. """ logging.info("Audiosync module failed to return the lag") @Slot(int) def on_audiosync_success(self, lag: int) -> None: """ Slot used after the audiosync function has finished. It sets the returned lag in milliseconds on the player. This assumes that the song wasn't paused until this issue is fixed: https://github.com/vidify/audiosync/issues/12 """ logging.info("Audiosync module returned %d ms", lag) # The current API position according to what's being recorded. playback_delay = round((time.time() - self.timestamp) * 1000) \ - self.player.position lag += playback_delay # The user's custom audiosync delay. This is basically the time taken # until the module started recording (which may depend on the user # hardware and other things). Thus, it will almost always be a # negative value. lag += self.config.audiosync_calibration logging.info("Total delay is %d ms", lag) if lag > 0: self.player.position += lag elif lag < 0: # If a negative delay is larger than the current player position, # the player position is set to zero after the lag has passed # with a timer. if self.player.position < -lag: self.sync_timer = QTimer(self) self.sync_timer.singleShot( -lag, lambda: self.change_video_position(0)) else: self.player.position += lag def init_spotify_web_api(self) -> None: """ SPOTIFY WEB API CUSTOM FUNCTION Note: the Tekore imports are done inside the functions so that Tekore isn't needed for whoever doesn't plan to use the Spotify Web API. """ from vidify.api.spotify.web import get_token from vidify.gui.api.spotify_web import SpotifyWebPrompt token = get_token(self.config.refresh_token, self.config.client_id, self.config.client_secret) if token is not None: # If the previous token was valid, the API can already start. logging.info("Reusing a previously generated token") self.start_spotify_web_api(token, save_config=False) else: # Otherwise, the credentials are obtained with the GUI. When # a valid auth token is ready, the GUI will initialize the API # automatically exactly like above. The GUI won't ask for a # redirect URI for now. logging.info("Asking the user for credentials") # The SpotifyWebPrompt handles the interaction with the user and # emits a `done` signal when it's done. self._spotify_web_prompt = SpotifyWebPrompt( self.config.client_id, self.config.client_secret, self.config.redirect_uri) self._spotify_web_prompt.done.connect(self.start_spotify_web_api) self.layout.addWidget(self._spotify_web_prompt) def start_spotify_web_api(self, token: 'RefreshingToken', save_config: bool = True) -> None: """ SPOTIFY WEB API CUSTOM FUNCTION Initializes the Web API, also saving them in the config for future usage (if `save_config` is true). """ from vidify.api.spotify.web import SpotifyWebAPI logging.info("Initializing the Spotify Web API") # Initializing the web API self.api = SpotifyWebAPI(token) api_data = APIData['SPOTIFY_WEB'] self.wait_for_connection( self.api.connect_api, message=api_data.connect_msg, event_loop_interval=api_data.event_loop_interval) # The obtained credentials are saved for the future if save_config: logging.info("Saving the Spotify Web API credentials") self.config.client_secret = self._spotify_web_prompt.client_secret self.config.client_id = self._spotify_web_prompt.client_id self.config.refresh_token = token.refresh_token # The credentials prompt widget is removed after saving the data. It # may not exist because start_spotify_web_api was called directly, # so errors are taken into account. try: self.layout.removeWidget(self._spotify_web_prompt) self._spotify_web_prompt.hide() del self._spotify_web_prompt except AttributeError: pass
class LightApp(Display): """ Main widget display for the lightpath Shows tables of devices and the current destination of the beam, as well as the status of the MPS system for LCLS Parameters ---------- controller: LightController LightController object beamline : str, optional Beamline to initialize the application with, otherwise the most upstream beamline will be selected dark : bool, optional Load the UI with the `qdarkstyle` interface parent : optional """ shown_types = [ dtypes.Attenuator, dtypes.GateValve, dtypes.IPM, dtypes.LODCM, dtypes.OffsetMirror, dtypes.PIM, PPSStopper, dtypes.PulsePicker, dtypes.Slits, dtypes.Stopper, dtypes.XFLS ] def __init__(self, controller, beamline=None, parent=None, dark=True): super().__init__(parent=parent) # Store Lightpath information self.light = controller self.path = None self.detail_screen = None self.device_buttons = dict() self._lock = threading.Lock() # Create empty layout self.lightLayout = QHBoxLayout() self.lightLayout.setSpacing(1) self.widget_rows.setLayout(self.lightLayout) self.device_types.setLayout(QGridLayout()) self.overview.setLayout(QHBoxLayout()) self.overview.layout().setSpacing(2) self.overview.layout().setContentsMargins(2, 2, 2, 2) # Setup the fancy overview slider slide_scroll = self.scroll.horizontalScrollBar() self.slide.setRange(slide_scroll.minimum(), slide_scroll.maximum()) self.slide.sliderMoved.connect(slide_scroll.setSliderPosition) slide_scroll.rangeChanged.connect(self.slide.setRange) slide_scroll.valueChanged.connect(self.slide.setSliderPosition) # Add destinations for line in self.destinations(): self.destination_combo.addItem(line) # Connect signals to slots self.destination_combo.currentIndexChanged.connect( self.change_path_display) self.device_combo.activated[str].connect(self.focus_on_device) self.impediment_button.pressed.connect(self.focus_on_device) self.remove_check.toggled.connect(self.filter) self.upstream_check.toggled.connect(self.filter) self.detail_hide.clicked.connect(self.hide_detailed) # Store LightRow objects to manage subscriptions self.rows = list() # Select the beamline to begin with beamline = beamline or self.destinations()[0] try: idx = self.destinations().index(beamline.upper()) except ValueError: logger.error("%s is not a valid beamline", beamline) idx = 0 # Move the ComboBox self.destination_combo.setCurrentIndex(idx) # Add all of our device type options max_columns = 3 for i, row in enumerate(np.array_split(self.shown_types, max_columns)): for j, device_type in enumerate(row): # Add box to layout box = QCheckBox(device_type.__name__) box.setChecked(True) self.device_types.layout().addWidget(box, j, i) # Hook up box to hide function self.device_buttons[box] = device_type box.toggled.connect(self.filter) # Setup the UI self.change_path_display() self.resizeSlider() # Change the stylesheet if dark: typhos.use_stylesheet(dark=True) def destinations(self): """ All possible beamline destinations sorted by end point """ return sorted(list(self.light.beamlines.keys()), key=lambda x: self.light.beamlines[x].range[0]) def load_device_row(self, device): """ Create LightRow for device """ # Create two widgets widgets = (LightRow(device), LightRow(device)) # Condense the second widgets[1].condense() return widgets def select_devices(self, beamline): """ Select a subset of beamline devices to show in the display Parameters ---------- beamline : str Beamline to display upstream : bool, optional Include upstream devices in the display """ # Clear any remaining subscriptions if self.path: self.clear_subs() # Find pool of devices and create subscriptions self.path = self.light.beamlines[beamline] # Defer running updates until UI is created self.path.subscribe(self.update_path, run=False) logger.debug("Selected %s devices ...", len(self.path.path)) return self.path.path def selected_beamline(self): """ Current beamline selected by the combo box """ return self.destination_combo.currentText() @property def hidden_devices(self): """Device types set to currently be visible""" return [ dtype for button, dtype in self.device_buttons.items() if not button.isChecked() ] @pyqtSlot() @pyqtSlot(bool) def change_path_display(self, value=None): """ Change the display devices based on the state of the control buttons """ with self._lock: logger.debug("Resorting beampath display ...") # Remove old detailed screen self.hide_detailed() # Grab all the light rows rows = [ self.load_device_row(d) for d in self.select_devices(self.selected_beamline()) ] # Clear layout if previously loaded rows exist if self.rows: # Clear our subscribtions for row in self.rows: # Remove from layout self.lightLayout.removeWidget(row[0]) self.overview.layout().removeWidget(row[1]) # Disconnect for widget in row: widget.clear_sub() widget.deleteLater() # Clear subscribed row cache self.rows.clear() self.device_combo.clear() # Hide nothing when switching beamlines boxes = self.device_types.children() boxes.extend([self.upstream_check, self.remove_check]) for box in boxes: if isinstance(box, QCheckBox): box.setChecked(True) # Add all the widgets to the display for i, row in enumerate(rows): # Cache row to later clear subscriptions self.rows.append(row) # Add widget to layout self.lightLayout.addWidget(row[0]) self.overview.layout().addWidget(row[1]) # Connect condensed widget to focus_on_device row[1].device_drawing.clicked.connect( partial(self.focus_on_device, name=row[1].device.name)) # Connect large widget to show Typhos screen row[0].device_drawing.clicked.connect( partial(self.show_detailed, row[0].device)) # Add device to combo self.device_combo.addItem(row[0].device.name) # Initialize interface for row in self.rows: for widget in row: widget.update_state() # Update the state of the path self.update_path() def ui_filename(self): """ Name of designer UI file """ return 'lightapp.ui' def ui_filepath(self): """ Full path to :attr:`.ui_filename` """ return os.path.join(os.path.dirname(os.path.abspath(__file__)), self.ui_filename()) def update_path(self, *args, **kwargs): """ Update the PyDMRectangles to show devices as in the beam or not """ with self._lock: block = self.path.impediment # Set the current impediment label if block: self.current_impediment.setText(block.name) self.impediment_button.setEnabled(True) else: self.current_impediment.setText('None') self.impediment_button.setEnabled(False) for row in self.rows: device = row[0].device # If our device is before or at the impediment, it is lit if not block or (device.md.z <= block.md.z): _in = True # Check whether this device is passing beam _out = block != device # Otherwise, it is off else: _in, _out = (False, False) # Update widget display for widget in row: widget.update_light(_in, _out) @pyqtSlot() @pyqtSlot(str) def focus_on_device(self, name=None): """Scroll to the desired device""" # If not provided a name, use the impediment name = name or self.current_impediment.text() # Map of names names = [row[0].device.name for row in self.rows] # Find index try: idx = names.index(name) except ValueError: logger.error("Can not set focus on device %r", name) return # Grab widget self.rows[idx][0].setHidden(False) self.scroll.ensureWidgetVisible(self.rows[idx][0]) @pyqtSlot(bool) def filter(self, *args): """Hide devices along the beamline for a more succinct view""" for row in self.rows: device = row[0].device # Hide if a hidden instance of a device type hidden_device_type = type(device) in self.hidden_devices # Hide if removed hidden_removed = (not self.remove_check.isChecked() and row[0].last_state == DeviceState.Removed) # Hide if upstream beamline = self.selected_beamline() hidden_upstream = (not self.upstream_check.isChecked() and device.md.beamline != beamline) # Hide device if any of the criteria are met row[0].setHidden(hidden_device_type or hidden_removed or hidden_upstream) # Change the slider size to match changing view self.resizeSlider() def clear_subs(self): """ Clear the subscription event """ self.path.clear_sub(self.update_path) @pyqtSlot() def show_detailed(self, device): """Show the Typhos display for a device""" # Hide the last widget self.hide_detailed() # Create a Typhos display try: self.detail_screen = TyphosDeviceDisplay.from_device(device) except Exception: logger.exception("Unable to create display for %r", device.name) return # Add to widget self.detail_layout.insertWidget(1, self.detail_screen, 0, Qt.AlignHCenter) self.device_detail.show() @pyqtSlot() def hide_detailed(self): """Hide Typhos display for a device""" # Catch the issue when there is no detail_screen already self.device_detail.hide() if self.detail_screen: # Remove from layout self.detail_layout.removeWidget(self.detail_screen) # Destroy widget self.detail_screen.deleteLater() self.detail_screen = None def resizeSlider(self): # Visible area of beamline visible = self.scroll.width() / self.scroll.widget().width() # Take same fraction of bar up in handle width slider_size = round(self.slide.width() * visible) # Set Stylesheet self.slide.setStyleSheet('QSlider::handle' '{width: %spx;' 'background: rgb(124, 252, 0);}' '' % slider_size) def show(self): # Comandeered to assure that slider is initialized properly super().show() self.resizeSlider() def resizeEvent(self, evt): # Further resize-ing of the widget should affect the fancy slider super().resizeEvent(evt) self.resizeSlider()
class CyclePlotWidget(QWidget): """ Widget to display cycling data and labels showing data at the point under the mouse. Parameters ---------- parent : CycleTracks CycleTracks main window object. style : str, optional Plot style to apply. """ currentPointChanged = Signal(dict) """ **signal** currentPointChanged(dict `values`) Emitted when a point in the plot is hovered over. The dict provides the date, speed, distance, calories and time data for the chosen point. """ pointSelected = Signal(object) """ **signal** pointSelected(datetime `currentPoint`) Emitted when the plot is double clicked, with the date from the current point. """ def __init__(self, parent, style="dark"): super().__init__() self.plotState = None self.plotLabel = None self.parent = parent self._makePlot(parent, style=style) self.plotToolBar = PlotToolBar() self.plotToolBar.viewAllClicked.connect(self.plotWidget.viewAll) self.plotToolBar.viewRangeClicked.connect( self.plotWidget.resetMonthRange) self.plotToolBar.highlightPBClicked.connect( self.plotWidget._highlightPBs) self.plotLayout = QHBoxLayout() self.plotLayout.addWidget(self.plotWidget) self.plotLayout.addWidget(self.plotToolBar) self.layout = QVBoxLayout() self.layout.addLayout(self.plotLayout) self.layout.addWidget(self.plotLabel) self.setLayout(self.layout) def _makePlot(self, *args, **kwargs): self.plotWidget = Plot(*args, **kwargs) if self.plotLabel is None: self.plotLabel = CyclePlotLabel(self.plotWidget.style) else: self.plotLabel.setStyle(self.plotWidget.style) self.plotLabel.labelClicked.connect(self.plotWidget.switchSeries) self.plotWidget.currentPointChanged.connect(self.plotLabel.setLabels) self.plotWidget.pointSelected.connect(self.pointSelected) @Slot() def newData(self): self.plotWidget.updatePlots() @Slot(object) def setCurrentPointFromDate(self, date): self.plotWidget.setCurrentPointFromDate(date) @Slot(object, bool) def setXAxisRange(self, months, fromRecentSession=True): self.plotWidget.setXAxisRange(months, fromRecentSession=fromRecentSession) @Slot(str) def setStyle(self, style, force=False): if force or self.plotWidget.style.name != style: self.plotState = self.plotWidget.getState() self.plotLayout.removeWidget(self.plotWidget) self.plotWidget.deleteLater() self._makePlot(self.parent, style=style) self.plotWidget.setState(self.plotState) self.plotLayout.insertWidget(0, self.plotWidget) def addCustomStyle(self, name, style, setStyle=True): self.plotWidget.style.addStyle(name, style) if setStyle: self.setStyle(name, force=True) def removeCustomStyle(self, name): # TODO remove style from file # and update current? self.plotWidget.style.removeStyle(name) def getStyle(self, name): return self.plotWidget.style.getStyleDict(name) def getStyleKeys(self): return self.plotWidget.style.keys def getStyleSymbolKeys(self): return self.plotWidget.style.symbolKeys def getValidStyles(self): return self.plotWidget.style.validStyles def getDefaultStyles(self): return self.plotWidget.style.defaultStyles