Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ venv/
ENV/
env.bak/
venv.bak/
virtualenv/

# Spyder project settings
.spyderproject
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
*Project Level 3:* ***Real-World***

*This project is designed for learners who know Python fundamentals and are learning to build real-world programs.*

## Project Description

In this project, we will build a simple image gallery viewer where users can browse through images stored in a folder. The app will allow the user to select a folder from their computer and display the images of that folder:
<p align="center">
<img src="result.gif" />
</p>
<p align="center">
<img src="result_2.gif" />
</p>

This project is useful for building a GUI application using one of the best GUI libraries such as PyQt6, and it introduces users to managing file systems, working with images, and handling GUI events.

## Learning Benefits

- Learn how to create a basic PyQt6 GUI with interactive elements.

- Implement image navigation and display logic.

- Practice handling user input (button clicks, list selections).

## Prerequisites

**Required Libraries**:PyQt6. Install the libraries with: pip install PyQt6

**Required Files**: You need to have a folder with some images.

**IDE**: Use any IDE.
Binary file added image.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import sys
from src.main_window import MainWindow
from PyQt6.QtWidgets import QApplication


def main():
app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())


if __name__ == "__main__":
main()
Binary file added result.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added result_2.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
76 changes: 76 additions & 0 deletions src/image_gallery_widget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import os
from PyQt6.QtCore import Qt
from src.image_model import ImageModel
from PyQt6.QtWidgets import (
QLabel,
QWidget,
QPushButton,
QHBoxLayout,
QVBoxLayout,
)
from PyQt6.QtGui import QPixmap


class ImageGalleryWidget(QWidget):
"""
The central widget displaying the current image,
with Previous/Next buttons. Connects to an ImageModel
to load images and update the display.
"""

def __init__(self, model: ImageModel):
super().__init__()
self._model = model
# Widgets
self._image_label = QLabel("No image loaded.")
self._image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)

self._prev_button = QPushButton("Previous")
self._next_button = QPushButton("Next")
# Layouts
button_layout = QHBoxLayout()
button_layout.addWidget(self._prev_button)
button_layout.addWidget(self._next_button)

main_layout = QVBoxLayout()
main_layout.addWidget(self._image_label, stretch=1)
main_layout.addLayout(button_layout)

self.setLayout(main_layout)

# Button Signals
self._prev_button.clicked.connect(self.show_previous_image)
self._next_button.clicked.connect(self.show_next_image)

def load_current_image(self):
"""
Loads the current image from the model, if any.
"""
path = self._model.get_current_image_path()
if path and os.path.isfile(path):
pixmap = QPixmap(path)

