"""Welcome Screen Module
=====================
Initial welcome screen with project management, recent projects, and quick actions.
:author: Dipl.-Ing. (FH) Jonas Pfeiffer
"""
import os
import sys
import json
import webbrowser
from pathlib import Path
from datetime import datetime
from typing import List, Optional, Dict
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
QPushButton, QLabel, QFrame, QScrollArea,
QFileDialog, QMessageBox, QApplication, QCheckBox)
from PyQt6.QtCore import Qt, pyqtSignal, QSize, QPropertyAnimation, QRect, QEasingCurve
from PyQt6.QtGui import QFont, QPixmap, QIcon, QPainter, QColor, QPen
[docs]
class ThemeToggleSwitch(QCheckBox):
"""
Modern toggle switch for theme switching with animated handle.
.. note::
Renders custom painted toggle with sun/moon icons for light/dark theme indication.
"""
[docs]
def __init__(self, parent=None):
"""
Initialize toggle switch with custom styling and animation.
:param parent: Parent widget, defaults to None
:type parent: QWidget, optional
"""
super().__init__(parent)
self.setFixedSize(60, 20)
self.setStyleSheet("""
QCheckBox {
background: transparent;
spacing: 0px;
}
QCheckBox::indicator {
width: 0px;
height: 0px;
}
""")
# Animation for smooth toggle
self.animation = QPropertyAnimation(self, b"geometry")
self.animation.setDuration(200)
self.animation.setEasingCurve(QEasingCurve.Type.InOutQuad)
[docs]
def mousePressEvent(self, event):
"""
Handle mouse press events to toggle the checkbox state.
:param event: Mouse event
:type event: QMouseEvent
"""
if event.button() == Qt.MouseButton.LeftButton:
self.setChecked(not self.isChecked())
super().mousePressEvent(event)
[docs]
def paintEvent(self, event):
"""
Custom paint event for rendering the toggle switch with animated handle.
:param event: Paint event
:type event: QPaintEvent
.. note::
Draws track background, circular handle, and sun/moon icons based on theme state.
"""
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
# Track (background)
track_color = QColor("#4a90e2" if self.isChecked() else "#ccc")
painter.setBrush(track_color)
painter.setPen(Qt.PenStyle.NoPen)
track_rect = QRect(0, 0, self.width(), self.height())
painter.drawRoundedRect(track_rect, self.height()//2, self.height()//2)
# Handle (circle)
handle_radius = self.height() - 4
handle_x = self.width() - handle_radius - 2 if self.isChecked() else 2
handle_y = 2
painter.setBrush(QColor("white"))
painter.drawEllipse(handle_x, handle_y, handle_radius, handle_radius)
# Icons
icon_size = 12
icon_y = (self.height() - icon_size) // 2
# Sun icon (left side) - visible when light mode (unchecked)
if not self.isChecked():
painter.setPen(QPen(QColor("#666"), 2))
sun_x = 8
sun_center_x = sun_x + icon_size // 2
sun_center_y = icon_y + icon_size // 2
painter.drawEllipse(sun_center_x - 3, sun_center_y - 3, 6, 6)
# Sun rays
painter.drawLine(sun_center_x, sun_center_y - 8, sun_center_x, sun_center_y - 6)
painter.drawLine(sun_center_x, sun_center_y + 6, sun_center_x, sun_center_y + 8)
painter.drawLine(sun_center_x - 8, sun_center_y, sun_center_x - 6, sun_center_y)
painter.drawLine(sun_center_x + 6, sun_center_y, sun_center_x + 8, sun_center_y)
# Moon icon (right side) - visible when dark mode (checked)
else:
painter.setPen(QPen(QColor("white"), 2))
painter.setBrush(QColor("white"))
moon_x = self.width() - 20
moon_center_x = moon_x + icon_size // 2
moon_center_y = icon_y + icon_size // 2
painter.drawEllipse(moon_center_x - 4, moon_center_y - 4, 8, 8)
painter.setBrush(track_color)
painter.drawEllipse(moon_center_x - 1, moon_center_y - 4, 6, 6)
[docs]
class WelcomeScreen(QWidget):
"""
Welcome screen widget providing project management and quick actions.
:signal projectSelected: Emitted when user selects a project (str: path)
:signal newProjectRequested: Emitted when user wants to create new project
:signal themeChangeRequested: Emitted when user changes theme (str: theme_key)
.. note::
Features recent projects display, quick action buttons, documentation links, and clean modern interface.
"""
projectSelected = pyqtSignal(str) # Signal when user selects a project
newProjectRequested = pyqtSignal() # Signal when user wants to create new project
themeChangeRequested = pyqtSignal(str) # Signal when user changes theme (light/dark)
[docs]
def __init__(self, config_manager=None):
"""
Initialize welcome screen with config manager for recent projects.
:param config_manager: Configuration manager instance, defaults to None
:type config_manager: ConfigManager, optional
"""
super().__init__()
self.config_manager = config_manager # Use the real config manager for recent projects
self.recent_projects = []
self.setup_ui()
self.load_recent_projects()
[docs]
def setup_ui(self):
"""
Setup the main welcome screen UI with header, content sections, and footer.
.. note::
Creates three-section layout: header with title/theme toggle, content with recent projects and quick actions, funding footer.
"""
self.setWindowTitle("DistrictHeatingSim - Welcome")
self.setMinimumSize(1000, 700)
# Main layout
main_layout = QVBoxLayout()
main_layout.setContentsMargins(50, 40, 50, 40)
main_layout.setSpacing(40)
# Header
self.create_header(main_layout)
# Content area with better proportions
content_layout = QHBoxLayout()
content_layout.setSpacing(50)
# Left side - Recent Projects (60% width)
self.create_recent_projects_section(content_layout)
# Right side - Quick Actions and Getting Started (40% width)
self.create_actions_section(content_layout)
main_layout.addLayout(content_layout, 1) # Stretch factor 1
# Funding/Project Information Footer
self.create_funding_footer(main_layout)
main_layout.addStretch(0) # No extra stretch at bottom
self.setLayout(main_layout)
[docs]
def create_recent_projects_section(self, parent_layout):
"""
Create the recent projects section with scrollable project list.
:param parent_layout: Parent layout to add section to
:type parent_layout: QHBoxLayout
.. note::
Creates scrollable area with project widgets and project count badge.
"""
left_widget = QWidget()
left_layout = QVBoxLayout()
left_layout.setContentsMargins(0, 0, 0, 0)
left_layout.setSpacing(20)
# Section title with project count
title_layout = QHBoxLayout()
recent_title = QLabel("Aktuelle Projekte")
recent_font = QFont()
recent_font.setPointSize(18)
recent_font.setBold(True)
recent_title.setFont(recent_font)
title_layout.addWidget(recent_title)
title_layout.addStretch()
# Project count badge
self.project_count_label = QLabel("0")
self.project_count_label.setObjectName("projectCountBadge")
self.project_count_label.setFixedSize(25, 25)
self.project_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
title_layout.addWidget(self.project_count_label)
left_layout.addLayout(title_layout)
# Scroll area for projects
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
scroll_area.setMinimumHeight(400)
scroll_area.setObjectName("projectScrollArea")
# Container for project widgets
self.projects_container = QWidget()
self.projects_layout = QVBoxLayout()
self.projects_layout.setContentsMargins(5, 5, 5, 5)
self.projects_layout.setSpacing(12)
self.projects_container.setLayout(self.projects_layout)
scroll_area.setWidget(self.projects_container)
left_layout.addWidget(scroll_area)
left_widget.setLayout(left_layout)
left_widget.setMinimumWidth(500) # Increased width
parent_layout.addWidget(left_widget, 3) # 60% of space
[docs]
def create_actions_section(self, parent_layout):
"""
Create the quick actions and getting started section in right panel.
:param parent_layout: Parent layout to add section to
:type parent_layout: QHBoxLayout
.. note::
Combines quick action buttons and getting started resources.
"""
right_widget = QWidget()
right_layout = QVBoxLayout()
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.setSpacing(35)
# Quick Actions
self.create_quick_actions(right_layout)
# Getting Started
self.create_getting_started(right_layout)
right_layout.addStretch()
right_widget.setLayout(right_layout)
right_widget.setMinimumWidth(350) # Increased width
parent_layout.addWidget(right_widget, 2) # 40% of space
[docs]
def create_quick_actions(self, parent_layout):
"""
Create the quick actions section with New Project and Open Project buttons.
:param parent_layout: Parent layout to add quick actions to
:type parent_layout: QVBoxLayout
.. note::
Creates styled action button containers with callbacks.
"""
actions_title = QLabel("Schnellaktionen")
actions_font = QFont()
actions_font.setPointSize(18)
actions_font.setBold(True)
actions_title.setFont(actions_font)
parent_layout.addWidget(actions_title)
# Action buttons with descriptions
actions_layout = QVBoxLayout()
actions_layout.setSpacing(20)
# New Project button with description
new_project_container = self.create_action_button(
"🆕 Neues Projekt erstellen",
"primaryButton",
self.new_project_clicked
)
actions_layout.addWidget(new_project_container)
# Open Project button with description
open_project_container = self.create_action_button(
"📂 Bestehendes Projekt öffnen",
"secondaryButton",
self.open_project_clicked
)
actions_layout.addWidget(open_project_container)
parent_layout.addLayout(actions_layout)
[docs]
def create_getting_started(self, parent_layout):
"""
Create the getting started section with documentation, examples, and support links.
:param parent_layout: Parent layout to add section to
:type parent_layout: QVBoxLayout
.. note::
Provides quick access to documentation, example projects, and GitHub support.
"""
started_title = QLabel("Schnellstart")
started_font = QFont()
started_font.setPointSize(18)
started_font.setBold(True)
started_title.setFont(started_font)
parent_layout.addWidget(started_title)
# Getting started content
started_layout = QVBoxLayout()
started_layout.setSpacing(15)
# Documentation link
doc_btn = QPushButton("📖 Dokumentation öffnen")
doc_btn.setMinimumHeight(40)
doc_btn.setObjectName("linkButton")
doc_btn.clicked.connect(self.open_documentation)
started_layout.addWidget(doc_btn)
# Examples
examples_btn = QPushButton("🔧 Beispiele")
examples_btn.setMinimumHeight(40)
examples_btn.setObjectName("linkButton")
examples_btn.clicked.connect(self.open_examples)
started_layout.addWidget(examples_btn)
# Support
support_btn = QPushButton("💬 Unterstützung")
support_btn.setMinimumHeight(40)
support_btn.setObjectName("linkButton")
support_btn.clicked.connect(self.open_support)
started_layout.addWidget(support_btn)
parent_layout.addLayout(started_layout)
[docs]
def load_recent_projects(self):
"""
Load and display recent projects from config manager with fallback to example project.
.. note::
Clears existing widgets, loads from config manager, adds Görlitz example if no projects found.
"""
# Clear existing projects
while self.projects_layout.count():
child = self.projects_layout.takeAt(0)
if child.widget():
child.widget().deleteLater()
# Get recent projects from config manager if available
self.recent_projects = []
if self.config_manager:
try:
recent_project_paths = self.config_manager.get_recent_projects()
for project_path in recent_project_paths:
if os.path.exists(project_path):
project_info = self.get_project_info(project_path)
self.recent_projects.append((project_path, project_info))
except Exception as e:
print(f"Could not load recent projects: {e}")
# Always add the bundled example project (Görlitz) if no recent projects exist
if not self.recent_projects:
example_project = self.get_bundled_example_project()
if example_project:
self.recent_projects.append(example_project)
if not self.recent_projects:
# Show "no projects" message with helpful tips
no_projects_container = QFrame()
no_projects_container.setObjectName("noProjectsContainer")
no_projects_layout = QVBoxLayout()
no_projects_layout.setContentsMargins(20, 30, 20, 30)
no_projects_layout.setSpacing(15)
no_projects_label = QLabel("Keine aktuellen Projekte gefunden")
no_projects_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
no_projects_label.setObjectName("noProjectsTitle")
font = QFont()
font.setBold(True)
font.setPointSize(14)
no_projects_label.setFont(font)
no_projects_layout.addWidget(no_projects_label)
tip_label = QLabel("Erstellen Sie ein neues Projekt oder öffnen Sie ein bestehendes, um zu beginnen!")
tip_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
tip_label.setWordWrap(True)
tip_label.setObjectName("noProjectsTip")
no_projects_layout.addWidget(tip_label)
no_projects_container.setLayout(no_projects_layout)
self.projects_layout.addWidget(no_projects_container)
# Update project count
if hasattr(self, 'project_count_label'):
self.project_count_label.setText("0")
else:
# Add recent project widgets
for project_path, project_info in self.recent_projects[:10]: # Show max 10 recent
project_widget = RecentProjectWidget(project_path, project_info)
project_widget.projectSelected.connect(self.project_selected)
self.projects_layout.addWidget(project_widget)
# Update project count
if hasattr(self, 'project_count_label'):
self.project_count_label.setText(str(min(len(self.recent_projects), 10)))
self.projects_layout.addStretch()
[docs]
def get_bundled_example_project(self) -> Optional[tuple]:
"""
Get the bundled Görlitz example project path.
This method locates the Görlitz example project that is bundled with
the application in the project_data directory. Works both in development
and in frozen (PyInstaller) deployments.
Returns:
Optional[tuple]: (project_path, project_info) if found, None otherwise
"""
try:
# Import utilities to get resource path
from districtheatingsim.utilities.utilities import get_resource_path
# Try to get the standard folder path from config
standard_project_path = None
if self.config_manager:
try:
# Get from file_paths.json configuration
standard_relative = self.config_manager.get_relative_path('standard_folder_path')
# Convert to absolute path using resource path resolution
standard_project_path = get_resource_path(standard_relative)
except Exception as e:
print(f"Could not get standard project path from config: {e}")
# Fallback: try common locations
if not standard_project_path or not os.path.exists(standard_project_path):
# Get application base directory
if getattr(sys, 'frozen', False):
# Running as compiled executable
base_path = sys._MEIPASS
# Also check directory where exe is located (for user-accessible folders)
exe_dir = os.path.dirname(sys.executable)
else:
# Running in development
base_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
exe_dir = base_path
# Try different possible locations
possible_paths = [
# First check next to exe (user-accessible location)
os.path.join(exe_dir, 'project_data', 'Görlitz'),
# Then check in _MEIPASS (internal location)
os.path.join(base_path, 'project_data', 'Görlitz'),
os.path.join(base_path, 'districtheatingsim', 'project_data', 'Görlitz'),
os.path.join(os.path.dirname(base_path), 'project_data', 'Görlitz'),
]
for path in possible_paths:
if os.path.exists(path) and os.path.isdir(path):
standard_project_path = path
break
# Check if we found a valid example project
if standard_project_path and os.path.exists(standard_project_path):
project_info = self.get_project_info(standard_project_path)
project_info['name'] = '📚 Görlitz Beispielprojekt' # Add icon to indicate it's the example
return (standard_project_path, project_info)
except Exception as e:
print(f"Error locating bundled example project: {e}")
return None
[docs]
def scan_for_projects(self) -> List[tuple]:
"""
Scan common directories for existing project folders.
:return: List of tuples (project_path, project_info) sorted by modification time
:rtype: List[tuple]
.. note::
Scans Documents, current directory, and application directory for valid project structures.
"""
projects = []
# Default locations to scan
scan_paths = [
os.path.expanduser("~/Documents"),
os.getcwd(),
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
]
for scan_path in scan_paths:
if os.path.exists(scan_path):
try:
for item in os.listdir(scan_path):
item_path = os.path.join(scan_path, item)
if os.path.isdir(item_path):
# Check if it looks like a project folder
if self.is_project_folder(item_path):
project_info = self.get_project_info(item_path)
projects.append((item_path, project_info))
except (OSError, PermissionError):
continue
# Sort by last modified time (newest first)
projects.sort(key=lambda x: x[1].get('last_modified_timestamp', 0), reverse=True)
return projects
[docs]
def is_project_folder(self, folder_path: str) -> bool:
"""
Check if a folder contains typical DistrictHeatingSim project structure.
:param folder_path: Path to check
:type folder_path: str
:return: True if appears to be a project folder
:rtype: bool
.. note::
Looks for indicator folders like 'Eingangsdaten allgemein', 'Definition Quartier IST', 'Variante 1'.
"""
# Look for typical project structure indicators
indicators = [
"Eingangsdaten allgemein",
"Definition Quartier IST",
"Variante 1",
"Gebäudedaten",
"Wärmenetz"
]
try:
contents = os.listdir(folder_path)
return any(indicator in contents for indicator in indicators)
except (OSError, PermissionError):
return False
[docs]
def get_project_info(self, project_path: str) -> Dict:
"""
Get metadata information about a project folder.
:param project_path: Path to the project
:type project_path: str
:return: Dictionary with name, path, last_modified, last_modified_timestamp
:rtype: Dict
.. note::
Extracts folder name and modification timestamp for display and sorting.
"""
info = {
'name': os.path.basename(project_path),
'path': project_path
}
try:
# Get folder modification time
stat = os.stat(project_path)
info['last_modified_timestamp'] = stat.st_mtime
info['last_modified'] = datetime.fromtimestamp(stat.st_mtime).strftime("%d.%m.%Y %H:%M")
except (OSError, PermissionError):
info['last_modified'] = "Unbekannt"
info['last_modified_timestamp'] = 0
return info
[docs]
def new_project_clicked(self):
"""
Handle new project button click by emitting newProjectRequested signal.
.. note::
Main window handles complete workflow including folder selection and project creation.
"""
# Simply emit the signal - the main window will handle the complete workflow
# including folder selection, project name input, and project creation
self.newProjectRequested.emit()
[docs]
def open_project_clicked(self):
"""
Handle open project button click with file dialog for folder selection.
.. note::
Opens file dialog starting in default project directory, emits projectSelected signal.
"""
# Start in a sensible default location - check for existing projects first
start_dir = self.get_default_project_directory()
folder = QFileDialog.getExistingDirectory(
self,
"Projektordner auswählen",
start_dir
)
if folder:
self.projectSelected.emit(folder)
[docs]
def get_default_project_directory(self):
"""
Get a sensible default directory for project dialogs.
:return: Path to default directory (most recent project parent or Documents folder)
:rtype: str
.. note::
Prefers parent directory of most recent project, falls back to ~/Documents.
"""
# Try to use the most recent project's parent directory
if self.config_manager:
try:
recent_projects = self.config_manager.get_recent_projects()
if recent_projects:
# Use the parent directory of the most recent project
most_recent = recent_projects[0]
return os.path.dirname(most_recent)
except:
pass
# Fallback to Documents folder
return os.path.expanduser("~/Documents")
[docs]
def project_selected(self, project_path: str):
"""
Handle project selection by emitting projectSelected signal.
:param project_path: Path to selected project
:type project_path: str
"""
self.projectSelected.emit(project_path)
[docs]
def open_documentation(self):
"""
Open the ReadTheDocs documentation website in default browser.
.. note::
Opens https://districtheatingsim.readthedocs.io/en/latest/
"""
webbrowser.open("https://districtheatingsim.readthedocs.io/en/latest/")
[docs]
def open_examples(self):
"""
Open example projects folder or GitHub examples page.
.. note::
Opens local examples folder if exists, otherwise opens GitHub examples page.
"""
examples_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "examples")
if os.path.exists(examples_path):
# Open the examples folder
os.startfile(examples_path) if os.name == 'nt' else os.system(f'open "{examples_path}"')
else:
# Fallback to documentation
webbrowser.open("https://github.com/JonasPfeiffer123/DistrictHeatingSim/tree/main/examples")
[docs]
def open_support(self):
"""
Open GitHub issues page for support and bug reporting.
.. note::
Opens https://github.com/JonasPfeiffer123/DistrictHeatingSim/issues
"""
webbrowser.open("https://github.com/JonasPfeiffer123/DistrictHeatingSim/issues")
[docs]
def on_theme_toggle(self, checked):
"""
Handle theme toggle switch change to apply light or dark theme.
:param checked: True if dark theme, False if light theme
:type checked: bool
"""
if checked:
self.apply_dark_theme()
else:
self.apply_light_theme()
[docs]
def apply_light_theme(self):
"""
Request light theme application for entire application.
.. note::
Emits themeChangeRequested signal with 'light_theme_style_path'.
"""
self.themeChangeRequested.emit('light_theme_style_path')
[docs]
def apply_dark_theme(self):
"""
Request dark theme application for entire application.
.. note::
Emits themeChangeRequested signal with 'dark_theme_style_path'.
"""
self.themeChangeRequested.emit('dark_theme_style_path')
[docs]
def set_current_theme(self, is_dark_theme: bool):
"""
Set the toggle switch state based on current theme without triggering signals.
:param is_dark_theme: True for dark theme, False for light theme
:type is_dark_theme: bool
.. note::
Temporarily disconnects signal to avoid loops when updating toggle state.
"""
# Temporarily disconnect the signal to avoid loops
self.theme_toggle.toggled.disconnect(self.on_theme_toggle)
self.theme_toggle.setChecked(is_dark_theme)
# Reconnect the signal
self.theme_toggle.toggled.connect(self.on_theme_toggle)
[docs]
def refresh_recent_projects(self):
"""
Refresh the recent projects list by reloading from config manager.
.. note::
Calls load_recent_projects() to update the displayed project widgets.
"""
self.load_recent_projects()
[docs]
def main():
"""
Test the welcome screen standalone for development and debugging.
.. note::
Creates QApplication and displays WelcomeScreen widget.
"""
app = QApplication([])
welcome = WelcomeScreen()
welcome.show()
app.exec()
if __name__ == '__main__':
main()