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)