From 1e8ed3ab7ad298ce624c2742dece08cec7b181fb Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Sun, 30 Jun 2024 18:29:03 -0500 Subject: [PATCH 1/5] Update license year --- LICENSE | 2 +- docs/conf.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LICENSE b/LICENSE index 1f342036..4b972174 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Jordan Cook +Copyright (c) 2024 Jordan Cook Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/docs/conf.py b/docs/conf.py index f9a644d4..b53e0782 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -7,7 +7,7 @@ # Basic config project = 'Naturtag' -copyright = '2022, Jordan Cook' +copyright = '2024, Jordan Cook' author = 'Jordan Cook' html_static_path = ['_static'] templates_path = ['_templates'] From 5f3581c95d91be689660be5db8ba8772731d4463 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Mon, 1 Jul 2024 14:36:08 -0500 Subject: [PATCH 2/5] Observation tab: add separate 'select' (for tagging) and 'view taxon' buttons instead of doing so automatically --- naturtag/app/app.py | 21 ++++++---- .../controllers/observation_controller.py | 37 ++++++++++------- naturtag/controllers/observation_view.py | 41 ++++++++++++++----- pyproject.toml | 2 +- 4 files changed, 67 insertions(+), 34 deletions(-) diff --git a/naturtag/app/app.py b/naturtag/app/app.py index 61408d92..90a8e87d 100755 --- a/naturtag/app/app.py +++ b/naturtag/app/app.py @@ -89,10 +89,10 @@ def __init__(self, app: NaturtagApp): # Select observation/taxon from image context menu, ID input fields, and iconic taxa filters self.image_controller.gallery.on_select_taxon.connect(self.taxon_controller.select_taxon) self.image_controller.gallery.on_select_observation.connect( - self.observation_controller.select_observation + self.observation_controller.display_observation_by_id ) self.image_controller.on_select_observation_id.connect( - self.observation_controller.select_observation + self.observation_controller.display_observation_by_id ) self.image_controller.on_select_taxon_id.connect(self.taxon_controller.select_taxon) self.taxon_controller.search.iconic_taxon_filters.on_select.connect( @@ -102,10 +102,9 @@ def __init__(self, app: NaturtagApp): # Update photo tab when a taxon is selected self.taxon_controller.on_select.connect(self.image_controller.select_taxon) - # Update photo and taxon tabs when an observation is selected - self.observation_controller.on_select.connect(self.image_controller.select_observation) - self.observation_controller.on_select.connect( - lambda obs: self.taxon_controller.display_taxon(obs.taxon, notify=False) + # Update photo tab when an observation is selected + self.observation_controller.obs_info.on_select.connect( + self.image_controller.select_observation ) # Settings that take effect immediately @@ -141,6 +140,14 @@ def __init__(self, app: NaturtagApp): lambda: self.tabs.setCurrentWidget(self.observation_controller) ) + # Display taxon and switch tabs for 'view taxon' button + self.observation_controller.obs_info.on_view_taxon.connect( + lambda taxon: self.taxon_controller.display_taxon(taxon, notify=False) + ) + self.observation_controller.obs_info.on_view_taxon.connect( + lambda: self.tabs.setCurrentWidget(self.taxon_controller) + ) + # Connect file picker <--> recent/favorite dirs self.image_controller.gallery.on_load_images.connect(self.app.user_dirs.add_recent_dirs) self.app.user_dirs.on_dir_open.connect(self.image_controller.gallery.load_file_dialog) @@ -176,7 +183,7 @@ def __init__(self, app: NaturtagApp): QShortcut(QKeySequence('F9'), self).activated.connect(self.reload_qss) demo_images = list((ASSETS_DIR / 'demo_images').glob('*.jpg')) self.image_controller.gallery.load_images(demo_images) # type: ignore - self.observation_controller.select_observation(56830941) + self.observation_controller.display_observation_by_id(56830941) def check_username(self): """If username isn't saved, show popup dialog to prompt user to enter it""" diff --git a/naturtag/controllers/observation_controller.py b/naturtag/controllers/observation_controller.py index f3cd93e4..f4db4caf 100644 --- a/naturtag/controllers/observation_controller.py +++ b/naturtag/controllers/observation_controller.py @@ -1,26 +1,31 @@ from logging import getLogger from typing import Iterable -from pyinaturalist import Observation +from pyinaturalist import Observation, Taxon from PySide6.QtCore import Qt, QThread, QTimer, Signal, Slot from PySide6.QtWidgets import QLabel, QPushButton from naturtag.app.style import fa_icon from naturtag.constants import DEFAULT_PAGE_SIZE from naturtag.controllers import BaseController, ObservationInfoSection -from naturtag.widgets import HorizontalLayout, ObservationInfoCard, ObservationList, VerticalLayout +from naturtag.widgets import ( + HorizontalLayout, + ObservationInfoCard, + ObservationList, + VerticalLayout, +) logger = getLogger(__name__) class ObservationController(BaseController): - on_select = Signal(Observation) #: An observation was selected + on_view_taxon = Signal(Taxon) #: A taxon was selected for viewing def __init__(self): super().__init__() self.root = HorizontalLayout(self) self.root.setAlignment(Qt.AlignLeft) - self.selected_observation: Observation = None + self.displayed_observation: Observation = None # Search inputs # self.search = ObservationSearch(self.app.settings) @@ -66,9 +71,8 @@ def __init__(self): self.next_button.setEnabled(False) button_layout.addWidget(self.next_button) - # Selected observation info + # Full observation info viewer self.obs_info = ObservationInfoSection() - self.obs_info.on_select.connect(self.display_observation) obs_layout = VerticalLayout() obs_layout.addLayout(self.obs_info) self.root.addLayout(obs_layout) @@ -83,13 +87,13 @@ def __init__(self): # Actions triggered directly by UI # ---------------------------------------- - def select_observation(self, observation_id: int): - """Select an observation to display full details""" - # Don't need to do anything if this observation is already selected - if self.selected_observation and self.selected_observation.id == observation_id: + def display_observation_by_id(self, observation_id: int): + """Display full observation details""" + # Don't need to do anything if this observation is already displayed + if self.displayed_observation and self.displayed_observation.id == observation_id: return - logger.info(f'Selecting observation {observation_id}') + logger.info(f'Viewing observation {observation_id}') future = self.app.threadpool.schedule( lambda: self.app.client.observations(observation_id, taxonomy=True), priority=QThread.HighPriority, @@ -128,8 +132,7 @@ def refresh(self): @Slot(Observation) def display_observation(self, observation: Observation): """Display full details for a single observation""" - self.selected_observation = observation - self.on_select.emit(observation) + self.displayed_observation = observation self.obs_info.load(observation) logger.debug(f'Loaded observation {observation.id}') @@ -145,7 +148,7 @@ def display_user_observations(self, observations: list[Observation]): def bind_selection(self, obs_cards: Iterable[ObservationInfoCard]): """Connect click signal from each observation card""" for obs_card in obs_cards: - obs_card.on_click.connect(self.select_observation) + obs_card.on_click.connect(self.display_observation_by_id) def update_pagination_buttons(self): """Update pagination buttons based on current page""" @@ -164,7 +167,11 @@ def get_user_observations(self) -> list[Observation]: # Maybe do that except on initial observation load? self.total_results = self.app.client.observations.count(username=self.app.settings.username) self.total_pages = (self.total_results // DEFAULT_PAGE_SIZE) + 1 - logger.debug('Total user observations: %s (%s pages)', self.total_results, self.total_pages) + logger.debug( + 'Total user observations: %s (%s pages)', + self.total_results, + self.total_pages, + ) observations = self.app.client.observations.get_user_observations( username=self.app.settings.username, diff --git a/naturtag/controllers/observation_view.py b/naturtag/controllers/observation_view.py index 926608df..d1e470f1 100644 --- a/naturtag/controllers/observation_view.py +++ b/naturtag/controllers/observation_view.py @@ -6,7 +6,7 @@ from collections import deque from logging import getLogger -from pyinaturalist import Observation +from pyinaturalist import Observation, Taxon from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtWidgets import QGroupBox, QLabel, QPushButton @@ -29,7 +29,8 @@ class ObservationInfoSection(HorizontalLayout): """Section to display selected observation photos and info""" - on_select = Signal(Observation) #: An observation was selected + on_select = Signal(Observation) #: An observation was selected for tagging + on_view_taxon = Signal(Taxon) #: A taxon was selected for viewing def __init__(self): super().__init__() @@ -90,7 +91,24 @@ def __init__(self): # self.parent_button.clicked.connect(self.select_parent) # button_layout.addWidget(self.parent_button) - # # Link button: Open web browser to taxon info page + # Select button: Use observation for tagging + self.select_button = QPushButton('Select') + self.select_button.setIcon(fa_icon('fa.tag', primary=True)) + self.select_button.clicked.connect(lambda: self.on_select.emit(self.selected_observation)) + self.select_button.setEnabled(False) + self.select_button.setToolTip('Select this observation for tagging') + button_layout.addWidget(self.select_button) + + # View taxon button + self.view_taxon_button = QPushButton('View Taxon') + self.view_taxon_button.setIcon(fa_icon('fa5s.spider', primary=True)) + self.view_taxon_button.clicked.connect( + lambda: self.on_view_taxon.emit(self.selected_observation.taxon) + ) + self.view_taxon_button.setEnabled(False) + button_layout.addWidget(self.view_taxon_button) + + # Link button: Open web browser to observation info page self.link_button = QPushButton('View on iNaturalist') self.link_button.setIcon(fa_icon('mdi.web', primary=True)) self.link_button.clicked.connect(lambda: webbrowser.open(self.selected_observation.uri)) @@ -127,7 +145,7 @@ def load(self, obs: Observation): size='medium', priority=QThread.HighPriority, ) - self._update_nav_buttons() + self._update_buttons() # Load additional thumbnails self.thumbnails.clear() @@ -169,7 +187,7 @@ def load(self, obs: Observation): ) self.details.add_line( GEOPRIVACY_ICONS.get(obs.geoprivacy, 'mdi.map-marker'), - f'Coordinates: {obs.private_location or obs.location} ' + f"Coordinates: {obs.private_location or obs.location} " f'({obs.geoprivacy or "Unknown geoprivacy"})', ) self.details.add_line( @@ -191,12 +209,8 @@ def load(self, obs: Observation): # self.hist_prev.append(self.selected_observation) # self.on_select_obj.emit(self.history_taxon) - def select_observation(self, observation: Observation): - self.load(observation) - self.on_select.emit(observation) - - def _update_nav_buttons(self): - """Update status and tooltip for 'back', 'forward', 'parent', and 'view on iNat' buttons""" + def _update_buttons(self): + """Update status and tooltip for nav and selection buttons""" # self.prev_button.setEnabled(bool(self.hist_prev)) # self.prev_button.setToolTip(self.hist_prev[-1].full_name if self.hist_prev else None) # self.next_button.setEnabled(bool(self.hist_next)) @@ -207,3 +221,8 @@ def _update_nav_buttons(self): # ) self.link_button.setEnabled(True) self.link_button.setToolTip(self.selected_observation.uri) + self.view_taxon_button.setEnabled(True) + self.view_taxon_button.setToolTip( + f'See details for {self.selected_observation.taxon.full_name}' + ) + self.select_button.setEnabled(True) diff --git a/pyproject.toml b/pyproject.toml index 3084d92f..4a5a4c69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,7 +113,7 @@ fix = true unsafe-fixes = true line-length = 100 output-format = 'grouped' -target-version = 'py310' +target-version = 'py311' [tool.ruff.lint] select = ['B', 'C4', 'C90', 'E', 'F'] From f2d47df01eb39f75f1e40e0d7b3b2238434fcd4c Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Mon, 1 Jul 2024 16:56:59 -0500 Subject: [PATCH 3/5] Taxon tab: add separate 'select' (for tagging) button instead of doing so automatically --- naturtag/app/app.py | 37 +++++++--- naturtag/controllers/taxon_controller.py | 13 ++-- naturtag/controllers/taxon_view.py | 90 ++++++++++++++++-------- 3 files changed, 92 insertions(+), 48 deletions(-) diff --git a/naturtag/app/app.py b/naturtag/app/app.py index 90a8e87d..c50bb975 100755 --- a/naturtag/app/app.py +++ b/naturtag/app/app.py @@ -87,24 +87,18 @@ def __init__(self, app: NaturtagApp): self.observation_controller.on_message.connect(self.info) # Select observation/taxon from image context menu, ID input fields, and iconic taxa filters - self.image_controller.gallery.on_select_taxon.connect(self.taxon_controller.select_taxon) + self.image_controller.gallery.on_select_taxon.connect( + self.taxon_controller.display_taxon_by_id + ) self.image_controller.gallery.on_select_observation.connect( self.observation_controller.display_observation_by_id ) self.image_controller.on_select_observation_id.connect( self.observation_controller.display_observation_by_id ) - self.image_controller.on_select_taxon_id.connect(self.taxon_controller.select_taxon) + self.image_controller.on_select_taxon_id.connect(self.taxon_controller.display_taxon_by_id) self.taxon_controller.search.iconic_taxon_filters.on_select.connect( - self.taxon_controller.select_taxon - ) - - # Update photo tab when a taxon is selected - self.taxon_controller.on_select.connect(self.image_controller.select_taxon) - - # Update photo tab when an observation is selected - self.observation_controller.obs_info.on_select.connect( - self.image_controller.select_observation + self.taxon_controller.display_taxon_by_id ) # Settings that take effect immediately @@ -133,6 +127,7 @@ def __init__(self, app: NaturtagApp): self.tabs.setTabVisible(self.log_tab_idx, self.app.settings.show_logs) # Switch to different tab if requested from Photos tab + # TODO: this could be simplified a bit: connect both select and switch from controller.gallery self.image_controller.on_select_taxon_tab.connect( lambda: self.tabs.setCurrentWidget(self.taxon_controller) ) @@ -148,6 +143,26 @@ def __init__(self, app: NaturtagApp): lambda: self.tabs.setCurrentWidget(self.taxon_controller) ) + # Display observation and switch tabs for 'view observations' button + # self.observation_controller.obs_info.on_view_taxon.connect( + # lambda taxon: self.taxon_controller.display_taxon(taxon, notify=False) + # ) + # self.observation_controller.obs_info.on_view_taxon.connect( + # lambda: self.tabs.setCurrentWidget(self.taxon_controller) + # ) + + # Select taxon or observation for tagging and switch to Photos tab + self.taxon_controller.taxon_info.on_select.connect(self.image_controller.select_taxon) + self.taxon_controller.taxon_info.on_select.connect( + lambda: self.tabs.setCurrentWidget(self.image_controller) + ) + self.observation_controller.obs_info.on_select.connect( + self.image_controller.select_observation + ) + self.observation_controller.obs_info.on_select.connect( + lambda: self.tabs.setCurrentWidget(self.image_controller) + ) + # Connect file picker <--> recent/favorite dirs self.image_controller.gallery.on_load_images.connect(self.app.user_dirs.add_recent_dirs) self.app.user_dirs.on_dir_open.connect(self.image_controller.gallery.load_file_dialog) diff --git a/naturtag/controllers/taxon_controller.py b/naturtag/controllers/taxon_controller.py index 2a3af12e..202380d1 100644 --- a/naturtag/controllers/taxon_controller.py +++ b/naturtag/controllers/taxon_controller.py @@ -35,7 +35,7 @@ def __init__(self): # Search inputs self.search = TaxonSearch() - self.search.autocomplete.on_select.connect(self.select_taxon) + self.search.autocomplete.on_select.connect(self.display_taxon_by_id) self.search.on_results.connect(self.set_search_results) self.on_select.connect(self.search.set_taxon) self.root.addLayout(self.search) @@ -49,8 +49,8 @@ def __init__(self): # Selected taxon info self.taxon_info = TaxonInfoSection() - self.taxon_info.on_select_id.connect(self.select_taxon) - self.taxon_info.on_select.connect(self.display_taxon) + self.taxon_info.on_view_taxon_by_id.connect(self.display_taxon_by_id) + self.taxon_info.on_view_taxon.connect(self.display_taxon) self.taxonomy = TaxonomySection(self.user_taxa) taxon_layout = VerticalLayout() taxon_layout.addLayout(self.taxon_info) @@ -60,11 +60,12 @@ def __init__(self): # Navigation keyboard shortcuts self.add_shortcut('Ctrl+Left', self.taxon_info.prev) self.add_shortcut('Ctrl+Right', self.taxon_info.next) - self.add_shortcut('Alt+Up', self.taxon_info.select_parent) + self.add_shortcut('Ctrl+Up', self.taxon_info.select_parent) self.add_shortcut('Ctrl+Shift+Enter', self.search.search) self.add_shortcut('Ctrl+Shift+X', self.search.reset) - def select_taxon(self, taxon_id: int): + @Slot(int) + def display_taxon_by_id(self, taxon_id: int): """Load a taxon by ID and update info display. Taxon API request will be sent from a separate thread, return to main thread, and then display info will be loaded from a separate thread. @@ -106,7 +107,7 @@ def set_search_results(self, taxa: list[Taxon]): def bind_selection(self, taxon_cards: Iterable[TaxonInfoCard]): """Connect click signal from each taxon card""" for taxon_card in taxon_cards: - taxon_card.on_click.connect(self.select_taxon) + taxon_card.on_click.connect(self.display_taxon_by_id) class TaxonTabs(QTabWidget): diff --git a/naturtag/controllers/taxon_view.py b/naturtag/controllers/taxon_view.py index 1c2eb057..725cc55a 100644 --- a/naturtag/controllers/taxon_view.py +++ b/naturtag/controllers/taxon_view.py @@ -29,15 +29,20 @@ class TaxonInfoSection(HorizontalLayout): """Section to display selected taxon photo and basic info""" - on_select = Signal(Taxon) #: A taxon object was selected (from nav or another screen) - on_select_id = Signal(int) #: A taxon ID was selected (from 'parent' button) + on_select = Signal(Taxon) #: A taxon was selected for tagging + on_view_observations = Signal(Taxon) #: A taxon was selected for filtering observations + + # When selecting a taxon for viewing, a signal is sent to controller instead of handling here, + # since there are multiple sections to load (not just this class) + on_view_taxon = Signal(Taxon) #: A taxon was selected for viewing (from nav or another screen) + on_view_taxon_by_id = Signal(int) #: A taxon ID was selected for viewing (from 'parent' button) def __init__(self): super().__init__() self.hist_prev: deque[Taxon] = deque() # Viewing history for current session only self.hist_next: deque[Taxon] = deque() # Set when loading from history self.history_taxon: Taxon = None # Set when loading from history, to avoid loop - self.selected_taxon: Taxon = None + self.displayed_taxon: Taxon = None self.group_box = QGroupBox('No taxon selected') root = VerticalLayout(self.group_box) @@ -47,7 +52,7 @@ def __init__(self): self.setAlignment(Qt.AlignTop) # Medium taxon default photo - self.image = TaxonPhoto(hover_icon=True, hover_event=False) # Disabled until 1st load + self.image = TaxonPhoto(hover_icon=True, hover_event=False) # Disabled until first load self.image.setFixedHeight(395) # Height of 5 thumbnails + spacing self.image.setAlignment(Qt.AlignTop) images.addWidget(self.image) @@ -58,34 +63,59 @@ def __init__(self): self.thumbnails.setAlignment(Qt.AlignTop) images.addLayout(self.thumbnails) - # Back and Forward buttons: We already have the full Taxon object - button_layout = HorizontalLayout() + # Button layout + button_layout = VerticalLayout() + button_row_1 = HorizontalLayout() + button_row_2 = HorizontalLayout() + button_layout.addLayout(button_row_1) + button_layout.addLayout(button_row_2) root.addLayout(button_layout) + + # Back and Forward buttons: We already have the full Taxon object self.prev_button = QPushButton('Back') self.prev_button.setIcon(fa_icon('ei.chevron-left')) self.prev_button.clicked.connect(self.prev) self.prev_button.setEnabled(False) - button_layout.addWidget(self.prev_button) + button_row_1.addWidget(self.prev_button) self.next_button = QPushButton('Forward') self.next_button.setIcon(fa_icon('ei.chevron-right')) self.next_button.clicked.connect(self.next) self.next_button.setEnabled(False) - button_layout.addWidget(self.next_button) + button_row_1.addWidget(self.next_button) - # Parent button: We need to fetch the full Taxon object, so just pass the ID + # Parent button: Full Taxon object isn't available, so just pass the ID self.parent_button = QPushButton('Parent') self.parent_button.setIcon(fa_icon('ei.chevron-up')) self.parent_button.clicked.connect(self.select_parent) self.parent_button.setEnabled(False) - button_layout.addWidget(self.parent_button) + button_row_1.addWidget(self.parent_button) + + # Select button: Use taxon for tagging + self.select_button = QPushButton('Select') + self.select_button.setIcon(fa_icon('fa.tag', primary=True)) + self.select_button.clicked.connect(lambda: self.on_select.emit(self.displayed_taxon)) + self.select_button.setEnabled(False) + self.select_button.setToolTip('Select this taxon for tagging') + button_row_2.addWidget(self.select_button) + + # View observations button + # TODO: Observation filters + self.view_observations_button = QPushButton('View Observations') + self.view_observations_button.setIcon(fa_icon('fa5s.binoculars', primary=True)) + self.view_observations_button.clicked.connect( + lambda: self.on_view_observations.emit(self.displayed_taxon.id) + ) + self.view_observations_button.setEnabled(False) + self.select_button.setToolTip('View your observations of this taxon') + button_row_2.addWidget(self.view_observations_button) # Link button: Open web browser to taxon info page self.link_button = QPushButton('View on iNaturalist') self.link_button.setIcon(fa_icon('mdi.web', primary=True)) - self.link_button.clicked.connect(lambda: webbrowser.open(self.selected_taxon.url)) + self.link_button.clicked.connect(lambda: webbrowser.open(self.displayed_taxon.url)) self.link_button.setEnabled(False) - button_layout.addWidget(self.link_button) + button_row_2.addWidget(self.link_button) # Fullscreen image viewer self.image_window = TaxonImageWindow() @@ -93,12 +123,12 @@ def __init__(self): def load(self, taxon: Taxon): """Load default photo + additional thumbnails""" - if self.selected_taxon and taxon.id == self.selected_taxon.id: + if self.displayed_taxon and taxon.id == self.displayed_taxon.id: return # Append to nav history, unless we just loaded a taxon from history - if self.selected_taxon and taxon.id != getattr(self.history_taxon, 'id', None): - self.hist_prev.append(self.selected_taxon) + if self.displayed_taxon and taxon.id != getattr(self.history_taxon, 'id', None): + self.hist_prev.append(self.displayed_taxon) self.hist_next.clear() logger.debug( f'Navigation: {" | ".join([t.name for t in self.hist_prev])} | [{taxon.name}] | ' @@ -107,7 +137,7 @@ def load(self, taxon: Taxon): # Set title and main photo self.history_taxon = None - self.selected_taxon = taxon + self.displayed_taxon = taxon self.group_box.setTitle(taxon.full_name) self.image.hover_event = True self.image.taxon = taxon @@ -117,7 +147,7 @@ def load(self, taxon: Taxon): size='medium', priority=QThread.HighPriority, ) - self._update_nav_buttons() + self._update_buttons() # Load additional thumbnails self.thumbnails.clear() @@ -132,35 +162,33 @@ def prev(self): if not self.hist_prev: return self.history_taxon = self.hist_prev.pop() - self.hist_next.appendleft(self.selected_taxon) - self.on_select.emit(self.history_taxon) + self.hist_next.appendleft(self.displayed_taxon) + self.on_view_taxon.emit(self.history_taxon) def next(self): if not self.hist_next: return self.history_taxon = self.hist_next.popleft() - self.hist_prev.append(self.selected_taxon) - self.on_select.emit(self.history_taxon) - - def select_taxon(self, taxon: Taxon): - self.load(taxon) - self.on_select.emit(taxon) + self.hist_prev.append(self.displayed_taxon) + self.on_view_taxon.emit(self.history_taxon) def select_parent(self): - self.on_select_id.emit(self.selected_taxon.parent_id) + self.on_view_taxon_by_id.emit(self.displayed_taxon.parent_id) - def _update_nav_buttons(self): - """Update status and tooltip for 'back', 'forward', 'parent', and 'view on iNat' buttons""" + def _update_buttons(self): + """Update status and tooltip for nav and selection buttons""" self.prev_button.setEnabled(bool(self.hist_prev)) self.prev_button.setToolTip(self.hist_prev[-1].full_name if self.hist_prev else None) self.next_button.setEnabled(bool(self.hist_next)) self.next_button.setToolTip(self.hist_next[0].full_name if self.hist_next else None) - self.parent_button.setEnabled(bool(self.selected_taxon.parent)) + self.parent_button.setEnabled(bool(self.displayed_taxon.parent)) self.parent_button.setToolTip( - self.selected_taxon.parent.full_name if self.selected_taxon.parent else None + self.displayed_taxon.parent.full_name if self.displayed_taxon.parent else None ) + self.select_button.setEnabled(True) + # self.view_observations_button.setEnabled(True) self.link_button.setEnabled(True) - self.link_button.setToolTip(self.selected_taxon.url) + self.link_button.setToolTip(self.displayed_taxon.url) class TaxonomySection(HorizontalLayout): From f941a213697db1e714d010f9fb7e6231d6e363b1 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Mon, 1 Jul 2024 17:06:38 -0500 Subject: [PATCH 4/5] Photos tab: update to separate selecting vs. browsing; reorganize signals between tabs Fix layout bug (flow_layout added as child layout twice) --- naturtag/app/app.py | 69 ++++++++++++------------ naturtag/controllers/image_controller.py | 32 ++++++----- naturtag/controllers/image_gallery.py | 17 +++--- naturtag/controllers/taxon_controller.py | 1 + 4 files changed, 62 insertions(+), 57 deletions(-) diff --git a/naturtag/app/app.py b/naturtag/app/app.py index c50bb975..25fd8154 100755 --- a/naturtag/app/app.py +++ b/naturtag/app/app.py @@ -86,21 +86,6 @@ def __init__(self, app: NaturtagApp): self.taxon_controller.on_message.connect(self.info) self.observation_controller.on_message.connect(self.info) - # Select observation/taxon from image context menu, ID input fields, and iconic taxa filters - self.image_controller.gallery.on_select_taxon.connect( - self.taxon_controller.display_taxon_by_id - ) - self.image_controller.gallery.on_select_observation.connect( - self.observation_controller.display_observation_by_id - ) - self.image_controller.on_select_observation_id.connect( - self.observation_controller.display_observation_by_id - ) - self.image_controller.on_select_taxon_id.connect(self.taxon_controller.display_taxon_by_id) - self.taxon_controller.search.iconic_taxon_filters.on_select.connect( - self.taxon_controller.display_taxon_by_id - ) - # Settings that take effect immediately self.settings_menu.all_ranks.on_click.connect(self.taxon_controller.search.reset_ranks) self.settings_menu.dark_mode.on_click.connect(set_theme) @@ -126,36 +111,43 @@ def __init__(self, app: NaturtagApp): ) self.tabs.setTabVisible(self.log_tab_idx, self.app.settings.show_logs) - # Switch to different tab if requested from Photos tab - # TODO: this could be simplified a bit: connect both select and switch from controller.gallery - self.image_controller.on_select_taxon_tab.connect( - lambda: self.tabs.setCurrentWidget(self.taxon_controller) - ) - self.image_controller.on_select_observation_tab.connect( - lambda: self.tabs.setCurrentWidget(self.observation_controller) + # Photos tab: view taxon and switch tab + self.image_controller.gallery.on_view_taxon_id.connect( + self.taxon_controller.display_taxon_by_id ) + self.image_controller.gallery.on_view_taxon_id.connect(self.switch_tab_taxa) + self.image_controller.on_view_taxon_id.connect(self.taxon_controller.display_taxon_by_id) + self.image_controller.on_view_taxon_id.connect(self.switch_tab_taxa) - # Display taxon and switch tabs for 'view taxon' button - self.observation_controller.obs_info.on_view_taxon.connect( - lambda taxon: self.taxon_controller.display_taxon(taxon, notify=False) + # Photos tab: view observation and switch tab + self.image_controller.gallery.on_view_observation_id.connect( + self.observation_controller.display_observation_by_id ) - self.observation_controller.obs_info.on_view_taxon.connect( - lambda: self.tabs.setCurrentWidget(self.taxon_controller) + self.image_controller.gallery.on_view_observation_id.connect(self.switch_tab_observations) + self.image_controller.on_view_observation_id.connect( + self.observation_controller.display_observation_by_id ) + self.image_controller.on_view_observation_id.connect(self.switch_tab_observations) - # Display observation and switch tabs for 'view observations' button - # self.observation_controller.obs_info.on_view_taxon.connect( - # lambda taxon: self.taxon_controller.display_taxon(taxon, notify=False) - # ) - # self.observation_controller.obs_info.on_view_taxon.connect( - # lambda: self.tabs.setCurrentWidget(self.taxon_controller) + # Species tab: View observation and switch tab + # self.taxon_controller.taxon_info.on_view_observations.connect( + # lambda obs: self.observation_controller.display_observation(obs, notify=False) # ) + # self.taxon_controller.taxon_info.on_view_observations.connect(self.switch_tab_observations) - # Select taxon or observation for tagging and switch to Photos tab + # Species tab: Select taxon for tagging and switch to Photos tab self.taxon_controller.taxon_info.on_select.connect(self.image_controller.select_taxon) self.taxon_controller.taxon_info.on_select.connect( lambda: self.tabs.setCurrentWidget(self.image_controller) ) + + # Observations tab: View taxon and switch tab + self.observation_controller.obs_info.on_view_taxon.connect( + lambda taxon: self.taxon_controller.display_taxon(taxon, notify=False) + ) + self.observation_controller.obs_info.on_view_taxon.connect(self.switch_tab_taxa) + + # Observations tab: Select observation for tagging and switch to Photos tab self.observation_controller.obs_info.on_select.connect( self.image_controller.select_observation ) @@ -277,6 +269,15 @@ def show_settings(self): """Show the settings menu""" self.settings_menu.show() + def switch_tab_observations(self): + self.tabs.setCurrentWidget(self.observation_controller) + + def switch_tab_taxa(self): + self.tabs.setCurrentWidget(self.taxon_controller) + + def switch_tab_photos(self): + self.tabs.setCurrentWidget(self.image_controller) + def toggle_fullscreen(self) -> bool: """Toggle fullscreen, and change icon for toolbar fullscreen button""" if not self.isFullScreen(): diff --git a/naturtag/controllers/image_controller.py b/naturtag/controllers/image_controller.py index 474807a8..d683f20d 100644 --- a/naturtag/controllers/image_controller.py +++ b/naturtag/controllers/image_controller.py @@ -24,10 +24,8 @@ class ImageController(BaseController): """Controller for selecting and tagging local image files""" on_new_metadata = Signal(MetaMetadata) #: Metadata for an image was updated - on_select_taxon_id = Signal(int) #: A taxon ID was entered - on_select_taxon_tab = Signal() #: Request to switch to taxon tab - on_select_observation_id = Signal(int) #: An observation ID was entered - on_select_observation_tab = Signal() #: Request to switch to observation tab + on_view_taxon_id = Signal() #: Request to switch to taxon tab + on_view_observation_id = Signal() #: Request to switch to observation tab def __init__(self): super().__init__() @@ -53,8 +51,8 @@ def __init__(self): inputs_layout.addWidget(self.input_taxon_id) # Notify other controllers when an ID is selected from input text - self.input_obs_id.on_select.connect(self.on_select_observation_id) - self.input_taxon_id.on_select.connect(self.on_select_taxon_id) + self.input_obs_id.on_select.connect(self.select_observation_by_id) + self.input_taxon_id.on_select.connect(self.select_taxon_by_id) # Selected taxon/observation info group_box = QGroupBox('Metadata source') @@ -70,8 +68,6 @@ def __init__(self): # Image gallery self.gallery = ImageGallery() - self.gallery.on_select_observation.connect(self.on_select_observation_tab) - self.gallery.on_select_taxon.connect(self.on_select_taxon_tab) photo_layout.addWidget(self.gallery) def run(self): @@ -139,28 +135,36 @@ def paste(self): # Check for IDs if an iNat URL was pasted observation_id, taxon_id = get_ids_from_url(text) if observation_id: - self.on_select_observation_id.emit(observation_id) + self.select_observation_by_id(observation_id) elif taxon_id: - self.on_select_taxon_id.emit(taxon_id) + self.select_taxon_by_id(taxon_id) # If not an iNat URL, check for valid image paths else: self.gallery.load_images(text.splitlines()) + # TODO + def select_taxon_by_id(self, taxon_id: int): + pass + + # TODO + def select_observation_by_id(self, observation_id: int): + pass + @Slot(Taxon) def select_taxon(self, taxon: Taxon): - """Update input info from a taxon object (loaded from Species tab)""" + """Update input info from a taxon object""" if self.input_taxon_id.text() == str(taxon.id): return self.input_taxon_id.set_id(taxon.id) self.data_source_card.clear() card = TaxonInfoCard(taxon=taxon, delayed_load=False) - card.on_click.connect(self.on_select_taxon_tab) + card.on_click.connect(self.on_view_taxon_id) self.data_source_card.addWidget(card) @Slot(Observation) def select_observation(self, observation: Observation): - """Update input info from an observation object (loaded from Observations tab)""" + """Update input info from an observation object""" if self.input_obs_id.text() == str(observation.id): return @@ -168,7 +172,7 @@ def select_observation(self, observation: Observation): self.input_taxon_id.set_id(observation.taxon.id) self.data_source_card.clear() card = ObservationInfoCard(obs=observation, delayed_load=False) - card.on_click.connect(self.on_select_observation_tab) + card.on_click.connect(self.on_view_observation_id) self.data_source_card.addWidget(card) def info(self, message: str): diff --git a/naturtag/controllers/image_gallery.py b/naturtag/controllers/image_gallery.py index 936649d8..7a7962fb 100644 --- a/naturtag/controllers/image_gallery.py +++ b/naturtag/controllers/image_gallery.py @@ -49,8 +49,8 @@ class ImageGallery(BaseController): """Container for displaying local image thumbnails & info""" on_load_images = Signal(list) #: New images have been loaded - on_select_taxon = Signal(int) #: A taxon was selected from context menu - on_select_observation = Signal(int) #: An observation was selected from context menu + on_view_taxon_id = Signal(int) #: A taxon was selected from context menu + on_view_observation_id = Signal(int) #: An observation was selected from context menu def __init__(self): super().__init__() @@ -69,7 +69,6 @@ def __init__(self): scroll_area.setWidgetResizable(True) scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) scroll_area.setWidget(self.scroll_panel) - root.addLayout(self.flow_layout) root.addWidget(scroll_area) def clear(self): @@ -128,8 +127,8 @@ def _bind_image_actions(self, thumbnail: 'ThumbnailCard'): thumbnail.on_remove.connect(self.remove_image) thumbnail.on_select.connect(self.select_image) thumbnail.on_copy.connect(self.on_message) - thumbnail.context_menu.on_select_taxon.connect(self.on_select_taxon) - thumbnail.context_menu.on_select_observation.connect(self.on_select_observation) + thumbnail.context_menu.on_view_taxon_id.connect(self.on_view_taxon_id) + thumbnail.context_menu.on_view_observation_id.connect(self.on_view_observation_id) def dragEnterEvent(self, event): event.acceptProposedAction() @@ -314,8 +313,8 @@ def set_pixmap_meta(self, pixmap_meta: tuple[QPixmap, MetaMetadata]): class ThumbnailContextMenu(QMenu): """Context menu for local image thumbnails""" - on_select_taxon = Signal(int) #: A taxon was selected from context menu - on_select_observation = Signal(int) #: An observation was selected from context menu + on_view_taxon_id = Signal(int) #: A taxon was selected from context menu + on_view_observation_id = Signal(int) #: An observation was selected from context menu def refresh_actions(self, thumbnail_card: ThumbnailCard): """Update menu actions based on the available metadata""" @@ -328,7 +327,7 @@ def refresh_actions(self, thumbnail_card: ThumbnailCard): text='View Taxon', tooltip=f'View taxon {meta.taxon_id} in naturtag', enabled=meta.has_taxon, - callback=lambda: self.on_select_taxon.emit(meta.taxon_id), + callback=lambda: self.on_view_taxon_id.emit(meta.taxon_id), ) self._add_action( parent=thumbnail_card, @@ -344,7 +343,7 @@ def refresh_actions(self, thumbnail_card: ThumbnailCard): text='View Observation', tooltip=f'View observation {meta.observation_id} in naturtag', enabled=meta.has_observation, - callback=lambda: self.on_select_observation.emit(meta.observation_id), + callback=lambda: self.on_view_observation_id.emit(meta.observation_id), ) self._add_action( parent=thumbnail_card, diff --git a/naturtag/controllers/taxon_controller.py b/naturtag/controllers/taxon_controller.py index 202380d1..ed355549 100644 --- a/naturtag/controllers/taxon_controller.py +++ b/naturtag/controllers/taxon_controller.py @@ -37,6 +37,7 @@ def __init__(self): self.search = TaxonSearch() self.search.autocomplete.on_select.connect(self.display_taxon_by_id) self.search.on_results.connect(self.set_search_results) + self.search.iconic_taxon_filters.on_select.connect(self.display_taxon_by_id) self.on_select.connect(self.search.set_taxon) self.root.addLayout(self.search) From 47654a25df04ee94cbea86804ebe4ca51722f1c1 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Mon, 1 Jul 2024 19:19:25 -0500 Subject: [PATCH 5/5] Photos tab: rewrite selecting an obs/taxon by ID --- naturtag/controllers/image_controller.py | 73 ++++++++++++------- .../controllers/observation_controller.py | 11 +-- naturtag/controllers/taxon_controller.py | 4 +- naturtag/controllers/taxon_view.py | 2 +- naturtag/widgets/inputs.py | 5 +- 5 files changed, 58 insertions(+), 37 deletions(-) diff --git a/naturtag/controllers/image_controller.py b/naturtag/controllers/image_controller.py index d683f20d..0694201c 100644 --- a/naturtag/controllers/image_controller.py +++ b/naturtag/controllers/image_controller.py @@ -2,10 +2,10 @@ from typing import Optional from pyinaturalist import Observation, Taxon -from PySide6.QtCore import Qt, Signal, Slot +from PySide6.QtCore import Qt, QThread, Signal, Slot from PySide6.QtWidgets import QApplication, QGroupBox, QLabel, QSizePolicy -from naturtag.controllers import BaseController, ImageGallery +from naturtag.controllers import BaseController, ImageGallery, get_app from naturtag.metadata import MetaMetadata, _refresh_tags, tag_images from naturtag.utils import get_ids_from_url from naturtag.widgets import ( @@ -24,8 +24,8 @@ class ImageController(BaseController): """Controller for selecting and tagging local image files""" on_new_metadata = Signal(MetaMetadata) #: Metadata for an image was updated - on_view_taxon_id = Signal() #: Request to switch to taxon tab - on_view_observation_id = Signal() #: Request to switch to observation tab + on_view_taxon_id = Signal(int) #: Request to switch to taxon tab + on_view_observation_id = Signal(int) #: Request to switch to observation tab def __init__(self): super().__init__() @@ -43,16 +43,14 @@ def __init__(self): # Input fields inputs_layout = VerticalLayout(group_box) - self.input_obs_id = IdInput() - inputs_layout.addWidget(QLabel('Observation ID:')) - inputs_layout.addWidget(self.input_obs_id) self.input_taxon_id = IdInput() + self.input_taxon_id.on_select.connect(self.select_taxon_by_id) inputs_layout.addWidget(QLabel('Taxon ID:')) inputs_layout.addWidget(self.input_taxon_id) - - # Notify other controllers when an ID is selected from input text + self.input_obs_id = IdInput() self.input_obs_id.on_select.connect(self.select_observation_by_id) - self.input_taxon_id.on_select.connect(self.select_taxon_by_id) + inputs_layout.addWidget(QLabel('Observation ID:')) + inputs_layout.addWidget(self.input_obs_id) # Selected taxon/observation info group_box = QGroupBox('Metadata source') @@ -61,10 +59,8 @@ def __init__(self): group_box.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Minimum) top_section_layout.addWidget(group_box) self.data_source_card = HorizontalLayout(group_box) - - # Clear info when clearing an input field - self.input_obs_id.on_clear.connect(self.data_source_card.clear) - self.input_taxon_id.on_clear.connect(self.data_source_card.clear) + self.selected_taxon_id: Optional[int] = None + self.selected_observation_id: Optional[int] = None # Image gallery self.gallery = ImageGallery() @@ -77,7 +73,7 @@ def run(self): self.info('Select images to tag') return - obs_id, taxon_id = self.input_obs_id.text(), self.input_taxon_id.text() + obs_id, taxon_id = self.selected_taxon_id, self.selected_observation_id if not (obs_id or taxon_id): self.info('Select either an observation or an organism to tag images with') return @@ -142,22 +138,46 @@ def paste(self): else: self.gallery.load_images(text.splitlines()) - # TODO + # Note: These methods duplicate "display_x_by_id" controller methods, but attempts at code reuse + # added too much spaghetti def select_taxon_by_id(self, taxon_id: int): - pass + """Load a taxon by ID (pasted or directly entered)""" + if self.selected_taxon_id == taxon_id: + return + + app = get_app() + logger.info(f'Loading taxon {taxon_id}') + future = app.threadpool.schedule( + lambda: app.client.taxa(taxon_id, locale=app.settings.locale), + priority=QThread.HighPriority, + ) + future.on_result.connect(self.select_taxon) - # TODO def select_observation_by_id(self, observation_id: int): - pass + """Load an observation by ID (pasted or directly entered)""" + if self.selected_observation_id == observation_id: + return + + app = get_app() + logger.info(f'Loading observation {observation_id}') + future = app.threadpool.schedule( + lambda: app.client.observations(observation_id, taxonomy=True), + priority=QThread.HighPriority, + ) + future.on_result.connect(self.select_observation) @Slot(Taxon) def select_taxon(self, taxon: Taxon): - """Update input info from a taxon object""" - if self.input_taxon_id.text() == str(taxon.id): + """Update metadata info from a taxon object""" + if self.selected_taxon_id == taxon.id: return - self.input_taxon_id.set_id(taxon.id) + self.selected_taxon_id = taxon.id + self.selected_observation_id = None + self.input_obs_id.clear() + self.input_taxon_id.clear() self.data_source_card.clear() + card = TaxonInfoCard(taxon=taxon, delayed_load=False) card.on_click.connect(self.on_view_taxon_id) self.data_source_card.addWidget(card) @@ -165,12 +185,15 @@ def select_taxon(self, taxon: Taxon): @Slot(Observation) def select_observation(self, observation: Observation): """Update input info from an observation object""" - if self.input_obs_id.text() == str(observation.id): + if self.selected_observation_id == observation.id: return - self.input_obs_id.set_id(observation.id) - self.input_taxon_id.set_id(observation.taxon.id) + self.selected_taxon_id = None + self.selected_observation_id = observation.id + self.input_obs_id.clear() + self.input_taxon_id.clear() self.data_source_card.clear() + card = ObservationInfoCard(obs=observation, delayed_load=False) card.on_click.connect(self.on_view_observation_id) self.data_source_card.addWidget(card) diff --git a/naturtag/controllers/observation_controller.py b/naturtag/controllers/observation_controller.py index f4db4caf..607150df 100644 --- a/naturtag/controllers/observation_controller.py +++ b/naturtag/controllers/observation_controller.py @@ -8,18 +8,13 @@ from naturtag.app.style import fa_icon from naturtag.constants import DEFAULT_PAGE_SIZE from naturtag.controllers import BaseController, ObservationInfoSection -from naturtag.widgets import ( - HorizontalLayout, - ObservationInfoCard, - ObservationList, - VerticalLayout, -) +from naturtag.widgets import HorizontalLayout, ObservationInfoCard, ObservationList, VerticalLayout logger = getLogger(__name__) class ObservationController(BaseController): - on_view_taxon = Signal(Taxon) #: A taxon was selected for viewing + on_view_taxon = Signal(Taxon) #: Request to switch to taxon tab def __init__(self): super().__init__() @@ -93,7 +88,7 @@ def display_observation_by_id(self, observation_id: int): if self.displayed_observation and self.displayed_observation.id == observation_id: return - logger.info(f'Viewing observation {observation_id}') + logger.info(f'Loading observation {observation_id}') future = self.app.threadpool.schedule( lambda: self.app.client.observations(observation_id, taxonomy=True), priority=QThread.HighPriority, diff --git a/naturtag/controllers/taxon_controller.py b/naturtag/controllers/taxon_controller.py index ed355549..f97e7925 100644 --- a/naturtag/controllers/taxon_controller.py +++ b/naturtag/controllers/taxon_controller.py @@ -76,7 +76,7 @@ def display_taxon_by_id(self, taxon_id: int): return # Fetch taxon record - logger.info(f'Selecting taxon {taxon_id}') + logger.info(f'Loading taxon {taxon_id}') client = self.app.client if self.tabs._init_complete: self.app.threadpool.cancel() @@ -84,7 +84,7 @@ def display_taxon_by_id(self, taxon_id: int): lambda: client.taxa(taxon_id, locale=self.app.settings.locale), priority=QThread.HighPriority, ) - future.on_result.connect(lambda taxon: self.display_taxon(taxon)) + future.on_result.connect(self.display_taxon) @Slot(Taxon) def display_taxon(self, taxon: Taxon, notify: bool = True): diff --git a/naturtag/controllers/taxon_view.py b/naturtag/controllers/taxon_view.py index 725cc55a..bf890692 100644 --- a/naturtag/controllers/taxon_view.py +++ b/naturtag/controllers/taxon_view.py @@ -30,7 +30,7 @@ class TaxonInfoSection(HorizontalLayout): """Section to display selected taxon photo and basic info""" on_select = Signal(Taxon) #: A taxon was selected for tagging - on_view_observations = Signal(Taxon) #: A taxon was selected for filtering observations + on_view_observations = Signal(Taxon) #: Request to switch to observations tab # When selecting a taxon for viewing, a signal is sent to controller instead of handling here, # since there are multiple sections to load (not just this class) diff --git a/naturtag/widgets/inputs.py b/naturtag/widgets/inputs.py index 94ad6fb9..9f6a97a1 100644 --- a/naturtag/widgets/inputs.py +++ b/naturtag/widgets/inputs.py @@ -31,7 +31,10 @@ def focusOutEvent(self, event: Optional[QEvent] = None): def select(self): if self.text(): - self.on_select.emit(int(self.text())) + self.on_select.emit(self.get_id()) + + def get_id(self) -> int: + return int(self.text()) if self.text() else 0 def set_id(self, id: int): self.setText(str(id))