# Optionally scale to label
scale_pixmap = pixmap.scaled(
self._image_label.size(),
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
self._image_label.setPixmap(scale_pixmap)
else:
self._image_label.setText("No image loaded.")

def show_previous_image(self):
self._model.previous_image()
self.load_current_image()

def show_next_image(self):
self._model.next_image()
self.load_current_image()

def resizeEvent(self, event):
"""
Called when the widget is resized (so we can re-scale the image).
"""
super().resizeEvent(event)
self.load_current_image()
48 changes: 48 additions & 0 deletions src/image_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from src.thumbnails_list_widget import ThumbnailListWidget


class ImageModel:
"""
Holds the list of image paths, and the current index.
Manages navigation logic for next/previous images.
"""

def __init__(self):
self._image_paths = []
self._current_index = -1
self._thumbnails_list = None

def set_images(
self,
image_paths: list[str],
thumbnails_list: ThumbnailListWidget,
):
self._image_paths = image_paths
self._current_index = 0 if image_paths else -1
self._thumbnails_list = thumbnails_list

def get_current_image_path(self):
if 0 <= self._current_index < len(self._image_paths):
return self._image_paths[self._current_index]
return None

def next_image(self):
if 0 <= self._current_index < len(self._image_paths) - 1:
self._current_index += 1
if self._thumbnails_list:
self._thumbnails_list.select_index(self._current_index)

def previous_image(self):
"""
Move to the previous image, if possible.
"""
if self._current_index > 0:
self._current_index -= 1
if self._thumbnails_list:
self._thumbnails_list.select_index(self._current_index)

def jump_to_index(self, index):
if 0 <= index < len(self._image_paths):
self._current_index = index
if self._thumbnails_list:
self._thumbnails_list.select_index(self._current_index)
156 changes: 156 additions & 0 deletions src/main_window.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import os
from PyQt6.QtWidgets import (
QMenu,
QWidget,
QMenuBar,
QMainWindow,
QHBoxLayout,
QFileDialog,
QMessageBox,
)

from PyQt6.QtCore import QTimer, Qt
from src.image_model import ImageModel
from PyQt6.QtGui import QKeySequence, QAction
from src.image_gallery_widget import ImageGalleryWidget
from src.thumbnails_list_widget import ThumbnailListWidget


class MainWindow(QMainWindow):
"""
Combines the ImageGalleryWidget (center),
the ThumbnailsListWidget (left), and a member for
opening folders and starting/stopping a slideshow
"""

def __init__(self):
super().__init__()
self.setWindowTitle("Customizable Image Gallery")
self.resize(1200, 800)

# Model
self._model: ImageModel = ImageModel()

# Widgets
self._image_gallery_widget = ImageGalleryWidget(self._model)
self._thumbnails_list = ThumbnailListWidget(self.on_thumbnails_selected)
self._model.set_images([], self._thumbnails_list)
# Timer for Slideshow
self._slideshow_timer = QTimer()
self._slideshow_timer.setInterval(2000) # 2 seconds per image
self._slideshow_timer.timeout.connect(self.handle_slideshow_step)
self._slideshow_running = True

# Layout
central_widget = QWidget()
main_layout = QHBoxLayout()
main_layout.addWidget(self._thumbnails_list, stretch=1)
main_layout.addWidget(self._image_gallery_widget, stretch=3)
central_widget.setLayout(main_layout)
self.setCentralWidget(central_widget)

# Menubar
menubar = self.menuBar() if self.menuBar() else QMenuBar(self)
file_menu = menubar.addMenu("File")
slideshow_menu = menubar.addMenu("Slideshow")

open_folder_action = QAction("Open Folder", self)
open_folder_action.triggered.connect(self.open_folder)
file_menu.addAction(open_folder_action)

start_slideshow_action = QAction("Start Slideshow", self)
start_slideshow_action.triggered.connect(self.start_slideshow)
slideshow_menu.addAction(start_slideshow_action)

stop_slideshow_action = QAction("Stop Slideshow", self)
stop_slideshow_action.triggered.connect(self.stop_slideshow)
slideshow_menu.addAction(stop_slideshow_action)

# Keyboard shortcut (Left/Right arrow keys)
prev_action = QAction("Previous", self)
prev_action.setShortcut(QKeySequence(Qt.Key.Key_Left))
prev_action.triggered.connect(self.show_previous_image)
self.addAction(prev_action)

next_action = QAction("Next", self)
next_action.setShortcut(QKeySequence(Qt.Key.Key_Right))
next_action.triggered.connect(self.show_next_image)
self.addAction(next_action)

def open_folder(self):
"""
Opens a folder dialog and loads images into the model
"""
folder_path = QFileDialog.getExistingDirectory(
self,
"Select Folder",
)
if folder_path:
valid_extensions = {
".png",
".jpg",
".jpeg",
".bmp",
".gif",
}
image_paths = [
os.path.join(folder_path, f)
for f in os.listdir(folder_path)
if os.path.splitext(f.lower())[1] in valid_extensions
]
image_paths.sort()

if not image_paths:
QMessageBox.warning(self, "Warning", "No images found in this folder")
return

self._model.set_images(image_paths, self._thumbnails_list)

# Update UI
self._image_gallery_widget.load_current_image()
self._thumbnails_list.populate(image_paths)
self._thumbnails_list.select_index(self._model._current_index)

def start_slideshow(self):
if self._model._image_paths:
self._slideshow_timer.start()
self._slideshow_running = True

def stop_slideshow(self):
self._slideshow_timer.stop()
self._slideshow_running = False

def handle_slideshow_step(self):
"""
Move to the next image automatically. If we reach the end, wrap around.
"""

if not self._model._image_paths:
return

if self._model._current_index >= len(self._model._image_paths) - 1:
# Wrap to First
self._model._current_index = 0
else:
self._model.next_image()

self.update_display()

def on_thumbnails_selected(self, index):
"""
Called when user selects a thumbnail in the list.
"""
self._model.jump_to_index(index)
self.update_display()

def show_previous_image(self):
self._model.previous_image()
self.update_display()

def show_next_image(self):
self._model.next_image()
self.update_display()

def update_display(self):
self._image_gallery_widget.load_current_image()
self._thumbnails_list.select_index(self._model._current_index)
50 changes: 50 additions & 0 deletions src/thumbnails_list_widget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import os
from PyQt6.QtCore import QSize
from PyQt6.QtWidgets import QListWidget, QListWidgetItem


class ThumbnailListWidget(QListWidget):
"""
Displays a list of image filenames (or actual thumbnails) on the side.
When an item is selected, it calls a callback to let the main app
switch to that image
"""

def __init__(self, on_item_selected=None):
super().__init__()
self._on_item_selected = on_item_selected
self.setIconSize(QSize(60, 60)) # Adjust thumbnail icon size as needed

# Connect the selection signal
self.itemSelectionChanged.connect(self.handle_selection_changed)

def populate(self, image_paths):
"""
Clears the list and re-populates with given image paths.
Here, we add items with either icons or text.
"""
self.clear()
for path in image_paths:
item = QListWidgetItem(os.path.basename(path))
# If you wanna to show a small thumbnail icon:
# pixmap = QPixmax(path)
# icon = QIcon(
# pixmap.scaled(60, 60, Qt.AspectRatioMode.KeepAspectRatio)
# )
# item.setIcon(icon)
self.addItem(item)

def handle_selection_changed(self):
# Use currentIndex() to get the selected item
selected_item = self.currentIndex()
if selected_item.isValid(): # Check if the item is selected
selected_index = selected_item.row() # Get the index of the selected item
if self._on_item_selected:
self._on_item_selected(selected_index)

def select_index(self, index):
"""
Programmatically select an index in the list.
"""
if 0 <= index < self.count():
self.setCurrentRow(index)