From b433e2f0de4606eda796855d981f9f8a67963437 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Wed, 3 Jul 2024 13:56:57 -0500 Subject: [PATCH 1/7] Use tooltips instead of statusbar for toolbar hover text --- naturtag/app/controls.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/naturtag/app/controls.py b/naturtag/app/controls.py index d1d69a81..d9208617 100644 --- a/naturtag/app/controls.py +++ b/naturtag/app/controls.py @@ -122,7 +122,7 @@ def add_button( ) -> QAction: action = QAction(fa_icon(icon), name, self) if tooltip: - action.setStatusTip(tooltip) + action.setToolTip(tooltip) if shortcut: action.setShortcut(QKeySequence(shortcut)) if visible: @@ -198,8 +198,8 @@ def add_favorite_dir(self, image_dir: Path, save: bool = True) -> Optional[QActi return None if save: self.settings.add_favorite_dir(image_dir) + logger.debug(f'Adding favorite: {image_dir}') - logger.debug(f'Adding favorite: {image_dir}') action = self.favorite_dirs_submenu.addAction( fa_icon('mdi.folder-star'), str(image_dir).replace(HOME_DIR, '~'), From 5c35346829de6972bd639b7560f20d20fb0bad7b Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Wed, 3 Jul 2024 13:57:11 -0500 Subject: [PATCH 2/7] Add tooltips to main tabs --- naturtag/app/app.py | 12 +++++++++--- naturtag/storage/app_state.py | 7 +++---- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/naturtag/app/app.py b/naturtag/app/app.py index c4df7dcc..ef305ed7 100755 --- a/naturtag/app/app.py +++ b/naturtag/app/app.py @@ -94,9 +94,14 @@ def __init__(self, app: NaturtagApp): # Tabs self.tabs = QTabWidget() self.tabs.setIconSize(QSize(32, 32)) - self.tabs.addTab(self.image_controller, fa_icon('fa.camera'), 'Photos') - self.tabs.addTab(self.taxon_controller, fa_icon('fa5s.spider'), 'Species') - self.tabs.addTab(self.observation_controller, fa_icon('fa5s.binoculars'), 'Observations') + idx = self.tabs.addTab(self.image_controller, fa_icon('fa.camera'), 'Photos') + self.tabs.setTabToolTip(idx, 'Add and tag local photos') + idx = self.tabs.addTab(self.taxon_controller, fa_icon('fa5s.spider'), 'Species') + self.tabs.setTabToolTip(idx, 'Browse and search taxonomy') + idx = self.tabs.addTab( + self.observation_controller, fa_icon('fa5s.binoculars'), 'Observations' + ) + self.tabs.setTabToolTip(idx, 'Browse your recent observations') # Root layout: tabs + progress bar self.root_widget = QWidget() @@ -110,6 +115,7 @@ def __init__(self, app: NaturtagApp): self.app.log_handler.widget, fa_icon('fa.file-text-o'), 'Logs' ) self.tabs.setTabVisible(self.log_tab_idx, self.app.settings.show_logs) + self.tabs.setTabToolTip(self.log_tab_idx, 'View application logs') # Photos tab: view taxon and switch tab self.image_controller.gallery.on_view_taxon_id.connect( diff --git a/naturtag/storage/app_state.py b/naturtag/storage/app_state.py index 1b7ec599..2bb40d42 100644 --- a/naturtag/storage/app_state.py +++ b/naturtag/storage/app_state.py @@ -27,9 +27,8 @@ @define(auto_attribs=False, slots=False) class AppState: - """Application state container. This includes values that don't need to be - human-readable/editable; so, they don't need to be stored in settings.yml, and are persisted in - SQLite instead. + """Container for persistent application state info. This includes values that don't need to be + human-readable/editable; so, they and are persisted in SQLite instead of `settings.yml`. """ db_path: Path = None # type: ignore @@ -117,7 +116,7 @@ def __str__(self): @classmethod def read(cls, db_path: Path = DB_PATH) -> 'AppState': """Read app state from SQLite database, or return a new instance if no state is found""" - logger.info(f'Reading app state from {db_path}') + logger.debug(f'Reading app state from {db_path}') try: with get_session(db_path) as session: From d32e142392c18d16583972ebf09b7eac33b24b53 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Wed, 3 Jul 2024 14:23:38 -0500 Subject: [PATCH 3/7] Increase statusbar size --- assets/data/style.qss | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/assets/data/style.qss b/assets/data/style.qss index 40e7cd7d..83bf554a 100644 --- a/assets/data/style.qss +++ b/assets/data/style.qss @@ -14,6 +14,12 @@ QGroupBox { QScrollArea { background: palette(midlight); } +QStatusBar { + min-height: 25px; +} +QStatusBar > QLabel { + font-size: 11pt; +} InfoCard { background: palette(base); From 13060bd84292fd99343e7341460e976134652ab9 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Wed, 3 Jul 2024 14:41:32 -0500 Subject: [PATCH 4/7] Add help text for 'Metadata Source' section; swap places with 'Quick Entry' --- naturtag/controllers/image_controller.py | 31 ++++++++++++++++-------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/naturtag/controllers/image_controller.py b/naturtag/controllers/image_controller.py index 0694201c..f0ac8b4b 100644 --- a/naturtag/controllers/image_controller.py +++ b/naturtag/controllers/image_controller.py @@ -15,6 +15,7 @@ TaxonInfoCard, VerticalLayout, ) +from naturtag.widgets.images import FAIcon logger = getLogger(__name__) @@ -34,6 +35,26 @@ def __init__(self): top_section_layout.setAlignment(Qt.AlignLeft) photo_layout.addLayout(top_section_layout) self.on_new_metadata.connect(self.update_metadata) + self.selected_taxon_id: Optional[int] = None + self.selected_observation_id: Optional[int] = None + + # Selected taxon/observation info + group_box = QGroupBox('Metadata source') + group_box.setFixedHeight(150) + group_box.setMinimumWidth(400) + group_box.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Minimum) + top_section_layout.addWidget(group_box) + self.data_source_card = HorizontalLayout(group_box) + self.data_source_card.setAlignment(Qt.AlignLeft) + + # Help text + help_msg = QLabel( + 'Select a source of metadata to tag photos with.\n' + 'Browse the Species or Observations tabs,\n' + 'paste an iNaturalist URL, or enter an ID to the right.' + ) + self.data_source_card.addWidget(FAIcon('ei.info-circle')) + self.data_source_card.addWidget(help_msg) # Input group group_box = QGroupBox('Quick entry') @@ -52,16 +73,6 @@ def __init__(self): inputs_layout.addWidget(QLabel('Observation ID:')) inputs_layout.addWidget(self.input_obs_id) - # Selected taxon/observation info - group_box = QGroupBox('Metadata source') - group_box.setFixedHeight(150) - group_box.setMinimumWidth(400) - group_box.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Minimum) - top_section_layout.addWidget(group_box) - self.data_source_card = HorizontalLayout(group_box) - self.selected_taxon_id: Optional[int] = None - self.selected_observation_id: Optional[int] = None - # Image gallery self.gallery = ImageGallery() photo_layout.addWidget(self.gallery) From 375a6e9182d124905c6aaaaed6bc3f009c86027d Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Wed, 3 Jul 2024 15:04:29 -0500 Subject: [PATCH 5/7] Add help text for image gallery --- naturtag/controllers/image_gallery.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/naturtag/controllers/image_gallery.py b/naturtag/controllers/image_gallery.py index 7a7962fb..2e0a8f49 100644 --- a/naturtag/controllers/image_gallery.py +++ b/naturtag/controllers/image_gallery.py @@ -71,6 +71,20 @@ def __init__(self): scroll_area.setWidget(self.scroll_panel) root.addWidget(scroll_area) + # Help text + help = QWidget() + help.setContentsMargins(5, 10, 5, 10) + help_layout = HorizontalLayout(help) + help_layout.setAlignment(Qt.AlignLeft) + self.flow_layout.addWidget(help) + help_msg = QLabel( + 'Select local photos to tag.\n' + 'Drag and drop files onto the window,\n' + 'Or use the file browser (Ctrl+O).' + ) + help_layout.addWidget(FAIcon('ei.info-circle')) + help_layout.addWidget(help_msg) + def clear(self): """Clear all images from the viewer""" self.images = {} @@ -112,6 +126,10 @@ def load_image(self, image_path: Path, delayed_load: bool = False) -> Optional[' logger.debug(f'Image already loaded: {image_path}') return None + # Clear initial help text if still present + if not self.images: + self.flow_layout.clear() + logger.info(f'Loading {image_path}') thumbnail_card = ThumbnailCard(image_path) thumbnail_card.on_loaded.connect(self._bind_image_actions) From 2d6577753a4bcd2d9b76afcfe1cad8080533ea9f Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Wed, 3 Jul 2024 17:49:00 -0500 Subject: [PATCH 6/7] Fix issue with taxon aggregate values (total obs + leaf taxa) sometimes missing from taxon ancestor/child cards --- naturtag/storage/client.py | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/naturtag/storage/client.py b/naturtag/storage/client.py index 4c8fa5b5..1e4ca1a1 100644 --- a/naturtag/storage/client.py +++ b/naturtag/storage/client.py @@ -90,7 +90,7 @@ def from_ids( # Add full taxonomy to observations, if specified if taxonomy: - self.taxon_controller._get_taxonomy([obs.taxon for obs in observations]) + self.taxon_controller._add_taxonomy([obs.taxon for obs in observations]) logger.debug(f'Finished in {time()-start:.2f} seconds') return WrapperPaginator(observations) @@ -178,6 +178,7 @@ def from_ids( api_results = super().from_ids(remaining_ids, **params).all() if remaining_ids else [] taxa.extend(api_results) save_taxa(api_results, self.client.db_path) + api_results = self._add_db_taxonomy(api_results) logger.debug(f'Finished in {time()-start:.2f} seconds') return WrapperPaginator(taxa) @@ -187,13 +188,13 @@ def _get_db_taxa(self, taxon_ids: list[int], accept_partial: bool = False): get_db_taxa(self.client.db_path, ids=taxon_ids, accept_partial=accept_partial) ) if not accept_partial: - db_results = self._get_taxonomy(db_results) + db_results = self._add_taxonomy(db_results) return db_results - def _get_taxonomy(self, taxa: list[Taxon]) -> list[Taxon]: + def _add_taxonomy(self, taxa: list[Taxon]) -> list[Taxon]: """Add ancestor and descendant records to all the specified taxa. - DB records only contain ancestor/child IDs, so we need another query to fetch full records + DB records only contain ancestor/child IDs, so we need another query to fetch full records. This could be done in SQL, but a many-to-many relationship with ancestors would get messy. Besides, some may be missing and need to be fetched from the API. """ @@ -211,6 +212,28 @@ def _get_taxonomy(self, taxa: list[Taxon]) -> list[Taxon]: taxon.children = [extended_taxa[id] for id in taxon.child_ids] return taxa + def _add_db_taxonomy(self, taxa: list[Taxon]) -> list[Taxon]: + """Given new taxon records from the API, replace partial ancestors and children with any + that are stored locally. This is mainly for displaying aggregate values in the taxonomy + browser. + """ + api_taxa = chain.from_iterable([t.ancestors + t.children for t in taxa]) + partial_taxa = {t.id: t for t in api_taxa} + full_taxa = { + t.id: t + for t in get_db_taxa( + self.client.db_path, ids=list(partial_taxa.keys()), accept_partial=True + ) + } + for taxon in taxa: + taxon.ancestors = [ + full_taxa.get(id) or partial_taxa[id] + for id in taxon.ancestor_ids + if id not in [ROOT_TAXON_ID, taxon.id] + ] + taxon.children = [full_taxa.get(id) or partial_taxa[id] for id in taxon.child_ids] + return taxa + # TODO: Save one page at a time def search(self, **params) -> WrapperPaginator[Taxon]: """Search taxa, and save results to the database (for future reference by ID)""" From b24d3d155897031ac7d5aa6c17322b0c62cc67c6 Mon Sep 17 00:00:00 2001 From: Jordan Cook Date: Wed, 3 Jul 2024 14:25:06 -0500 Subject: [PATCH 7/7] Add some more status messages --- naturtag/app/app.py | 7 +++++-- naturtag/controllers/base_controller.py | 3 +++ naturtag/controllers/image_controller.py | 5 +---- naturtag/controllers/observation_controller.py | 3 ++- naturtag/controllers/taxon_controller.py | 5 +++-- naturtag/storage/client.py | 5 ++++- 6 files changed, 18 insertions(+), 10 deletions(-) diff --git a/naturtag/app/app.py b/naturtag/app/app.py index ef305ed7..ef38c774 100755 --- a/naturtag/app/app.py +++ b/naturtag/app/app.py @@ -185,6 +185,9 @@ def __init__(self, app: NaturtagApp): self.addToolBar(self.toolbar) self.statusbar = QStatusBar(self) self.setStatusBar(self.statusbar) + # self.status_widget = QLabel('This is a status widget') + # self.statusbar.addWidget(self.status_widget) + # self.status_widget.setAttribute(Qt.WA_TransparentForMouseEvents) # Load any valid image paths provided on command line (or from drag & drop) self.image_controller.gallery.load_images( @@ -218,9 +221,9 @@ def closeEvent(self, _): self.app.settings.write() self.app.state.write() - def info(self, message: str): + def info(self, message: str, timeout: int = 3000): """Show a message both in the status bar and in the logs""" - self.statusbar.showMessage(message) + self.statusbar.showMessage(message, timeout) logger.info(message) def mousePressEvent(self, event): diff --git a/naturtag/controllers/base_controller.py b/naturtag/controllers/base_controller.py index 7090707f..21efd55e 100644 --- a/naturtag/controllers/base_controller.py +++ b/naturtag/controllers/base_controller.py @@ -17,3 +17,6 @@ class BaseController(StylableWidget): @property def app(self) -> 'NaturtagApp': return QApplication.instance() + + def info(self, message: str): + self.on_message.emit(message) diff --git a/naturtag/controllers/image_controller.py b/naturtag/controllers/image_controller.py index f0ac8b4b..3c1ceed9 100644 --- a/naturtag/controllers/image_controller.py +++ b/naturtag/controllers/image_controller.py @@ -119,12 +119,12 @@ def refresh(self): self.info('Select images to tag') return + self.info(f'Refreshing tags for {len(images)} images') for image in images: future = self.app.threadpool.schedule( lambda: _refresh_tags(image.metadata, self.app.client, self.app.settings), ) future.on_result.connect(self.update_metadata) - self.info(f'{len(images)} images updated') def clear(self): """Clear all images and input""" @@ -208,6 +208,3 @@ def select_observation(self, observation: Observation): card = ObservationInfoCard(obs=observation, delayed_load=False) card.on_click.connect(self.on_view_observation_id) self.data_source_card.addWidget(card) - - def info(self, message: str): - self.on_message.emit(message) diff --git a/naturtag/controllers/observation_controller.py b/naturtag/controllers/observation_controller.py index 9f0df5ce..77c91bee 100644 --- a/naturtag/controllers/observation_controller.py +++ b/naturtag/controllers/observation_controller.py @@ -101,7 +101,7 @@ def load_user_observations(self): logger.info('Unknown user; skipping observation load') return - logger.info('Fetching user observations') + self.info('Fetching user observations') future = self.app.threadpool.schedule( self.get_user_observations, priority=QThread.LowPriority ) @@ -139,6 +139,7 @@ def display_user_observations(self, observations: list[Observation]): self.update_pagination_buttons() if self.total_results: self.user_obs_group_box.set_title(f'My Observations ({self.total_results})') + self.info('') def bind_selection(self, obs_cards: Iterable[ObservationInfoCard]): """Connect click signal from each observation card""" diff --git a/naturtag/controllers/taxon_controller.py b/naturtag/controllers/taxon_controller.py index 03dcfc2b..e64c19db 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'Loading taxon {taxon_id}') + self.info(f'Loading taxon {taxon_id}') client = self.app.client if self.tabs._init_complete: self.app.threadpool.cancel() @@ -96,6 +96,7 @@ def display_taxon(self, taxon: Taxon, notify: bool = True): self.bind_selection(self.taxonomy.ancestors_list.cards) self.bind_selection(self.taxonomy.children_list.cards) logger.debug(f'Loaded taxon {taxon.id}') + self.info('') def set_search_results(self, taxa: list[Taxon]): """Load search results into Results tab""" @@ -163,7 +164,7 @@ def add_tab(self, taxon_list: TaxonList, icon_str: str, label: str, tooltip: str return taxon_list def load_user_taxa(self): - logger.info('Fetching user-observed taxa') + self.info('Fetching user-observed taxa') app = get_app() client = app.client display_ids = self.user_taxa.display_ids diff --git a/naturtag/storage/client.py b/naturtag/storage/client.py index 1e4ca1a1..848149e7 100644 --- a/naturtag/storage/client.py +++ b/naturtag/storage/client.py @@ -126,7 +126,10 @@ def get_user_observations( updated_since=updated_since, refresh=True, ).all() - logger.debug(f'{len(new_observations)} new observations found') + if new_observations: + logger.info(f'{len(new_observations)} new observations found since {updated_since}') + else: + logger.info(f'No new observations found since {updated_since}') if not limit: return []