You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

432 lines
13 KiB

"""
Content Viewer - Center panel for displaying lesson content
"""
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
from PyQt5.QtWebEngineWidgets import QWebEngineView, QWebEnginePage
from PyQt5.QtCore import Qt, pyqtSignal, QUrl
from pathlib import Path
import markdown
from pymdownx import superfences, arithmatex
from app import config
from app.models import Lesson
from app.utils import VariableWrapper
class ContentViewer(QWidget):
"""Center panel for displaying lesson content with markdown and MathJax"""
# Signals
scroll_position_changed = pyqtSignal(float) # For auto-save
def __init__(self, parent=None):
super().__init__(parent)
self.current_lesson = None
self.markdown_converter = self._init_markdown()
self.variable_wrapper = VariableWrapper()
self.init_ui()
def init_ui(self):
"""Initialize the UI components"""
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Lesson title bar
self.title_label = QLabel("No lesson selected")
self.title_label.setStyleSheet(f"""
background-color: {config.COLOR_PRIMARY};
color: white;
font-size: 16pt;
font-weight: bold;
padding: 12px;
""")
self.title_label.setWordWrap(True)
layout.addWidget(self.title_label)
# Web view for content
self.web_view = QWebEngineView()
self.web_view.setPage(QWebEnginePage(self.web_view))
layout.addWidget(self.web_view, 1)
# Load welcome page
self.show_welcome()
def _init_markdown(self):
"""Initialize markdown converter with extensions"""
return markdown.Markdown(
extensions=[
'extra',
'codehilite',
'tables',
'toc',
'pymdownx.arithmatex',
'pymdownx.superfences',
'pymdownx.highlight',
'pymdownx.inlinehilite',
],
extension_configs={
'pymdownx.arithmatex': {
'generic': True
},
'codehilite': {
'css_class': 'highlight',
'linenums': False
}
}
)
def show_welcome(self):
"""Display welcome message"""
html = self._wrap_html("""
<div style="text-align: center; padding: 60px 20px;">
<h1>Welcome to Tesla Coil Spark Physics Course</h1>
<p style="font-size: 18px; color: #666;">
Select a lesson from the navigation panel to begin learning.
</p>
<p style="margin-top: 40px; color: #999;">
⚡ Explore the fascinating world of Tesla coils and electromagnetic theory ⚡
</p>
</div>
""", "Welcome")
self.web_view.setHtml(html)
self.title_label.setText("Welcome")
def load_lesson(self, lesson: Lesson):
"""Load and display a lesson"""
self.current_lesson = lesson
self.title_label.setText(f"{lesson.order}. {lesson.title}")
# Read markdown file
lesson_path = Path(lesson.file_path)
if not lesson_path.exists():
self.show_error(f"Lesson file not found: {lesson.file_path}")
return
try:
with open(lesson_path, 'r', encoding='utf-8') as f:
markdown_content = f.read()
# Convert markdown to HTML
html_content = self.markdown_converter.convert(markdown_content)
# Process custom tags
html_content = self._process_custom_tags(html_content, lesson)
# Wrap variables with tooltips
html_content = self.variable_wrapper.wrap_in_context(html_content)
# Wrap in full HTML document
full_html = self._wrap_html(html_content, lesson.title)
# Load into web view
self.web_view.setHtml(full_html, QUrl.fromLocalFile(str(lesson_path.parent)))
except Exception as e:
self.show_error(f"Error loading lesson: {str(e)}")
def _process_custom_tags(self, html: str, lesson: Lesson) -> str:
"""Process custom tags like {exercise:id} and {image:file}"""
import re
# Process {exercise:id} tags
def replace_exercise(match):
exercise_id = match.group(1)
return f'''
<div class="exercise-placeholder" data-exercise-id="{exercise_id}">
<h3>📝 Exercise: {exercise_id}</h3>
<p><em>Interactive exercise will be loaded here</em></p>
</div>
'''
html = re.sub(r'\{exercise:([^}]+)\}', replace_exercise, html)
# Process {image:file} tags
def replace_image(match):
image_file = match.group(1)
image_path = config.IMAGES_DIR / image_file
return f'<img src="{image_path}" alt="{image_file}" style="max-width: 100%; height: auto;" />'
html = re.sub(r'\{image:([^}]+)\}', replace_image, html)
return html
def _wrap_html(self, content: str, title: str) -> str:
"""Wrap content in full HTML document with styling and MathJax"""
return f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{title}</title>
<style>
body {{
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: {config.COLOR_TEXT};
max-width: 900px;
margin: 0 auto;
padding: 20px 40px;
background-color: white;
}}
h1, h2, h3, h4, h5, h6 {{
color: {config.COLOR_PRIMARY};
margin-top: 1.5em;
margin-bottom: 0.5em;
}}
h1 {{ font-size: 2.2em; border-bottom: 3px solid {config.COLOR_PRIMARY}; padding-bottom: 10px; }}
h2 {{ font-size: 1.8em; border-bottom: 2px solid {config.COLOR_SECONDARY}; padding-bottom: 8px; }}
h3 {{ font-size: 1.4em; }}
p {{ margin: 1em 0; }}
code {{
background-color: #f4f4f4;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.9em;
}}
pre {{
background-color: #f4f4f4;
padding: 15px;
border-radius: 5px;
overflow-x: auto;
border-left: 4px solid {config.COLOR_PRIMARY};
}}
pre code {{
background-color: transparent;
padding: 0;
}}
blockquote {{
border-left: 4px solid {config.COLOR_WARNING};
padding-left: 20px;
margin-left: 0;
color: #666;
font-style: italic;
}}
table {{
border-collapse: collapse;
width: 100%;
margin: 1.5em 0;
}}
th, td {{
border: 1px solid #ddd;
padding: 10px;
text-align: left;
}}
th {{
background-color: {config.COLOR_PRIMARY};
color: white;
font-weight: bold;
}}
tr:nth-child(even) {{
background-color: #f9f9f9;
}}
img {{
max-width: 100%;
height: auto;
display: block;
margin: 20px auto;
border-radius: 5px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}}
.exercise-placeholder {{
background-color: #fff8dc;
border: 2px dashed {config.COLOR_WARNING};
padding: 20px;
margin: 20px 0;
border-radius: 5px;
}}
.math {{
font-size: 1.1em;
}}
/* Syntax highlighting */
.highlight {{
background: #f4f4f4;
}}
/* Variable tooltip styles */
.var-tooltip {{
color: {config.COLOR_PRIMARY};
font-weight: 600;
cursor: help;
border-bottom: 1px dotted {config.COLOR_PRIMARY};
position: relative;
display: inline-block;
transition: all 0.2s ease;
}}
.var-tooltip:hover {{
color: {config.COLOR_SECONDARY};
border-bottom-color: {config.COLOR_SECONDARY};
}}
/* Tooltip popup */
.tooltip-popup {{
position: absolute;
background-color: #2c3e50;
color: white;
padding: 12px 16px;
border-radius: 6px;
font-size: 13px;
font-weight: normal;
max-width: 350px;
z-index: 10000;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
line-height: 1.6;
white-space: pre-wrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
}}
.tooltip-popup.show {{
opacity: 1;
}}
.tooltip-arrow {{
position: absolute;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 8px solid #2c3e50;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
}}
</style>
<!-- MathJax for equation rendering -->
<script>
MathJax = {{
tex: {{
inlineMath: [['$', '$'], ['\\\\(', '\\\\)']],
displayMath: [['$$', '$$'], ['\\\\[', '\\\\]']],
processEscapes: true
}},
svg: {{
fontCache: 'global'
}}
}};
</script>
<script src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js" async></script>
<!-- Variable tooltip JavaScript -->
<script>
// Create tooltip element
let tooltipPopup = null;
function createTooltip() {{
if (!tooltipPopup) {{
tooltipPopup = document.createElement('div');
tooltipPopup.className = 'tooltip-popup';
const arrow = document.createElement('div');
arrow.className = 'tooltip-arrow';
tooltipPopup.appendChild(arrow);
const content = document.createElement('div');
content.className = 'tooltip-content';
tooltipPopup.appendChild(content);
document.body.appendChild(tooltipPopup);
}}
}}
function showTooltip(element, text) {{
createTooltip();
// Set content
const content = tooltipPopup.querySelector('.tooltip-content');
content.textContent = text.replace(/&#10;/g, '\\n');
// Position tooltip
const rect = element.getBoundingClientRect();
const tooltipRect = tooltipPopup.getBoundingClientRect();
const left = rect.left + (rect.width / 2) - (tooltipPopup.offsetWidth / 2);
const top = rect.top - tooltipPopup.offsetHeight - 10;
tooltipPopup.style.left = Math.max(10, left) + 'px';
tooltipPopup.style.top = Math.max(10, top) + window.scrollY + 'px';
// Show tooltip
setTimeout(() => {{
tooltipPopup.classList.add('show');
}}, 10);
}}
function hideTooltip() {{
if (tooltipPopup) {{
tooltipPopup.classList.remove('show');
}}
}}
// Attach event listeners after DOM loads
document.addEventListener('DOMContentLoaded', function() {{
const varTooltips = document.querySelectorAll('.var-tooltip');
varTooltips.forEach(element => {{
element.addEventListener('mouseenter', function() {{
const tooltipText = this.getAttribute('title');
if (tooltipText) {{
showTooltip(this, tooltipText);
// Remove title to prevent browser default tooltip
this.setAttribute('data-original-title', tooltipText);
this.removeAttribute('title');
}}
}});
element.addEventListener('mouseleave', function() {{
hideTooltip();
// Restore title
const originalTitle = this.getAttribute('data-original-title');
if (originalTitle) {{
this.setAttribute('title', originalTitle);
}}
}});
}});
}});
</script>
</head>
<body>
{content}
</body>
</html>
"""
def show_error(self, message: str):
"""Display an error message"""
html = self._wrap_html(f"""
<div style="text-align: center; padding: 60px 20px; color: {config.COLOR_ERROR};">
<h1>⚠ Error</h1>
<p style="font-size: 18px;">{message}</p>
</div>
""", "Error")
self.web_view.setHtml(html)
def get_scroll_position(self) -> float:
"""Get current scroll position (0.0 to 1.0)"""
# This would require JavaScript execution in QWebEngineView
# For now, return 0.0 - can be implemented later
return 0.0
def set_scroll_position(self, position: float):
"""Set scroll position (0.0 to 1.0)"""
# This would require JavaScript execution in QWebEngineView
# For now, do nothing - can be implemented later
pass