diff --git a/.gitignore b/.gitignore index 15201ac..8847dcb 100644 --- a/.gitignore +++ b/.gitignore @@ -135,6 +135,7 @@ venv/ ENV/ env.bak/ venv.bak/ +virtualenv/ # Spyder project settings .spyderproject diff --git a/README.md b/README.md new file mode 100644 index 0000000..98660fc --- /dev/null +++ b/README.md @@ -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: +

+ +

+

+ +

+ +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. \ No newline at end of file diff --git a/image.gif b/image.gif new file mode 100644 index 0000000..cdd3f16 Binary files /dev/null and b/image.gif differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..665fd10 --- /dev/null +++ b/main.py @@ -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() diff --git a/result.gif b/result.gif new file mode 100644 index 0000000..19ef462 Binary files /dev/null and b/result.gif differ diff --git a/result_2.gif b/result_2.gif new file mode 100644 index 0000000..e44f72c Binary files /dev/null and b/result_2.gif differ diff --git a/src/image_gallery_widget.py b/src/image_gallery_widget.py new file mode 100644 index 0000000..c9c5416 --- /dev/null +++ b/src/image_gallery_widget.py @@ -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() diff --git a/src/image_model.py b/src/image_model.py new file mode 100644 index 0000000..45f5de5 --- /dev/null +++ b/src/image_model.py @@ -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) diff --git a/src/main_window.py b/src/main_window.py new file mode 100644 index 0000000..0e088c9 --- /dev/null +++ b/src/main_window.py @@ -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) diff --git a/src/thumbnails_list_widget.py b/src/thumbnails_list_widget.py new file mode 100644 index 0000000..4159bdd --- /dev/null +++ b/src/thumbnails_list_widget.py @@ -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)