Skip to content

Commit

Permalink
Add pagination controls for user observations (#342)
Browse files Browse the repository at this point in the history
Closes #254

Main changes:
* pagination controls
* pagination keyboard shortcuts
* refresh button
  • Loading branch information
JWCook committed Nov 15, 2023
2 parents 78fbaef + eacf1f9 commit 97167f7
Show file tree
Hide file tree
Showing 11 changed files with 167 additions and 51 deletions.
19 changes: 12 additions & 7 deletions docs/app.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,27 +128,32 @@ To add a directory to favorites select **Add a favorite** from the favorites sub
To remove a directory from favorites, Ctrl-click the directory from the favorites submenu.

## Keyboard Shortcuts
Some keyboard shortcuts are included for convenience:
Some keyboard shortcuts are included for convenience.
These are listed below, along with the tab or screen they primarily apply to:

Key(s) | Action | Tab/Screen
------ | ------ | ----------
`Ctrl+O` | Open file browser | Photos
`Ctrl+V` | Paste photos or iNat URLs | Photos
`Ctrl+R` | Run image tagger | Photos
`Ctrl+Shift+R` | Run image tag refresh | Photos
`Ctrl+X` | Clear selected images | Photos
`F5` | Refresh photo metadata | Photos
| |
`Ctrl+Enter` | Run search | Species
`Ctrl+Enter` | Run search | Species
`Ctrl+Shift+X` | Clear search fields | Species
`Alt+Left` | View previous taxon | Species
`Alt+Right` | View next taxon | Species
`Ctrl+Left` | View previous taxon | Species
`Ctrl+Right` | View next taxon | Species
`Alt+Up` | View parent taxon | Species
⠀ | |
| |
`F5` | Refresh observations | Observations
`Ctrl+Left` | View previous page | Observations
`Ctrl+Right` | View next page | Observations
| |
`Left` | View previous image | Fullscreen image (local photo or taxon)
`Right` | View next image | Fullscreen image (local photo or taxon)
`Escape` | Exit fullscreen view | Fullscreen image (local photo or taxon)
`Del` | Remove image from selection | Fullscreen image (local photo)
| |
| |
`Ctrl+Tab` | Cycle through tabs | All
`Ctrl+Shift+F` | Add a directory to favorites| All
`Ctrl+Shift+T` | Toggle toolbar visibility | All
Expand Down
3 changes: 2 additions & 1 deletion naturtag/app/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,11 @@ def __init__(self, settings: Settings):
# Toolbar actions
self.toolbar = Toolbar(self, self.user_dirs)
self.toolbar.run_button.triggered.connect(self.image_controller.run)
self.toolbar.refresh_tags_button.triggered.connect(self.image_controller.refresh)
self.toolbar.open_button.triggered.connect(self.image_controller.gallery.load_file_dialog)
self.toolbar.paste_button.triggered.connect(self.image_controller.paste)
self.toolbar.clear_button.triggered.connect(self.image_controller.clear)
self.toolbar.refresh_button.triggered.connect(self.image_controller.refresh)
self.toolbar.refresh_obs_button.triggered.connect(self.observation_controller.refresh)
self.toolbar.fullscreen_button.triggered.connect(self.toggle_fullscreen)
self.toolbar.reset_db_button.triggered.connect(self.reset_db)
self.toolbar.settings_button.triggered.connect(self.show_settings)
Expand Down
16 changes: 12 additions & 4 deletions naturtag/app/controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ def __init__(self, parent: QWidget, user_dirs: 'UserDirs'):
self.run_button = self.add_button(
'&Run', tooltip='Apply tags to images', icon='fa.play', shortcut='Ctrl+R'
)
self.refresh_tags_button = self.add_button(
'Refresh &tags',
tooltip='Refresh previously tagged images with latest observation/taxon data',
icon='msc.debug-rerun',
shortcut='Ctrl+Shift+R',
)
self.addSeparator()
self.open_button = self.add_button(
'&Open', tooltip='Open images', icon='ri.image-add-fill', shortcut='Ctrl+O'
Expand All @@ -46,9 +52,10 @@ def __init__(self, parent: QWidget, user_dirs: 'UserDirs'):
self.clear_button = self.add_button(
'&Clear', tooltip='Clear open images', icon='fa.remove', shortcut='Ctrl+X'
)
self.refresh_button = self.add_button(
'&Refresh tags',
tooltip='Refresh previously tagged images with latest observation/taxon data',
self.addSeparator()
self.refresh_obs_button = self.add_button(
'&Refresh observations',
tooltip='Refresh local observations with latest data from iNaturalist',
icon='fa.refresh',
shortcut='F5',
)
Expand Down Expand Up @@ -126,14 +133,15 @@ def populate_menu(self, menu: QMenu):
"""Populate the application menu using actions defined on the toolbar"""
file_menu = menu.addMenu('&File')
file_menu.addAction(self.run_button)
file_menu.addAction(self.refresh_tags_button)
file_menu.addAction(self.open_button)

file_menu.addMenu(self.user_dirs.favorite_dirs_submenu)
file_menu.addMenu(self.user_dirs.recent_dirs_submenu)

file_menu.addAction(self.refresh_obs_button)
file_menu.addAction(self.paste_button)
file_menu.addAction(self.clear_button)
file_menu.addAction(self.refresh_button)
file_menu.addAction(self.exit_button)

view_menu = menu.addMenu('&View')
Expand Down
49 changes: 37 additions & 12 deletions naturtag/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from pyinaturalist_convert.db import get_db_observations, get_db_taxa, save_observations, save_taxa
from requests_cache import SQLiteDict

from naturtag.constants import DB_PATH, IMAGE_CACHE, ROOT_TAXON_ID, PathOrStr
from naturtag.constants import DB_PATH, DEFAULT_PAGE_SIZE, IMAGE_CACHE, ROOT_TAXON_ID, PathOrStr

if TYPE_CHECKING:
from PySide6.QtGui import QPixmap
Expand Down Expand Up @@ -65,30 +65,55 @@ def from_ids(
logger.debug(f'Finished in {time()-start:.2f} seconds')
return WrapperPaginator(observations)

def count(self, username: str, **params) -> int:
"""Get the total number of observations matching the specified criteria"""
return super().search(user_login=username, refresh=True, **params).count()

# TODO: Save one page at a time
def search(self, **params) -> WrapperPaginator[Observation]:
"""Search observations, and save results to the database (for future reference by ID)"""
results = super().search(**params).all()
save_observations(results, self.client.db_path)
return WrapperPaginator(results)

def get_user_observations(
self, username: str, updated_since: Optional[datetime] = None, limit: int = 50
self,
username: str,
updated_since: Optional[datetime] = None,
limit: int = DEFAULT_PAGE_SIZE,
page: int = 1,
) -> list[Observation]:
# Fetch and save any new observations
new_observations = self.search(
user_login=username,
updated_since=updated_since,
refresh=True,
).all()
"""Fetch any new observations from the API since last search, save them to the db, and then
return up to `limit` most recent observations from the db
"""
# TODO: Initial load should be done in a separate thread
logger.debug(f'Fetching new user observations since {updated_since}')
new_observations = []
if page == 1:
new_observations = self.search(
user_login=username,
updated_since=updated_since,
refresh=True,
).all()
logger.debug(f'{len(new_observations)} new observations found')

if not limit:
return []

# If there are enough new results to fill first page, return them directly
if len(new_observations) >= limit:
new_observations = sorted(
new_observations, key=lambda obs: obs.created_at, reverse=True
)
return new_observations[:limit]

# Get up to `limit` most recent saved observations
# Otherwise get up to `limit` most recent saved observations from the db
obs = get_db_observations(
self.client.db_path, username=username, limit=limit, order_by_date=True
self.client.db_path,
username=username,
limit=limit,
page=page,
order_by_created=True,
)
return list(obs)

Expand Down Expand Up @@ -147,7 +172,7 @@ def _get_taxonomy(self, taxa: list[Taxon]) -> list[Taxon]:
taxon.children = [extended_taxa[id] for id in taxon.child_ids]
return taxa

# TODO: Don't use all
# 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)"""
results = super().search(**params).all()
Expand Down Expand Up @@ -217,6 +242,6 @@ def get_url_hash(url: str) -> str:
return f'{thumbnail_hash}.{ext}'


# TODO: Refactoring to not depend on global session and client objects
# TODO: Refactor to depend on app.client and session objects instead of these module-level globals
INAT_CLIENT = iNatDbClient()
IMG_SESSION = ImageSession()
4 changes: 4 additions & 0 deletions naturtag/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
SIZE_DEFAULT = (250, 250)
SIZE_LG = (500, 500)

# TODO: Make this user-configurable
DEFAULT_PAGE_SIZE = 50

# Relevant groups of image metadata tags
EXIF_HIDE_PREFIXES = [
'Exif.Image.PrintImageMatching',
Expand Down Expand Up @@ -106,3 +109,4 @@
IntTuple = tuple[Optional[int], Optional[int]]
StrTuple = tuple[str, str]
PathOrStr = Union[Path, str]
IconDimensions = Union[int, tuple[int, int]]
6 changes: 3 additions & 3 deletions naturtag/controllers/image_gallery.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,10 @@ def load_images(self, image_paths: Iterable[PathOrStr]):
def load_image(self, image_path: Path, delayed_load: bool = False) -> Optional['ThumbnailCard']:
"""Load an image"""
if not image_path.is_file():
logger.info(f'File does not exist: {image_path}')
logger.debug(f'File does not exist: {image_path}')
return None
elif image_path in self.images:
logger.info(f'Image already loaded: {image_path}')
logger.debug(f'Image already loaded: {image_path}')
return None

logger.info(f'Loading {image_path}')
Expand Down Expand Up @@ -191,7 +191,7 @@ def __init__(self, image_path: Path, size: Dimensions = SIZE_DEFAULT):
layout.addWidget(self.label)

# Icon shown when an image is tagged or updated
self.check = FAIcon('fa5s.check', self.image, secondary=True, size=SIZE_DEFAULT[0])
self.check = FAIcon('fa5s.check', self.image, secondary=True, size=SIZE_DEFAULT)
self.check.setVisible(False)

def load_image(self):
Expand Down
80 changes: 72 additions & 8 deletions naturtag/controllers/observation_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@

from pyinaturalist import Observation
from PySide6.QtCore import Qt, QThread, QTimer, Signal, Slot
from PySide6.QtWidgets import QLabel, QPushButton

from naturtag.app.style import fa_icon
from naturtag.client import INAT_CLIENT
from naturtag.constants import DEFAULT_PAGE_SIZE
from naturtag.controllers import BaseController, ObservationInfoSection
from naturtag.widgets import HorizontalLayout, ObservationInfoCard, ObservationList, VerticalLayout

Expand All @@ -27,6 +30,12 @@ def __init__(self, *args, **kwargs):
# self.on_select.connect(self.search.set_taxon)
# self.root.addLayout(self.search)

# Pagination
self.page = 1
self.total_pages = 0
# TODO: Cache pages while navigating back and forth?
# self.pages: dict[int, list[ObservationInfoCard]] = {}

# User observations
self.user_observations = ObservationList(self.threadpool)
user_obs_group_box = self.add_group(
Expand All @@ -38,17 +47,41 @@ def __init__(self, *args, **kwargs):
)
user_obs_group_box.addWidget(self.user_observations.scroller)

# Pagination buttons + label
button_layout = HorizontalLayout()
user_obs_group_box.addLayout(button_layout)
self.prev_button = QPushButton('Prev')
self.prev_button.setIcon(fa_icon('ei.chevron-left'))
self.prev_button.clicked.connect(self.prev_page)
self.prev_button.setEnabled(False)
button_layout.addWidget(self.prev_button)

self.page_label = QLabel('Page 1 / ?')
self.page_label.setAlignment(Qt.AlignCenter)
button_layout.addWidget(self.page_label)

self.next_button = QPushButton('Next')
self.next_button.setIcon(fa_icon('ei.chevron-right'))
self.next_button.clicked.connect(self.next_page)
self.next_button.setEnabled(False)
button_layout.addWidget(self.next_button)

# Selected observation info
self.obs_info = ObservationInfoSection(self.threadpool)
self.obs_info.on_select.connect(self.display_observation)
obs_layout = VerticalLayout()
obs_layout.addLayout(self.obs_info)
self.root.addLayout(obs_layout)

# Navigation keyboard shortcuts
self.add_shortcut('Ctrl+Left', self.prev_page)
self.add_shortcut('Ctrl+Right', self.next_page)

# Add a delay before loading user observations on startup
QTimer.singleShot(1, self.load_user_observations)

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:
return
Expand All @@ -67,27 +100,58 @@ def display_observation(self, observation: Observation):
self.obs_info.load(observation)
logger.debug(f'Loaded observation {observation.id}')

@Slot(list)
def display_user_observations(self, observations: list[Observation]):
# Update observation list
self.user_observations.set_observations(observations)
self.bind_selection(self.user_observations.cards)

# Update pagination buttons based on current page
self.prev_button.setEnabled(self.page > 1)
self.next_button.setEnabled(self.page < self.total_pages)
self.page_label.setText(f'Page {self.page} / {self.total_pages}')

def load_user_observations(self):
logger.info('Fetching user observations')
future = self.threadpool.schedule(self.get_user_observations, priority=QThread.LowPriority)
future.on_result.connect(self.display_user_observations)

# TODO: Paginate results
# TODO: Handle casual_observations setting
# TODO: Store a Paginator object instead of page number?
def get_user_observations(self) -> list[Observation]:
if not self.settings.username:
return []

updated_since = self.settings.last_obs_check
self.settings.set_obs_checkpoint()

# TODO: Depending on order of operations, this could be counted from the db instead of API.
# Maybe do that except on initial observation load?
total_results = INAT_CLIENT.observations.count(username=self.settings.username)
self.total_pages = (total_results // DEFAULT_PAGE_SIZE) + 1
logger.debug('Total user observations: %s (%s pages)', total_results, self.total_pages)

observations = INAT_CLIENT.observations.get_user_observations(
username=self.settings.username,
updated_since=self.settings.last_obs_check,
limit=50,
updated_since=updated_since,
limit=DEFAULT_PAGE_SIZE,
page=self.page,
)
self.settings.set_obs_checkpoint()
return observations

@Slot(list)
def display_user_observations(self, observations: list[Observation]):
self.user_observations.set_observations(observations)
self.bind_selection(self.user_observations.cards)
def next_page(self):
if self.page < self.total_pages:
self.page += 1
self.load_user_observations()

def prev_page(self):
if self.page > 1:
self.page -= 1
self.load_user_observations()

def refresh(self):
self.page = 1
self.load_user_observations()

def bind_selection(self, obs_cards: Iterable[ObservationInfoCard]):
"""Connect click signal from each observation card"""
Expand Down
4 changes: 2 additions & 2 deletions naturtag/controllers/taxon_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ def __init__(self, *args, **kwargs):
self.root.addLayout(taxon_layout)

# Navigation keyboard shortcuts
self.add_shortcut('Alt+Left', self.taxon_info.prev)
self.add_shortcut('Alt+Right', self.taxon_info.next)
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+Shift+Enter', self.search.search)
self.add_shortcut('Ctrl+Shift+X', self.search.reset)
Expand Down
2 changes: 1 addition & 1 deletion naturtag/controllers/taxon_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ def __init__(self, threadpool: ThreadPool, user_taxa: UserTaxa):

def load(self, taxon: Taxon):
"""Populate taxon ancestors and children"""
logger.info(f'Loading {len(taxon.ancestors)} ancestors and {len(taxon.children)} children')
logger.debug(f'Loading {len(taxon.ancestors)} ancestors and {len(taxon.children)} children')

def get_label(text: str, items: list) -> str:
return text + (f' ({len(items)})' if items else '')
Expand Down
Loading

0 comments on commit 97167f7

Please sign in to comment.