First commit

This commit is contained in:
stan44 2026-02-24 13:22:10 -06:00
commit ae2ba3d873
53 changed files with 4073 additions and 0 deletions

17
.gitignore vendored Normal file
View File

@ -0,0 +1,17 @@
/.venv
__pycache__
*.pyc
*.pyo
*.pyd
/build
/dist
.pytest_cache/
.mypy_cache/
.ruff_cache/
*.egg-info/
.backup
.tmp
/data
/assets
/docs/build
/samples/bishpls.lmd

45
README.md Normal file
View File

@ -0,0 +1,45 @@
# LyricFlow IDE
LyricFlow is a Python/PyQt lyric-writing IDE with rhyme analysis powered by NLTK phonetics.
## Support Matrix
- Python: `3.14`
- Platforms: Windows and Linux (first-class)
## Install
```bash
cd lyricflow
python -m venv .venv
# Windows
.venv\Scripts\activate
# Linux/macOS
source .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install -r requirements.txt
```
`requirements.txt` now includes shared root requirements via `../requirements-common.txt`.
## NLTK Data
LyricFlow uses `cmudict` and `wordnet`. For predictable startup behavior, the app does not auto-download missing corpora.
Install them once for full rhyme and synonym features:
```bash
python -m nltk.downloader cmudict wordnet
```
## Run
```bash
python run.py
```
## Tests
```bash
python -m unittest discover -s tests -p "test_*.py"
```

66
docs/architecture.md Normal file
View File

@ -0,0 +1,66 @@
# System Architecture
LyricFlow is built using Python 3 and the PyQt6 framework. The codebase follows a strict separation of concerns between the linguistic engine and the user interface.
## Directory Structure
```text
lyricflow/
├── docs/ # Technical documentation
├── src/
│ ├── engine/ # Linguistic logic (Rhymes, Phonetics, Syllables)
│ ├── gui/ # UI components and styling
│ │ └── components/ # Specialized widgets (Editor, Explorer, Sidebar)
│ ├── lyricflow_core/ # Shared core for desktop/mobile clients
│ │ ├── api/ # Stable service/facade APIs
│ │ ├── engine/ # Core linguistic implementation
│ │ └── storage/ # Core persistence implementation
│ └── utils/ # Legacy compatibility wrappers
├── run.py # Main entry point script
└── README.md # Project overview
```
## Module Responsibilities
### `src/engine`
- **`phonetics.py`**: Wraps `nltk.cmudict`. Handles word normalization and phonetic extraction. Contains the `PhoneticProcessor` singleton.
- **`rhyme_engine.py`**: The core logic. Indexes the phonetic dictionary into memory for O(1) slant/perfect rhyme lookup. Implements word association (synonyms/vibes) using NLTK WordNet.
These now proxy to `src/lyricflow_core/engine` for backward compatibility.
### `src/lyricflow_core`
- **`api/analysis.py`**: Stable analysis service used by GUI and future mobile clients.
- **`api/project_state.py`**: Stable project state read/write and defensive parsing.
- **`api/facade.py`**: Aggregated `LyricFlowCoreFacade` integration point.
- **`engine/`** and **`storage/`**: Canonical implementation modules shared across clients.
### `src/gui`
- **`main_window.py`**: The central orchestrator.
- Manages the `QSplitter` layout.
- Handles **Tab Management** for multi-file editing.
- Implements **Project Persistence** via `.lyricproject` JSON files.
- Manages app-level restore and preferences via `QSettings` and recovered session snapshots.
- **`components/explorer.py`**: Uses `QFileSystemModel` and `QTreeView` to provide an IDE-like project explorer with context menu file operations.
- **`components/editor.py`**: A specialized `QPlainTextEdit` implementing:
- **RhymeHighlighter**: Real-time coloring based on flow density and LyricDown syntax.
- **Syllable Margin**: Custom painting to show the meter of each line.
- **Debounced Analysis**: Performance-optimized logic to prevent UI lag.
- **`components/sidebar.py`**: A composite widget that displays dynamic lists of rhymes, synonyms, and vibe concepts based on the current cursor selection.
- **`components/preferences_dialog.py`**: Modal preferences UI for startup/session defaults and appearance toggles.
### `src/utils`
- **`app_settings.py`**: Defines `AppPreferences` and `AppSettingsStore`, persisting app-level behavior and UI chrome state through `QSettings`.
- **`session_store.py`**: Stores and restores recovered unsaved tabs (untitled and dirty files) under app data as JSON snapshots.
## Data Flow
1. **User Types**: `LyricEditor` detects change -> Reset Debounce Timer.
2. **Timer Expires**: `LyricEditor` calls `engine.get_rhyme_groups()`.
3. **Highlighter Updates**: `RhymeHighlighter` receives results and colors the text, while strictly excluding LyricDown keywords/comments.
4. **Project Save**: `MainWindow` writes `.lyricproject` with open files, active file, and cursor positions.
5. **Session Autosave**: Every 30s (and on close), unsaved tabs are captured to app data for crash/quit recovery.
6. **Startup Restore**: Preferences are loaded first, then last project and recovered snapshots are optionally restored.
7. **Selection**: User clicks a word -> `wordSelected` signal sent to `Sidebar` -> Sidebar fetches creative suggestions from the engine.
## Styling
The application uses a custom CSS-like dictionary approach within PyQt to implement a **Dracula Theme**. This theme is consistently applied to the menu bar, tabs, explorer, editor, and sidebar to ensure a premium look and feel.

83
docs/features.md Normal file
View File

@ -0,0 +1,83 @@
# LyricFlow Advanced Features
This document provides a deep dive into the specialized features that make LyricFlow a unique tool for lyricists.
### LyricDown Syntax Guide
LyricFlow introduces **LyricDown**, a Markdown-like syntax for lyricists, natively saved as `.lmd` files. This custom syntax helps you structure your creative process without interfering with phonetic analysis.
| Element | Syntax | Usage |
| :--- | :--- | :--- |
| **Header** | `# Text` | Titles, large section breaks. |
| **Metadata** | `@Key: Value` | Track BPM, Key, Artist, or mood info. |
| **Comment** | `> Text` | Personal notes, alternative line ideas. |
| **Tag** | `[Tag]` | Structural markers (Verse, Chorus, Bridge). |
### The `.lmd` Format
LyricFlow uses the **.lmd (Lyric Markdown)** extension as its native format. While standard `.txt` files are supported, using `.lmd` ensures that your projects are clearly identified as LyricDown documents.
### Strict Analysis Filtering
The Rhyme Engine is context-aware. It **ignores** Headers, Metadata, and Comments entirely. When you click a word inside a comment or header, **the sidebar suggestions will not trigger**(may not function properly.), keeping your workspace clutter-free.
## 2. IDE Workspace & Layout
LyricFlow implements a professional "three-pane" architecture:
- **Project Explorer (Left)**: A full-featured file system explorer. Supporting right-click operations (New File/Folder, Rename, Delete) and `.lyricproject` session loading.
- **Tabbed Editor (Center)**: Supports unlimited open files. Each tab maintains its own highlight state and undo/redo history.
- **Creative Sidebar (Right)**:
- **Phonetics**: Displays the IPA-style breakdown of the selected word.
- **Perfect Rhymes**: Words with identical terminal sounds.
- **Slant Rhymes**: Words with closely matching vowel patterns (powered by CMUDict).
- **Thematic Suggestions**: Synonyms and "Vibe" associates.
## 3. The Rhyme Engine
### Mosaic (Multi-word) Rhymes
Unlike simple end-rhyme detectors, LyricFlow identify "mosaic" rhymes where a single word rhymes with a combination of words (e.g., "Orange" matching "Door Hinge").
### Internal Rhyme Detection
The engine scans the entire line, not just the last word. This helps identify complex internal structures that add rhythmic complexity to your writing.
## 4. Related Concepts ("Vibe")
One of LyricFlow's most powerful features is the **Vibe Suggestion** system. Standard thesauruses give you synonyms; LyricFlow searches for **Conceptual Neighbors**:
1. It looks up the word's "Synsets" in WordNet.
2. It traverses **Hypernyms** (general categories) and **Hyponyms** (specific types).
3. It filters out direct synonyms to find words that fit the "atmosphere" without sharing the exact meaning.
## 5. Performance & Debouncing
To maintain a responsive feel, we implemented a **Debounced Analysis** system.
Heavy calculations (scanning entire verses for rhymes) only happen when you pause (set to 1500ms). This prevents the UI from stuttering while you are mid-flow, ensuring the "Dracula" theme remains smooth and elegant.
## 6. Project Session Persistence
LyricFlow creates a `.lyricproject` JSON file in your project folders. This file stores:
- Lists of files you currently have open in tabs.
- The last active file you were editing.
- Cursor positions for open files.
When you re-open a folder, LyricFlow restores your exact workspace state instantly.
## 7. Recovered Unsaved Sessions
LyricFlow also keeps an app-level recovered session cache in user app data:
- **Untitled drafts** with content.
- **Dirty saved files** (unsaved edits).
Snapshots are written every 30 seconds and on app close. On startup, LyricFlow can restore recovered tabs automatically. If a recovered snapshot conflicts with a file changed on disk, LyricFlow prompts you to choose:
- Use recovered snapshot.
- Use disk version.
- Skip that tab.
## 8. Settings Menu
A top-level `Settings -> Preferences...` dialog allows users to configure:
- Reopen last project on startup.
- Restore unsaved tabs.
- Word wrap default.
- Left/right sidebar default visibility.
- Clearing recovered session cache for the current workspace or all workspaces.

50
docs/lyricflow.md Normal file
View File

@ -0,0 +1,50 @@
# LyricFlow IDE Roadmap
## Project Overview
A professional songwriting environment providing real-time rhyme density visualization and phonetic analysis for songwriters and poets.
## Core Goals
- **Phonetic Highlighting**: Use CMUDict to highlight perfect and near rhymes.
- **IDE Experience**: Tabbed editing, project explorer, and session persistence.
- **LyricDown Syntax**: A proprietary syntax for structured lyric drafting.
- **Offline First**: No cloud dependency; all analysis performed locally.
## Technology Stack
- **Base**: Python 3.14+
- **UI Framework**: PyQt6
- **Linguistic Engine**: NLTK (WordNet, CMUDict)
- **Styles**: Custom Dracula-inspired CSS
## Project Status: Active Development
### ✅ Phase 1: Core Engine
- [x] Phonetic extraction from CMUDict.
- [x] Perfect and Slant rhyme detection logic.
- [x] Mosaic rhyme identification.
- [x] WordNet-based "Vibe" suggestion system.
### ✅ Phase 2: Editor Experience
- [x] Basic text editor with debounced analysis.
- [x] Color-coded rhyme group highlighting.
- [x] Live syllable counts in margins.
### ✅ Phase 3: IDE Layout & Workflow
- [x] VS Code-style three-pane splitter layout.
- [x] Multi-tabbed editor support.
- [x] Project Explorer with right-click file operations.
- [x] `.lyricproject` session persistence (recovery of open tabs).
### ✅ Phase 4: LyricDown Syntax
- [x] Custom syntax highlighting (Headers, Metadata, Comments).
- [x] Context-aware analysis (filtering out non-lyric elements).
- [x] Integration of structural tags `[...]`.
### 🚀 Upcoming Features (Next Steps)
- [ ] **Near-Rhyme Sensitivity**: A slider to adjust the strictness of slant rhymes.
- [ ] **Density Mapping**: Visual representation of phonetic density throughout a verse.
- [ ] **BPM/Metronome**: Integrated metronome for rhythmic drafting.
- [ ] **Selection Analysis**: Perform rhyme check strictly on a selected block of text.
- [ ] **Binary Builds**: Standalone executables for Windows and Linux.

88
lyricflow.spec Normal file
View File

@ -0,0 +1,88 @@
# -*- mode: python ; coding: utf-8 -*-
import os
import sys
from importlib.machinery import EXTENSION_SUFFIXES
block_cipher = None
# Find PyQt6 sip binary manually
import PyQt6
pyqt6_path = os.path.dirname(PyQt6.__file__)
sip_binary = None
for f in os.listdir(pyqt6_path):
if f.startswith('sip') and any(f.endswith(suffix) for suffix in EXTENSION_SUFFIXES):
sip_binary = (os.path.join(pyqt6_path, f), 'PyQt6')
break
# Get NLTK data path from environment or default locations
import nltk
nltk_data_paths = nltk.data.path
nltk_path = None
for p in nltk_data_paths:
if os.path.exists(p):
nltk_path = p
break
datas = [
('src', 'src'),
('assets', 'assets'),
('data', 'data'),
]
if nltk_path:
cmudict_path = os.path.join(nltk_path, 'corpora', 'cmudict')
cmudict_zip = os.path.join(nltk_path, 'corpora', 'cmudict.zip')
wordnet_zip = os.path.join(nltk_path, 'corpora', 'wordnet.zip')
if os.path.exists(cmudict_path):
datas.append((cmudict_path, 'nltk_data/corpora/cmudict'))
if os.path.exists(cmudict_zip):
datas.append((cmudict_zip, 'nltk_data/corpora'))
if os.path.exists(wordnet_zip):
datas.append((wordnet_zip, 'nltk_data/corpora'))
a = Analysis(
['run.py'],
pathex=[os.path.abspath('src')],
binaries=[sip_binary] if sip_binary else [],
datas=datas,
hiddenimports=['PyQt6.sip', 'nltk.corpus.wordnet', 'nltk.corpus.cmudict'],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name='LyricFlow',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='LyricFlow',
)

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
-r ../requirements-common.txt
nltk>=3.9,<4

29
run.py Normal file
View File

@ -0,0 +1,29 @@
import sys
import os
import nltk
# Add bundled nltk_data path if running as executable
if getattr(sys, 'frozen', False):
bundle_dir = sys._MEIPASS
nltk_data_path = os.path.join(bundle_dir, 'nltk_data')
if nltk_data_path not in nltk.data.path:
nltk.data.path.append(nltk_data_path)
# Add src to path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), 'src')))
from gui.main_window import MainWindow
from PyQt6.QtWidgets import QApplication
from PyQt6.QtCore import QCoreApplication
def main():
app = QApplication(sys.argv)
QCoreApplication.setOrganizationName("LyricFlow")
QCoreApplication.setOrganizationDomain("lyricflow.local")
QCoreApplication.setApplicationName("LyricFlow")
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()

13
samples/.lyricproject Normal file
View File

@ -0,0 +1,13 @@
{
"version": 2,
"name": "test",
"open_files": [
"G:\\Python\\lyricflow\\test\\bishpls.lmd",
"G:\\Python\\lyricflow\\test\\test.lmd"
],
"active_file": null,
"cursor_positions": {
"G:\\Python\\lyricflow\\test\\bishpls.lmd": 0,
"G:\\Python\\lyricflow\\test\\test.lmd": 0
}
}

6
samples/test.lmd Normal file
View File

@ -0,0 +1,6 @@
## The Goat on a Boat
[Verse 1 - Register: Jester]
a goat on a boat afloat atop the mountain top as crazy as that sounds breezy like a dizzy abbey got shot by bambi
[Intro: on a dusty spooky night the Riddler of Stars laughs manically]

0
src/__init__.py Normal file
View File

8
src/engine/phonetics.py Normal file
View File

@ -0,0 +1,8 @@
"""Compatibility layer for legacy imports.
Primary implementation now lives in src.lyricflow_core.engine.phonetics.
"""
from src.lyricflow_core.engine.phonetics import PhoneticProcessor, processor
__all__ = ["PhoneticProcessor", "processor"]

View File

@ -0,0 +1,8 @@
"""Compatibility layer for legacy imports.
Primary implementation now lives in src.lyricflow_core.engine.rhyme_engine.
"""
from src.lyricflow_core.engine.rhyme_engine import RhymeEngine, engine
__all__ = ["RhymeEngine", "engine"]

0
src/gui/__init__.py Normal file
View File

View File

View File

@ -0,0 +1,415 @@
from PyQt6.QtWidgets import QPlainTextEdit, QWidget
from PyQt6.QtGui import QSyntaxHighlighter, QTextCharFormat, QColor, QFont, QTextCursor, QPainter
from PyQt6.QtCore import QTimer, pyqtSignal, QRect, Qt
from src.lyricflow_core.api.analysis import analysis_service
from typing import Optional, List, Tuple, Dict
import re
from src.lyricflow_core.engine.syntax import TAG_PATTERN
from src.gui.theme import Theme
class RhymeHighlighter(QSyntaxHighlighter):
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self.rhyme_map: Dict[str, int] = {}
self.spellcheck_enabled = True
def set_results(self, results: List[Dict]):
self.rhyme_map = {}
for res in results:
word = res.get('word', '')
group_id = res.get('group')
if group_id is not None:
normalized = word.lower().strip(".,!?;:()[]{}")
self.rhyme_map[normalized] = group_id
self.rehighlight()
def highlightBlock(self, text: str):
# 0. Define Formats
header_fmt = QTextCharFormat()
header_fmt.setForeground(QColor(Theme.HEADER))
header_fmt.setFontWeight(QFont.Weight.Bold)
header_fmt.setProperty(QTextCharFormat.Property.FontSizeAdjustment, 2)
metadata_fmt = QTextCharFormat()
metadata_fmt.setForeground(QColor(Theme.METADATA))
tag_format = QTextCharFormat()
tag_format.setForeground(QColor(Theme.TAG))
tag_format.setFontItalic(True)
comment_fmt = QTextCharFormat()
comment_fmt.setForeground(QColor(Theme.COMMENT))
comment_fmt.setFontItalic(True)
bold_fmt = QTextCharFormat()
bold_fmt.setFontWeight(QFont.Weight.Bold)
italic_fmt = QTextCharFormat()
italic_fmt.setFontItalic(True)
# 1. Headers (# Title)
if text.lstrip().startswith('#'):
self.setFormat(0, len(text), header_fmt)
return # Don't highlight rhymes in headers
# 2. Metadata (@Key: Value)
if text.lstrip().startswith('@'):
self.setFormat(0, len(text), metadata_fmt)
return # Don't highlight rhymes in metadata
# 3. Comments (> comment)
if text.lstrip().startswith('>'):
self.setFormat(0, len(text), comment_fmt)
return
# 4. Structural Tags [Verse 1]
excluded_ranges = []
for match in re.finditer(TAG_PATTERN, text):
start, end = match.start(), match.end()
self.setFormat(start, len(match.group()), tag_format)
excluded_ranges.append((start, end))
# 5. Bold (**text**)
for match in re.finditer(r"\*\*(.*?)\*\*", text):
self.setFormat(match.start(), len(match.group()), bold_fmt)
# 6. Italic (*text*)
for match in re.finditer(r"\*(.*?)\*", text):
self.setFormat(match.start(), len(match.group()), italic_fmt)
# 7. Highlight Rhymes
if self.rhyme_map:
for match in re.finditer(r"\b\w+\b", text):
word = match.group()
start = match.start()
# Check if inside excluded range (tag)
is_excluded = any(ex_start <= start < ex_end for ex_start, ex_end in excluded_ranges)
if is_excluded: continue
normalized = word.lower()
if normalized in self.rhyme_map:
group_id = self.rhyme_map[normalized]
idx = start
length = len(word)
# Merge with existing format (e.g. bold) if possible,
# but for now just overlay color
fmt = QTextCharFormat()
fmt.setForeground(Theme.get_rhyme_color(group_id))
fmt.setFontWeight(QFont.Weight.Bold)
self.setFormat(idx, length, fmt)
# 8. Spellcheck overlay
if self.spellcheck_enabled:
for match in re.finditer(r"\b\w+\b", text):
word = match.group()
start = match.start()
is_excluded = any(ex_start <= start < ex_end for ex_start, ex_end in excluded_ranges)
if is_excluded:
continue
normalized = analysis_service.normalize_word(word)
if not normalized:
continue
if analysis_service.is_known_word(normalized):
continue
existing = self.format(start)
fmt = QTextCharFormat(existing)
fmt.setUnderlineStyle(QTextCharFormat.UnderlineStyle.WaveUnderline)
fmt.setUnderlineColor(QColor(Theme.SPELLCHECK_ERROR))
self.setFormat(start, len(word), fmt)
class LyricEditor(QPlainTextEdit):
textChangedDebounced = pyqtSignal(str)
wordSelected = pyqtSignal(str)
_AUTOCORRECT_DELIMITERS = set(" \t.,!?;:)]}\"'")
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self.highlighter = RhymeHighlighter(self.document())
self.setPlaceholderText("Start writing your lyrics here...")
self.autocorrect_enabled = True
self._last_emitted_text: Optional[str] = None
self._last_analyzed_text: Optional[str] = None
self.timer = QTimer()
self.timer.setSingleShot(True)
self.timer.setInterval(1500)
self.timer.timeout.connect(self._emit_debounced)
self.textChanged.connect(self._on_text_changed)
self.cursorPositionChanged.connect(self._on_cursor_moved)
self.textChangedDebounced.connect(self._analyze)
# Set default monospace font
font = QFont("Consolas", 11)
font.setStyleHint(QFont.StyleHint.Monospace)
self.setFont(font)
def keyPressEvent(self, e):
super().keyPressEvent(e)
if not self.autocorrect_enabled:
return
typed = e.text()
if typed and typed in self._AUTOCORRECT_DELIMITERS:
self._autocorrect_previous_word()
def wheelEvent(self, e):
if e.modifiers() == Qt.KeyboardModifier.ControlModifier:
if e.angleDelta().y() > 0:
self.zoom_in()
else:
self.zoom_out()
else:
super().wheelEvent(e)
def zoom_in(self):
self.zoomIn(1)
def zoom_out(self):
self.zoomOut(1)
def move_line_up(self):
cursor = self.textCursor()
if not cursor.hasSelection():
cursor.select(QTextCursor.SelectionType.LineUnderCursor)
start = cursor.selectionStart()
end = cursor.selectionEnd()
cursor.setPosition(start)
cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock)
if cursor.atStart(): return
# Select the whole block(s)
cursor.setPosition(start)
cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock)
cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor)
text = cursor.selectedText()
cursor.removeSelectedText()
cursor.deletePreviousChar() # Remove the extra newline
cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock)
cursor.insertText(text + "\n")
cursor.movePosition(QTextCursor.MoveOperation.PreviousBlock)
self.setTextCursor(cursor)
def move_line_down(self):
cursor = self.textCursor()
if not cursor.hasSelection():
cursor.select(QTextCursor.SelectionType.LineUnderCursor)
start = cursor.selectionStart()
end = cursor.selectionEnd()
cursor.setPosition(end)
cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock)
if cursor.atEnd(): return
# Select the whole block(s)
cursor.setPosition(start)
cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock)
cursor.setPosition(end, QTextCursor.MoveMode.KeepAnchor)
cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock, QTextCursor.MoveMode.KeepAnchor)
text = cursor.selectedText()
cursor.removeSelectedText()
cursor.deleteChar() # Remove the extra newline
cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock)
cursor.insertText("\n" + text)
self.setTextCursor(cursor)
def toggle_line_comment(self):
cursor = self.textCursor()
start = cursor.selectionStart()
end = cursor.selectionEnd()
cursor.setPosition(start)
cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock)
while cursor.position() <= end:
cursor.movePosition(QTextCursor.MoveOperation.StartOfBlock)
line = cursor.block().text()
if line.lstrip().startswith(">"):
# Uncomment
pos = line.find(">")
cursor.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.MoveAnchor, pos)
cursor.deleteChar()
if line[pos+1:pos+2] == " ": cursor.deleteChar()
else:
# Comment
cursor.insertText("> ")
if not cursor.movePosition(QTextCursor.MoveOperation.NextBlock):
break
if cursor.position() > end:
break
def paintEvent(self, e):
super().paintEvent(e)
painter = QPainter(self.viewport())
painter.setPen(QColor(Theme.COMMENT))
# Syllables font scale with editor font
font = self.font()
font.setPointSize(max(8, font.pointSize() - 2))
painter.setFont(font)
block = self.firstVisibleBlock()
top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top()
bottom = top + self.blockBoundingRect(block).height()
while block.isValid() and top <= e.rect().bottom():
if block.isVisible() and bottom >= e.rect().top():
text = block.text().strip()
if text and not (text.startswith('[') and text.endswith(']')):
# Count syllables for the whole line
words = re.findall(r"\b\w+\b", text) if ' ' in text else [text]
# Robust extraction for syllable summation
words = [w for w in re.split(r'\s+', text) if w and not w.startswith('[')]
count = sum(analysis_service.count_syllables(w) for w in words)
if count > 0:
# Draw on the right side
rect = self.viewport().rect()
painter.drawText(rect.width() - 40, int(top) + self.fontMetrics().ascent(), str(count))
block = block.next()
top = bottom
bottom = top + self.blockBoundingRect(block).height()
def _on_text_changed(self):
self.timer.start()
def _autocorrect_previous_word(self):
cursor = self.textCursor()
block = cursor.block()
block_text = block.text()
if not block_text:
return
stripped = block_text.lstrip()
if stripped.startswith(("#", "@", ">")):
return
cursor_pos_in_block = cursor.position() - block.position()
if cursor_pos_in_block <= 0:
return
delimiter_idx = cursor_pos_in_block - 1
if delimiter_idx < 0 or delimiter_idx >= len(block_text):
return
if block_text[delimiter_idx] not in self._AUTOCORRECT_DELIMITERS:
return
excluded_ranges = [(m.start(), m.end()) for m in re.finditer(TAG_PATTERN, block_text)]
if any(start <= delimiter_idx < end for start, end in excluded_ranges):
return
word_end = delimiter_idx
while word_end > 0 and not (
block_text[word_end - 1].isalpha() or block_text[word_end - 1] == "'"
):
word_end -= 1
if word_end <= 0:
return
word_start = word_end
while word_start > 0 and (
block_text[word_start - 1].isalpha() or block_text[word_start - 1] == "'"
):
word_start -= 1
if word_start >= word_end:
return
if any(start <= word_start < end for start, end in excluded_ranges):
return
original = block_text[word_start:word_end]
suggestion = analysis_service.autocorrect_candidate(original)
if not suggestion:
return
if original.isupper():
replacement = suggestion.upper()
elif original[0].isupper():
replacement = suggestion.capitalize()
else:
replacement = suggestion
if replacement == original:
return
start_abs = block.position() + word_start
end_abs = block.position() + word_end
old_pos = cursor.position()
delta = len(replacement) - len(original)
edit_cursor = self.textCursor()
edit_cursor.beginEditBlock()
edit_cursor.setPosition(start_abs)
edit_cursor.setPosition(end_abs, QTextCursor.MoveMode.KeepAnchor)
edit_cursor.insertText(replacement)
edit_cursor.endEditBlock()
final_cursor = self.textCursor()
final_cursor.setPosition(max(0, old_pos + delta))
self.setTextCursor(final_cursor)
def _emit_debounced(self):
text = self.toPlainText()
if text == self._last_emitted_text:
return
self._last_emitted_text = text
self.textChangedDebounced.emit(text)
def _on_cursor_moved(self):
cursor = self.textCursor()
block = cursor.block()
block_text = block.text()
stripped = block_text.lstrip()
# Ignore LyricDown syntax lines
if stripped.startswith(('#', '@', '>')):
return
# Ignore cursor positions inside structural tags like [Voice: ...]
block_pos = block.position()
cursor_pos_in_block = cursor.position() - block_pos
excluded_ranges = [(m.start(), m.end()) for m in re.finditer(TAG_PATTERN, block_text)]
if any(start <= cursor_pos_in_block < end for start, end in excluded_ranges):
return
cursor.select(QTextCursor.SelectionType.WordUnderCursor)
word = cursor.selectedText().strip()
# Ignore words selected from within structural tags.
selection_start = cursor.selectionStart() - block_pos
if any(start <= selection_start < end for start, end in excluded_ranges):
return
if word:
self.wordSelected.emit(word)
def _analyze(self, text: str):
if text == self._last_analyzed_text:
return
if not text:
self.highlighter.set_results([])
v = self.viewport()
if v: v.update()
self._last_analyzed_text = text
return
results = analysis_service.rhyme_groups(text)
self.highlighter.set_results(results)
v = self.viewport()
if v: v.update() # Redraw syllables
self._last_analyzed_text = text

View File

@ -0,0 +1,258 @@
from PyQt6.QtWidgets import (QWidget, QVBoxLayout, QTreeView, QLabel,
QMenu, QInputDialog, QMessageBox)
from PyQt6.QtGui import QFileSystemModel
from PyQt6.QtCore import QDir, pyqtSignal, QModelIndex, Qt, QStandardPaths
from datetime import datetime
import os
import shutil
def _is_valid_entry_name(name: str) -> bool:
cleaned = name.strip()
if not cleaned or cleaned in {".", ".."}:
return False
if "/" in cleaned or "\\" in cleaned:
return False
return True
def _is_within_root(root_path: str, candidate_path: str) -> bool:
try:
root_abs = os.path.abspath(root_path)
candidate_abs = os.path.abspath(candidate_path)
return os.path.commonpath([root_abs, candidate_abs]) == root_abs
except ValueError:
return False
class ProjectExplorer(QWidget):
fileSelected = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Style the explorer header
self.header_label = QLabel("EXPLORER")
self.header_label.setStyleSheet("""
QLabel {
background-color: #343746;
color: #6272a4;
font-weight: bold;
padding: 10px;
font-size: 10pt;
}
""")
layout.addWidget(self.header_label)
# File system model
self.model = QFileSystemModel()
self.model.setRootPath(QDir.currentPath())
self.model.setReadOnly(False)
self.model.setFilter(QDir.Filter.AllDirs | QDir.Filter.Files | QDir.Filter.NoDotAndDotDot)
# Tree view
self.tree = QTreeView()
self.tree.setModel(self.model)
self.tree.setRootIndex(self.model.index(QDir.currentPath()))
# Hide columns
self.tree.setColumnHidden(1, True)
self.tree.setColumnHidden(2, True)
self.tree.setColumnHidden(3, True)
self.tree.header().hide()
self.tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.tree.customContextMenuRequested.connect(self._show_context_menu)
self.tree.setEditTriggers(QTreeView.EditTrigger.NoEditTriggers)
self.tree.setStyleSheet("""
QTreeView {
background-color: #21222c;
color: #f8f8f2;
border: none;
font-size: 11pt;
}
QTreeView::item:hover {
background-color: #44475a;
}
QTreeView::item:selected {
background-color: #44475a;
color: #8be9fd;
}
""")
self.tree.doubleClicked.connect(self._on_item_double_clicked)
layout.addWidget(self.tree)
def _project_root(self) -> str:
return os.path.abspath(self.model.rootPath())
def _resolve_parent_directory(self, index: QModelIndex) -> str:
path = self.model.filePath(index) if index.isValid() else self.model.rootPath()
if not os.path.isdir(path):
path = os.path.dirname(path)
return os.path.abspath(path)
def _ensure_within_project(self, path: str) -> bool:
root = self._project_root()
abs_path = os.path.abspath(path)
if _is_within_root(root, abs_path):
return True
QMessageBox.critical(
self,
"Invalid Path",
"Operation blocked because the target is outside the current project root.",
)
return False
def _trash_directory(self) -> str:
base_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
if not base_dir:
base_dir = os.path.join(os.path.expanduser("~"), ".lyricflow")
trash_dir = os.path.join(base_dir, "explorer_trash")
os.makedirs(trash_dir, exist_ok=True)
return trash_dir
def _move_to_trash(self, path: str) -> str:
trash_dir = self._trash_directory()
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
name = os.path.basename(path)
target = os.path.join(trash_dir, f"{timestamp}-{name}")
counter = 1
while os.path.exists(target):
target = os.path.join(trash_dir, f"{timestamp}-{counter}-{name}")
counter += 1
shutil.move(path, target)
return target
def _show_context_menu(self, position):
index = self.tree.indexAt(position)
menu = QMenu()
rename_act = None
delete_act = None
if index.isValid():
rename_act = menu.addAction("Rename")
delete_act = menu.addAction("Delete")
action = menu.exec(self.tree.viewport().mapToGlobal(position))
if action == new_file_act:
self._new_file(index)
elif action == new_folder_act:
self._new_folder(index)
elif action == rename_act:
self._rename_item(index)
elif action == delete_act:
self._delete_item(index)
def _new_file(self, index):
path = self._resolve_parent_directory(index)
if not self._ensure_within_project(path):
return
name, ok = QInputDialog.getText(self, "New File", "Filename:")
if ok and name:
if not _is_valid_entry_name(name):
QMessageBox.critical(
self,
"Invalid Filename",
"Use a simple file name without path separators.",
)
return
if not os.path.splitext(name)[1]:
name += ".lmd"
new_path = os.path.join(path, name)
if not self._ensure_within_project(new_path):
return
try:
with open(new_path, 'w', encoding='utf-8') as f:
pass
except Exception as e:
QMessageBox.critical(self, "Error", f"Could not create file: {e}")
def _new_folder(self, index):
path = self._resolve_parent_directory(index)
if not self._ensure_within_project(path):
return
name, ok = QInputDialog.getText(self, "New Folder", "Folder Name:")
if ok and name:
if not _is_valid_entry_name(name):
QMessageBox.critical(
self,
"Invalid Folder Name",
"Use a simple folder name without path separators.",
)
return
new_path = os.path.join(path, name)
if not self._ensure_within_project(new_path):
return
try:
os.makedirs(new_path, exist_ok=True)
except Exception as e:
QMessageBox.critical(self, "Error", f"Could not create folder: {e}")
def _rename_item(self, index):
old_path = self.model.filePath(index)
if not self._ensure_within_project(old_path):
return
old_name = os.path.basename(old_path)
new_name, ok = QInputDialog.getText(self, "Rename", "New Name:", text=old_name)
if ok and new_name and new_name != old_name:
if not _is_valid_entry_name(new_name):
QMessageBox.critical(
self,
"Invalid Name",
"Use a simple name without path separators.",
)
return
new_path = os.path.join(os.path.dirname(old_path), new_name)
if not self._ensure_within_project(new_path):
return
try:
os.rename(old_path, new_path)
except Exception as e:
QMessageBox.critical(self, "Error", f"Rename failed: {e}")
def _delete_item(self, index):
path = self.model.filePath(index)
root = self._project_root()
if not self._ensure_within_project(path):
return
if os.path.abspath(path) == root:
QMessageBox.critical(self, "Blocked", "Deleting the project root is not allowed.")
return
confirm = QMessageBox.question(
self,
"Confirm Delete",
(
f"Move '{os.path.basename(path)}' to LyricFlow trash?\n\n"
"This keeps a recoverable copy outside your project."
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if confirm == QMessageBox.StandardButton.Yes:
try:
self._move_to_trash(path)
except Exception as e:
QMessageBox.critical(self, "Error", f"Delete failed: {e}")
def _on_item_double_clicked(self, index: QModelIndex):
if not self.model.isDir(index):
path = self.model.filePath(index)
self.fileSelected.emit(path)
def set_root_path(self, path: str):
path = os.path.abspath(path)
if not os.path.isdir(path):
path = QDir.currentPath()
self.model.setRootPath(path)
self.tree.setRootIndex(self.model.index(path))
self.header_label.setText(f"PROJECT: {os.path.basename(path).upper()}")

View File

@ -0,0 +1,136 @@
from typing import Optional
from datetime import datetime
from PyQt6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QListWidget,
QPlainTextEdit,
QPushButton,
QSplitter,
QLabel,
QListWidgetItem,
QMessageBox,
QWidget
)
from PyQt6.QtCore import Qt, pyqtSignal
from src.gui.theme import Theme
from src.lyricflow_core.storage.db_manager import DatabaseManager, Snapshot
class HistoryDialog(QDialog):
restore_requested = pyqtSignal(Snapshot)
def __init__(self, db_manager: DatabaseManager, file_path: str, parent=None):
super().__init__(parent)
self.db_manager = db_manager
self.file_path = file_path
self.setWindowTitle("Version History")
self.resize(800, 600)
self.setStyleSheet(f"background-color: {Theme.BACKGROUND}; color: {Theme.FOREGROUND};")
self._setup_ui()
self._load_snapshots()
def _setup_ui(self):
layout = QVBoxLayout(self)
# Header
header = QLabel(f"History for: {self.file_path}")
header.setStyleSheet("font-weight: bold; padding-bottom: 10px;")
layout.addWidget(header)
# Splitter
self.splitter = QSplitter(Qt.Orientation.Horizontal)
self.splitter.setStyleSheet(f"QSplitter::handle {{ background-color: {Theme.CURRENT_LINE}; }}")
# Snapshot List
self.list_widget = QListWidget()
self.list_widget.setStyleSheet(
f"background-color: {Theme.BACKGROUND_SECONDARY}; border: 1px solid {Theme.CURRENT_LINE};"
)
self.list_widget.itemSelectionChanged.connect(self._on_selection_changed)
self.splitter.addWidget(self.list_widget)
# Preview
preview_widget = QWidget()
preview_layout = QVBoxLayout(preview_widget)
preview_layout.setContentsMargins(0, 0, 0, 0)
self.preview_editor = QPlainTextEdit()
self.preview_editor.setReadOnly(True)
self.preview_editor.setStyleSheet(
f"background-color: {Theme.BACKGROUND_SECONDARY}; border: 1px solid {Theme.CURRENT_LINE};"
)
preview_layout.addWidget(self.preview_editor)
# Buttons
button_layout = QHBoxLayout()
self.restore_btn = QPushButton("Restore This Version")
self.restore_btn.setEnabled(False)
self.restore_btn.clicked.connect(self._on_restore_clicked)
self.restore_btn.setStyleSheet(
f"QPushButton {{ background-color: {Theme.CURRENT_LINE}; padding: 8px; border-radius: 4px; }}"
f"QPushButton:hover {{ background-color: {Theme.TAB_HOVER}; }}"
)
self.close_btn = QPushButton("Close")
self.close_btn.clicked.connect(self.accept)
self.close_btn.setStyleSheet(
f"QPushButton {{ background-color: {Theme.CURRENT_LINE}; padding: 8px; border-radius: 4px; }}"
f"QPushButton:hover {{ background-color: {Theme.TAB_HOVER}; }}"
)
button_layout.addStretch()
button_layout.addWidget(self.restore_btn)
button_layout.addWidget(self.close_btn)
preview_layout.addLayout(button_layout)
self.splitter.addWidget(preview_widget)
self.splitter.setSizes([250, 550])
layout.addWidget(self.splitter)
def _load_snapshots(self):
self.snapshots = self.db_manager.get_snapshots(self.file_path)
self.list_widget.clear()
if not self.snapshots:
self.list_widget.addItem("No versions found.")
return
for snap in self.snapshots:
dt = datetime.fromtimestamp(snap.timestamp)
label = dt.strftime("%Y-%m-%d %H:%M:%S")
item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, snap)
self.list_widget.addItem(item)
def _on_selection_changed(self):
selected = self.list_widget.selectedItems()
if not selected:
self.preview_editor.clear()
self.restore_btn.setEnabled(False)
return
snap: Snapshot | None = selected[0].data(Qt.ItemDataRole.UserRole)
if snap:
self.preview_editor.setPlainText(snap.content)
self.restore_btn.setEnabled(True)
else:
self.restore_btn.setEnabled(False)
def _on_restore_clicked(self):
selected = self.list_widget.selectedItems()
if not selected:
return
snap: Snapshot | None = selected[0].data(Qt.ItemDataRole.UserRole)
if snap:
reply = QMessageBox.question(
self,
"Restore Version",
"Are you sure you want to restore this version? This will replace the current contents of the editor.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
self.restore_requested.emit(snap)
self.accept()

View File

@ -0,0 +1,91 @@
from dataclasses import replace
from PyQt6.QtCore import pyqtSignal
from PyQt6.QtWidgets import (
QCheckBox,
QDialog,
QDialogButtonBox,
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
)
from src.lyricflow_core.storage.app_settings import AppPreferences
from src.gui.theme import Theme
class PreferencesDialog(QDialog):
clearRequested = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Preferences")
self.setStyleSheet(f"background-color: {Theme.BACKGROUND}; color: {Theme.FOREGROUND};")
self.setMinimumWidth(420)
self._prefs = AppPreferences()
root = QVBoxLayout(self)
startup_group = QGroupBox("Startup")
startup_layout = QVBoxLayout(startup_group)
self.reopen_last_project_cb = QCheckBox("Reopen last project on startup")
self.restore_unsaved_cb = QCheckBox("Restore unsaved tabs from recovered sessions")
startup_layout.addWidget(self.reopen_last_project_cb)
startup_layout.addWidget(self.restore_unsaved_cb)
root.addWidget(startup_group)
editor_group = QGroupBox("Editor")
editor_layout = QVBoxLayout(editor_group)
self.word_wrap_default_cb = QCheckBox("Enable word wrap by default")
editor_layout.addWidget(self.word_wrap_default_cb)
root.addWidget(editor_group)
appearance_group = QGroupBox("Appearance")
appearance_layout = QVBoxLayout(appearance_group)
self.show_left_sidebar_cb = QCheckBox("Show left sidebar by default")
self.show_right_sidebar_cb = QCheckBox("Show right sidebar by default")
appearance_layout.addWidget(self.show_left_sidebar_cb)
appearance_layout.addWidget(self.show_right_sidebar_cb)
root.addWidget(appearance_group)
recovery_group = QGroupBox("Recovered Session Data")
recovery_layout = QVBoxLayout(recovery_group)
recovery_layout.addWidget(
QLabel("Remove cached unsaved snapshots for the current workspace or all workspaces.")
)
buttons_row = QHBoxLayout()
clear_workspace_btn = QPushButton("Clear Current Workspace")
clear_all_btn = QPushButton("Clear All Workspaces")
clear_workspace_btn.clicked.connect(lambda: self.clearRequested.emit("workspace"))
clear_all_btn.clicked.connect(lambda: self.clearRequested.emit("all"))
buttons_row.addWidget(clear_workspace_btn)
buttons_row.addWidget(clear_all_btn)
recovery_layout.addLayout(buttons_row)
root.addWidget(recovery_group)
button_box = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
root.addWidget(button_box)
def set_values(self, prefs: AppPreferences) -> None:
self._prefs = prefs
self.reopen_last_project_cb.setChecked(prefs.reopen_last_project)
self.restore_unsaved_cb.setChecked(prefs.restore_unsaved_tabs)
self.word_wrap_default_cb.setChecked(prefs.word_wrap_default)
self.show_left_sidebar_cb.setChecked(prefs.show_left_sidebar)
self.show_right_sidebar_cb.setChecked(prefs.show_right_sidebar)
def values(self) -> AppPreferences:
return replace(
self._prefs,
reopen_last_project=self.reopen_last_project_cb.isChecked(),
restore_unsaved_tabs=self.restore_unsaved_cb.isChecked(),
word_wrap_default=self.word_wrap_default_cb.isChecked(),
show_left_sidebar=self.show_left_sidebar_cb.isChecked(),
show_right_sidebar=self.show_right_sidebar_cb.isChecked(),
)

View File

@ -0,0 +1,48 @@
from PyQt6.QtWidgets import (
QWidget,
QVBoxLayout,
QLabel,
QPlainTextEdit,
)
from PyQt6.QtCore import pyqtSignal
from src.gui.theme import Theme
from src.gui.components.editor import LyricEditor
class ScratchpadWidget(QWidget):
content_changed = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.setMinimumWidth(200)
self.setStyleSheet(f"background-color: {Theme.BACKGROUND_SECONDARY}; border-left: 1px solid {Theme.CURRENT_LINE};")
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Header
header = QLabel("Scratchpad")
header.setStyleSheet(
f"color: {Theme.FOREGROUND}; background-color: {Theme.BACKGROUND_SECONDARY}; padding: 10px; font-weight: bold; border-bottom: 1px solid {Theme.CURRENT_LINE};"
)
layout.addWidget(header)
# Editor
self.editor = LyricEditor() # Or just QPlainTextEdit if we don't need highlighting
self.editor.setStyleSheet(
f"QPlainTextEdit {{ background-color: {Theme.BACKGROUND_SECONDARY}; color: {Theme.FOREGROUND}; padding: 10px; border: none; }}"
)
layout.addWidget(self.editor)
self.editor.textChanged.connect(self._on_text_changed)
def _on_text_changed(self):
self.content_changed.emit(self.editor.toPlainText())
def set_content(self, text: str):
self.editor.blockSignals(True)
self.editor.setPlainText(text)
self.editor.blockSignals(False)
def get_content(self) -> str:
return self.editor.toPlainText()

View File

@ -0,0 +1,140 @@
from PyQt6.QtWidgets import (
QWidget,
QVBoxLayout,
QLabel,
QListWidget,
QProgressBar,
QFrame,
QScrollArea,
QApplication,
QMenu,
)
from PyQt6.QtCore import Qt, pyqtSlot, QPoint
from src.lyricflow_core.api.analysis import analysis_service
from src.gui.theme import Theme
class DensityBar(QProgressBar):
def __init__(self, parent=None):
super().__init__(parent)
self.setTextVisible(False)
self.setFixedHeight(12)
self.setStyleSheet(f"""
QProgressBar {{
background-color: {Theme.BACKGROUND_SECONDARY};
border: none;
border-radius: 6px;
}}
QProgressBar::chunk {{
background-color: {Theme.ACCENT_COLOR};
border-radius: 6px;
}}
""")
class Sidebar(QFrame):
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedWidth(300)
self.setStyleSheet(f"background-color: {Theme.BACKGROUND}; color: {Theme.FOREGROUND}; border-left: 1px solid {Theme.CURRENT_LINE};")
layout = QVBoxLayout(self)
layout.setContentsMargins(15, 15, 15, 15)
layout.setSpacing(15)
# --- Section: Selected Word & Phonetics ---
self.word_label = QLabel("Select a word...")
self.word_label.setStyleSheet(f"font-weight: bold; font-size: 16px; color: {Theme.ACCENT_COLOR};")
layout.addWidget(self.word_label)
self.phonetic_label = QLabel("")
self.phonetic_label.setStyleSheet(f"font-family: 'Monospace'; color: {Theme.PURPLE};")
self.phonetic_label.setWordWrap(True)
layout.addWidget(self.phonetic_label)
# --- Section: Rhyme Suggestions ---
layout.addWidget(QLabel("Perfect Rhymes"))
self.perfect_list = QListWidget()
self.perfect_list.setStyleSheet(f"background: transparent; border: none; color: {Theme.FOREGROUND};")
self.perfect_list.setFixedHeight(150)
self._enable_copy_context_menu(self.perfect_list)
layout.addWidget(self.perfect_list)
layout.addWidget(QLabel("Near Rhymes (Slant)"))
self.slant_list = QListWidget()
self.slant_list.setStyleSheet(f"background: transparent; border: none; color: {Theme.FOREGROUND};")
self.slant_list.setFixedHeight(120)
self._enable_copy_context_menu(self.slant_list)
layout.addWidget(self.slant_list)
layout.addWidget(QLabel("Synonyms"))
self.synonym_list = QListWidget()
self.synonym_list.setStyleSheet(f"background: transparent; border: none; color: {Theme.FOREGROUND};")
self.synonym_list.setFixedHeight(120)
self._enable_copy_context_menu(self.synonym_list)
layout.addWidget(self.synonym_list)
layout.addWidget(QLabel("Related Concepts (Vibe)"))
self.vibe_list = QListWidget()
self.vibe_list.setStyleSheet(f"background: transparent; border: none; color: {Theme.FOREGROUND};")
self.vibe_list.setFixedHeight(120)
self._enable_copy_context_menu(self.vibe_list)
layout.addWidget(self.vibe_list)
def _enable_copy_context_menu(self, list_widget: QListWidget) -> None:
list_widget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
list_widget.customContextMenuRequested.connect(
lambda pos, lw=list_widget: self._show_list_context_menu(lw, pos)
)
def _show_list_context_menu(self, list_widget: QListWidget, position: QPoint) -> None:
item = list_widget.itemAt(position)
if item is None:
return
value = item.text().strip()
if not value:
return
menu = QMenu(self)
menu.setStyleSheet(
f"""
QMenu {{ background-color: {Theme.BACKGROUND_SECONDARY}; color: {Theme.FOREGROUND}; border: 1px solid {Theme.CURRENT_LINE}; }}
QMenu::item:selected {{ background-color: {Theme.CURRENT_LINE}; }}
"""
)
copy_action = menu.addAction(f"Copy '{value}'")
chosen = menu.exec(list_widget.viewport().mapToGlobal(position))
if chosen == copy_action:
QApplication.clipboard().setText(value)
@pyqtSlot(str)
def on_word_selected(self, word):
if not word: return
self.word_label.setText(word.upper())
# Get phonemes
phones = analysis_service.phonemes(word)
if phones:
self.phonetic_label.setText(" ".join(phones[0]))
else:
self.phonetic_label.setText("No phonetic data")
# Get rhyming suggestions
suggestions = analysis_service.suggestions(word) or {}
self.perfect_list.clear()
self.perfect_list.addItems(suggestions.get("perfect", []))
self.slant_list.clear()
self.slant_list.addItems(suggestions.get("slant", []))
# Get synonyms and vibe
results = analysis_service.synonyms(word) or {}
self.synonym_list.clear()
self.synonym_list.addItems(results.get("synonyms", []))
self.vibe_list.clear()
self.vibe_list.addItems(results.get("vibe", []))
@pyqtSlot(str)
def update_density(self, text):
# This acts as the debounced analysis results callback
pass

950
src/gui/main_window.py Normal file
View File

@ -0,0 +1,950 @@
from datetime import datetime, timezone
import os
from typing import Literal
from PyQt6.QtCore import QByteArray, QTimer, Qt
from PyQt6.QtGui import QAction, QCloseEvent, QKeySequence, QTextCursor
from PyQt6.QtWidgets import (
QFileDialog,
QInputDialog,
QMainWindow,
QMessageBox,
QPlainTextEdit,
QSplitter,
QTabWidget,
QVBoxLayout,
QWidget,
)
from .components.editor import LyricEditor
from .components.explorer import ProjectExplorer
from .components.preferences_dialog import PreferencesDialog
from .components.sidebar import Sidebar
from .components.scratchpad import ScratchpadWidget
from .components.history_dialog import HistoryDialog
from .theme import Theme
from src.lyricflow_core.api.project_state import ProjectState, project_state_service
from src.lyricflow_core.storage.app_settings import AppPreferences, AppSettingsStore
from src.lyricflow_core.storage.file_manager import FileManager
from src.lyricflow_core.storage.session_store import SessionStore, SessionTabSnapshot
from src.lyricflow_core.storage.db_manager import DatabaseManager
ConflictResolution = Literal["snapshot", "disk", "skip"]
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.file_manager = FileManager()
self.editors: dict[str, LyricEditor] = {}
self.current_project_root: str | None = None
self._left_sidebar_width = 250
self._right_sidebar_width = 250
self.word_wrap_enabled = False
self.app_settings = AppSettingsStore()
self.session_store = SessionStore()
self.preferences: AppPreferences = self.app_settings.load()
self.db_manager: DatabaseManager | None = None
self._setup_db_manager()
self.setWindowTitle("LyricFlow IDE")
self.resize(1300, 850)
central_widget = QWidget()
self.setCentralWidget(central_widget)
layout = QVBoxLayout(central_widget)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.splitter = QSplitter(Qt.Orientation.Horizontal)
self.splitter.setHandleWidth(1)
self.splitter.setStyleSheet(f"QSplitter::handle {{ background-color: {Theme.CURRENT_LINE}; }}")
self.explorer = ProjectExplorer()
self.explorer.setMinimumWidth(200)
self.explorer.fileSelected.connect(self.open_file_path)
self.tabs = QTabWidget()
self.tabs.setTabsClosable(True)
self.tabs.setMovable(True)
self.tabs.tabCloseRequested.connect(self.close_tab)
self.tabs.currentChanged.connect(self._on_tab_changed)
self.tabs.setStyleSheet(
f"""
QTabWidget::pane {{ border: none; background: {Theme.BACKGROUND}; }}
QTabBar::tab {{
background: {Theme.TAB_INACTIVE};
color: {Theme.COMMENT};
padding: 8px 15px;
border-right: 1px solid {Theme.BACKGROUND};
font-size: 10pt;
}}
QTabBar::tab:selected {{
background: {Theme.BACKGROUND};
color: {Theme.FOREGROUND};
border-bottom: 2px solid {Theme.TAB_BORDER};
}}
QTabBar::tab:hover {{ background: {Theme.TAB_HOVER}; }}
"""
)
self.sidebar = Sidebar()
self.sidebar.setMinimumWidth(250)
self.scratchpad = ScratchpadWidget()
self.scratchpad.hide() # Hidden by default
self.scratchpad.content_changed.connect(self._save_scratchpad)
self.splitter.addWidget(self.explorer)
self.splitter.addWidget(self.tabs)
self.splitter.addWidget(self.scratchpad)
self.splitter.addWidget(self.sidebar)
self.splitter.setSizes([250, 600, 200, 250])
layout.addWidget(self.splitter)
self._create_menu_bar()
self.statusBar().showMessage("Ready")
self.statusBar().setStyleSheet(
f"background-color: {Theme.STATUS_BAR_BG}; color: {Theme.COMMENT}; border-top: 1px solid {Theme.CURRENT_LINE};"
)
self._apply_preferences_to_ui()
self._restore_startup_state()
self._session_autosave_timer = QTimer(self)
self._session_autosave_timer.setInterval(30_000)
self._session_autosave_timer.timeout.connect(self._save_session_snapshots)
self._session_autosave_timer.start()
def _setup_db_manager(self):
if self.current_project_root:
db_path = os.path.join(self.current_project_root, ".lyricflow.db")
else:
db_path = os.path.join(os.path.expanduser("~"), ".lyricflow", "global.db")
self.db_manager = DatabaseManager(db_path)
def _create_menu_bar(self):
menu_bar = self.menuBar()
menu_bar.setStyleSheet(
f"""
QMenuBar {{ background-color: {Theme.STATUS_BAR_BG}; color: {Theme.FOREGROUND}; padding: 2px; }}
QMenuBar::item:selected {{ background-color: {Theme.TAB_HOVER}; border-radius: 3px; }}
QMenu {{ background-color: {Theme.BACKGROUND}; color: {Theme.FOREGROUND}; border: 1px solid {Theme.CURRENT_LINE}; }}
QMenu::item:selected {{ background-color: {Theme.TAB_HOVER}; }}
"""
)
file_menu = menu_bar.addMenu("&File")
file_menu.addAction("&New File", "Ctrl+N", self.new_file)
file_menu.addAction("&Open File...", "Ctrl+O", self.open_file)
file_menu.addAction("Open &Folder...", "Ctrl+Shift+O", self.open_folder)
file_menu.addAction("Open &Project...", self.open_project)
file_menu.addSeparator()
file_menu.addAction("&Save", "Ctrl+S", self.save_file)
file_menu.addAction("Save Project", self.save_project)
file_menu.addAction("Close Project", self.close_project)
file_menu.addSeparator()
file_menu.addAction("Version &History...", self.show_history_dialog)
file_menu.addSeparator()
file_menu.addAction("E&xit", self.close)
edit_menu = menu_bar.addMenu("&Edit")
edit_menu.addAction(
"&Undo",
"Ctrl+Z",
lambda: self.current_editor().undo() if self.current_editor() else None,
)
edit_menu.addAction(
"&Redo",
"Ctrl+Y",
lambda: self.current_editor().redo() if self.current_editor() else None,
)
edit_menu.addSeparator()
edit_menu.addAction(
"Cu&t",
"Ctrl+X",
lambda: self.current_editor().cut() if self.current_editor() else None,
)
edit_menu.addAction(
"&Copy",
"Ctrl+C",
lambda: self.current_editor().copy() if self.current_editor() else None,
)
edit_menu.addAction(
"&Paste",
"Ctrl+V",
lambda: self.current_editor().paste() if self.current_editor() else None,
)
edit_menu.addSeparator()
edit_menu.addAction("&Find", "Ctrl+F", self.find_text)
edit_menu.addAction(
"&Toggle Line Comment",
"Ctrl+/",
lambda: self.current_editor().toggle_line_comment() if self.current_editor() else None,
)
selection_menu = menu_bar.addMenu("&Selection")
selection_menu.addAction(
"Select &All",
"Ctrl+A",
lambda: self.current_editor().selectAll() if self.current_editor() else None,
)
selection_menu.addSeparator()
selection_menu.addAction(
"Move Line &Up",
"Alt+Up",
lambda: self.current_editor().move_line_up() if self.current_editor() else None,
)
selection_menu.addAction(
"Move Line &Down",
"Alt+Down",
lambda: self.current_editor().move_line_down() if self.current_editor() else None,
)
selection_menu.addAction("&Duplicate Selection", "Ctrl+D", self.duplicate_selection)
view_menu = menu_bar.addMenu("&View")
zoom_in_action = QAction("Zoom &In", self)
zoom_in_action.setShortcuts(
[
QKeySequence(QKeySequence.StandardKey.ZoomIn),
QKeySequence("Ctrl++"),
QKeySequence("Ctrl+="),
]
)
zoom_in_action.triggered.connect(self.zoom_in_current_editor)
view_menu.addAction(zoom_in_action)
zoom_out_action = QAction("Zoom &Out", self)
zoom_out_action.setShortcuts(
[
QKeySequence(QKeySequence.StandardKey.ZoomOut),
QKeySequence("Ctrl+-"),
]
)
zoom_out_action.triggered.connect(self.zoom_out_current_editor)
view_menu.addAction(zoom_out_action)
view_menu.addSeparator()
appearance_menu = view_menu.addMenu("&Appearance")
self.left_sidebar_action = QAction("Show &Left Sidebar", self)
self.left_sidebar_action.setCheckable(True)
self.left_sidebar_action.setChecked(True)
self.left_sidebar_action.toggled.connect(self.set_left_sidebar_visible)
appearance_menu.addAction(self.left_sidebar_action)
self.right_sidebar_action = QAction("Show &Right Sidebar", self)
self.right_sidebar_action.setCheckable(True)
self.right_sidebar_action.setChecked(True)
self.right_sidebar_action.toggled.connect(self.set_right_sidebar_visible)
appearance_menu.addAction(self.right_sidebar_action)
self.scratchpad_action = QAction("Show &Scratchpad", self)
self.scratchpad_action.setCheckable(True)
self.scratchpad_action.setChecked(False)
self.scratchpad_action.toggled.connect(self.set_scratchpad_visible)
appearance_menu.addAction(self.scratchpad_action)
view_menu.addSeparator()
self.word_wrap_action = QAction("&Word Wrap", self)
self.word_wrap_action.setCheckable(True)
self.word_wrap_action.toggled.connect(self.toggle_word_wrap)
view_menu.addAction(self.word_wrap_action)
settings_menu = menu_bar.addMenu("&Settings")
preferences_action = QAction("&Preferences...", self)
preferences_action.setShortcut(QKeySequence("Ctrl+,"))
preferences_action.triggered.connect(self.open_preferences)
settings_menu.addAction(preferences_action)
def current_editor(self) -> LyricEditor | None:
widget = self.tabs.currentWidget()
return widget if isinstance(widget, LyricEditor) else None
def _iter_open_editors(self):
for idx in range(self.tabs.count()):
widget = self.tabs.widget(idx)
if isinstance(widget, LyricEditor):
yield widget
def _path_for_editor(self, editor: LyricEditor) -> str | None:
for path, target in self.editors.items():
if target is editor:
return path
return None
def _dirty_editors(self) -> list[LyricEditor]:
dirty: list[LyricEditor] = []
for editor in self._iter_open_editors():
if editor.document().isModified():
dirty.append(editor)
return dirty
def _confirm_unsaved_changes_for_tab(self, editor: LyricEditor) -> bool:
if not editor.document().isModified():
return True
tab_index = self.tabs.indexOf(editor)
label = self.tabs.tabText(tab_index) if tab_index >= 0 else "Untitled"
response = QMessageBox.question(
self,
"Unsaved Changes",
f"Save changes to '{label}' before closing this tab?",
QMessageBox.StandardButton.Save
| QMessageBox.StandardButton.Discard
| QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Save,
)
if response == QMessageBox.StandardButton.Cancel:
return False
if response == QMessageBox.StandardButton.Discard:
return True
current_index = self.tabs.currentIndex()
if tab_index >= 0:
self.tabs.setCurrentIndex(tab_index)
self.save_file()
if tab_index >= 0 and current_index >= 0 and current_index < self.tabs.count():
self.tabs.setCurrentIndex(current_index)
return not editor.document().isModified()
def _confirm_unsaved_changes_for_scope(self, scope: str) -> bool:
dirty = self._dirty_editors()
if not dirty:
return True
response = QMessageBox.question(
self,
"Unsaved Changes",
(
f"There are {len(dirty)} unsaved tab(s). "
f"Save all changes before {scope}?"
),
QMessageBox.StandardButton.Save
| QMessageBox.StandardButton.Discard
| QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Save,
)
if response == QMessageBox.StandardButton.Cancel:
return False
if response == QMessageBox.StandardButton.Discard:
for editor in dirty:
editor.document().setModified(False)
return True
original_index = self.tabs.currentIndex()
for editor in dirty:
tab_index = self.tabs.indexOf(editor)
if tab_index < 0:
continue
self.tabs.setCurrentIndex(tab_index)
self.save_file()
if editor.document().isModified():
if original_index >= 0 and original_index < self.tabs.count():
self.tabs.setCurrentIndex(original_index)
return False
if original_index >= 0 and original_index < self.tabs.count():
self.tabs.setCurrentIndex(original_index)
return True
def new_file(self):
editor = LyricEditor()
self._setup_editor(editor)
idx = self.tabs.addTab(editor, "Untitled")
self.tabs.setCurrentIndex(idx)
def _setup_editor(self, editor: LyricEditor):
editor.wordSelected.connect(self.sidebar.on_word_selected)
editor.textChangedDebounced.connect(self.sidebar.update_density)
editor.setStyleSheet(
f"QPlainTextEdit {{ background-color: {Theme.BACKGROUND}; color: {Theme.FOREGROUND}; padding: 20px; border: none; }}"
)
self._apply_word_wrap_mode(editor)
def _apply_word_wrap_mode(self, editor: LyricEditor):
if self.word_wrap_enabled:
editor.setLineWrapMode(QPlainTextEdit.LineWrapMode.WidgetWidth)
else:
editor.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
def zoom_in_current_editor(self):
editor = self.current_editor()
if editor:
editor.zoom_in()
def zoom_out_current_editor(self):
editor = self.current_editor()
if editor:
editor.zoom_out()
def set_left_sidebar_visible(self, visible: bool):
sizes = self.splitter.sizes()
if len(sizes) < 3:
self.explorer.setVisible(visible)
return
if visible:
self.explorer.show()
if sizes[0] == 0:
target = max(self.explorer.minimumWidth(), self._left_sidebar_width)
sizes[0] = target
sizes[1] = max(200, sizes[1] - target)
self.splitter.setSizes(sizes)
else:
if sizes[0] > 0:
self._left_sidebar_width = sizes[0]
self.explorer.hide()
sizes[1] += sizes[0]
sizes[0] = 0
self.splitter.setSizes(sizes)
def set_right_sidebar_visible(self, visible: bool):
sizes = self.splitter.sizes()
if len(sizes) < 4:
self.sidebar.setVisible(visible)
return
if visible:
self.sidebar.show()
if sizes[3] == 0:
target = max(self.sidebar.minimumWidth(), self._right_sidebar_width)
sizes[3] = target
sizes[1] = max(200, sizes[1] - target)
self.splitter.setSizes(sizes)
else:
if sizes[3] > 0:
self._right_sidebar_width = sizes[3]
self.sidebar.hide()
sizes[1] += sizes[3]
sizes[3] = 0
self.splitter.setSizes(sizes)
def set_scratchpad_visible(self, visible: bool):
sizes = self.splitter.sizes()
if len(sizes) < 4:
self.scratchpad.setVisible(visible)
return
if visible:
self.scratchpad.show()
if sizes[2] == 0:
target = max(self.scratchpad.minimumWidth(), 200)
sizes[2] = target
sizes[1] = max(200, sizes[1] - target)
self.splitter.setSizes(sizes)
else:
if sizes[2] > 0:
# Store minimum width if we wanted to
pass
self.scratchpad.hide()
sizes[1] += sizes[2]
sizes[2] = 0
self.splitter.setSizes(sizes)
def _save_scratchpad(self, content: str):
if self.db_manager:
project_id = self.current_project_root or "global"
self.db_manager.save_scratchpad(project_id, content)
def show_history_dialog(self):
editor = self.current_editor()
if not editor:
QMessageBox.information(self, "History", "No active file to show history for.")
return
current_path = self._path_for_editor(editor)
if not current_path or not self.db_manager:
QMessageBox.information(self, "History", "File has no history context.")
return
dialog = HistoryDialog(self.db_manager, current_path, self)
def on_restore(snap: Snapshot):
editor.setPlainText(snap.content)
editor.document().setModified(True)
self.statusBar().showMessage(f"Restored version from {datetime.fromtimestamp(snap.timestamp).strftime('%Y-%m-%d %H:%M:%S')}")
dialog.restore_requested.connect(on_restore)
dialog.exec()
def open_file_path(self, path: str):
path = os.path.abspath(path)
if path in self.editors:
self.tabs.setCurrentWidget(self.editors[path])
return
content, msg = self.file_manager.load_file(path)
if content is not None:
editor = LyricEditor()
self._setup_editor(editor)
editor.setPlainText(content)
editor.document().setModified(False)
self.editors[path] = editor
idx = self.tabs.addTab(editor, os.path.basename(path))
self.tabs.setCurrentIndex(idx)
self.tabs.setTabToolTip(idx, path)
self.statusBar().showMessage(f"Opened {path}")
else:
QMessageBox.critical(self, "Error", msg)
def open_file(self):
path, _ = QFileDialog.getOpenFileName(
self,
"Open File",
"",
"All Supported (*.lmd *.txt *.lyricproject);;LyricMarkdown (*.lmd);;Text Files (*.txt);;Project (*.lyricproject);;All Files (*)",
)
if path:
if path.endswith(".lyricproject"):
loaded = self.load_project(path)
if loaded and self.preferences.restore_unsaved_tabs:
self._restore_session_snapshots()
else:
self.open_file_path(path)
def open_project(self):
path, _ = QFileDialog.getOpenFileName(
self, "Open LyricProject", "", "LyricProject (*.lyricproject)"
)
if path:
loaded = self.load_project(path)
if loaded and self.preferences.restore_unsaved_tabs:
self._restore_session_snapshots()
def open_folder(self):
folder = QFileDialog.getExistingDirectory(self, "Open Project Folder")
if folder:
if not self.close_project():
return
self.current_project_root = os.path.abspath(folder)
self.explorer.set_root_path(self.current_project_root)
self.preferences.last_project_file = os.path.join(
self.current_project_root, ".lyricproject"
)
self.statusBar().showMessage(f"Project: {self.current_project_root}")
self._setup_db_manager()
project_file = os.path.join(self.current_project_root, ".lyricproject")
if os.path.exists(project_file):
self._load_project_data(project_file)
if self.preferences.restore_unsaved_tabs:
self._restore_session_snapshots()
self._save_preferences()
def save_file(self):
editor = self.current_editor()
if not editor:
return
current_path = self._path_for_editor(editor)
if not current_path:
path, _ = QFileDialog.getSaveFileName(
self,
"Save Lyric File",
"",
"LyricMarkdown (*.lmd);;Text Files (*.txt);;All Files (*)",
)
if not path:
return
if not os.path.splitext(path)[1]:
path += ".lmd"
current_path = os.path.abspath(path)
self.editors[current_path] = editor
idx = self.tabs.currentIndex()
self.tabs.setTabText(idx, os.path.basename(current_path))
self.tabs.setTabToolTip(idx, current_path)
self.file_manager.current_file = current_path
success, msg = self.file_manager.save_file(editor.toPlainText())
if success:
editor.document().setModified(False)
self.statusBar().showMessage(msg)
if self.db_manager:
self.db_manager.save_snapshot(current_path, editor.toPlainText())
self._save_session_snapshots()
if self.current_project_root:
self.save_project()
else:
QMessageBox.critical(self, "Error", msg)
def save_project(self):
if not self.current_project_root:
return
active_editor = self.current_editor()
active_path = self._path_for_editor(active_editor) if active_editor else None
cursor_positions = {
path: editor.textCursor().position() for path, editor in self.editors.items()
}
project_state = ProjectState(
version=2,
name=os.path.basename(self.current_project_root),
open_files=[p for p in self.editors.keys()],
active_file=active_path,
cursor_positions=cursor_positions,
scratchpad_open=self.scratchpad_action.isChecked(),
)
project_file = os.path.join(self.current_project_root, ".lyricproject")
try:
project_state_service.write_project(project_file, project_state)
self.preferences.last_project_file = project_file
self.statusBar().showMessage(f"Project saved to {project_file}")
except Exception as e:
self.statusBar().showMessage(f"Failed to save project: {e}")
def load_project(self, project_file: str):
if not self.close_project():
return False
self._load_project_data(project_file)
return True
@staticmethod
def _extract_cursor_positions(data: dict) -> dict[str, int]:
return project_state_service.parse_cursor_positions(data.get("cursor_positions"))
def _load_project_data(self, project_file: str):
project_file = os.path.abspath(project_file)
try:
self.current_project_root = os.path.dirname(project_file)
self.preferences.last_project_file = project_file
self.explorer.set_root_path(self.current_project_root)
project_state = project_state_service.read_project(project_file)
cursor_positions = project_state.cursor_positions
for path in project_state.open_files:
if not isinstance(path, str):
continue
abs_path = os.path.abspath(path)
if os.path.exists(abs_path):
self.open_file_path(abs_path)
if abs_path in self.editors and abs_path in cursor_positions:
self._set_editor_cursor(self.editors[abs_path], cursor_positions[abs_path])
self.editors[abs_path].document().setModified(False)
if isinstance(project_state.active_file, str):
abs_active = os.path.abspath(project_state.active_file)
if abs_active in self.editors:
self.tabs.setCurrentWidget(self.editors[abs_active])
if self.db_manager:
project_id = self.current_project_root or "global"
content = self.db_manager.get_scratchpad(project_id)
self.scratchpad.set_content(content)
self.scratchpad_action.setChecked(project_state.scratchpad_open)
self.set_scratchpad_visible(project_state.scratchpad_open)
self._save_preferences()
self.statusBar().showMessage(f"Project Loaded: {self.current_project_root}")
except Exception as e:
QMessageBox.warning(self, "Project Load Error", f"Could not load project: {e}")
def close_project(self):
if not self.editors and not self.current_project_root and self.tabs.count() == 0:
return True
if not self._confirm_unsaved_changes_for_scope("closing the project"):
return False
self._save_session_snapshots()
if self.current_project_root:
self.save_project()
self.tabs.clear()
self.editors.clear()
self.current_project_root = None
self._setup_db_manager()
self.explorer.set_root_path(os.getcwd())
self.setWindowTitle("LyricFlow IDE")
self.statusBar().showMessage("Project Closed")
return True
def close_tab(self, index: int):
widget = self.tabs.widget(index)
if isinstance(widget, LyricEditor):
if not self._confirm_unsaved_changes_for_tab(widget):
return
for path, editor in list(self.editors.items()):
if editor == widget:
del self.editors[path]
self.tabs.removeTab(index)
self._save_session_snapshots()
if self.current_project_root:
self.save_project()
def _on_tab_changed(self, index: int):
editor = self.current_editor()
if editor:
self.sidebar.update_density(editor.toPlainText())
filename = self.tabs.tabText(index)
self.setWindowTitle(f"LyricFlow IDE - {filename}")
else:
self.setWindowTitle("LyricFlow IDE")
def find_text(self):
editor = self.current_editor()
if not editor:
return
text, ok = QInputDialog.getText(self, "Find", "Search For:")
if ok and text:
if not editor.find(text):
cursor = editor.textCursor()
cursor.movePosition(QTextCursor.MoveOperation.Start)
editor.setTextCursor(cursor)
if not editor.find(text):
self.statusBar().showMessage(f"No results for '{text}'")
def duplicate_selection(self):
editor = self.current_editor()
if not editor:
return
cursor = editor.textCursor()
if cursor.hasSelection():
text = cursor.selectedText()
cursor.setPosition(cursor.selectionEnd())
cursor.insertText("\n" + text)
else:
cursor.select(QTextCursor.SelectionType.LineUnderCursor)
text = cursor.selectedText()
cursor.movePosition(QTextCursor.MoveOperation.EndOfBlock)
cursor.insertText("\n" + text)
editor.setTextCursor(cursor)
def toggle_word_wrap(self, enabled: bool):
self.word_wrap_enabled = enabled
for editor in self._iter_open_editors():
self._apply_word_wrap_mode(editor)
def open_preferences(self):
dialog = PreferencesDialog(self)
dialog.set_values(self.preferences)
dialog.clearRequested.connect(self.clear_recovered_session_data)
if dialog.exec():
self.preferences = dialog.values()
self._apply_preferences_to_ui()
self._save_preferences()
self.statusBar().showMessage("Preferences updated")
def clear_recovered_session_data(self, scope: str):
if scope == "workspace":
self.session_store.save(self._current_workspace_root(), [])
self.statusBar().showMessage("Recovered session data cleared for current workspace")
elif scope == "all":
self.session_store.clear()
self.statusBar().showMessage("Recovered session data cleared for all workspaces")
def _apply_preferences_to_ui(self):
if self.preferences.window_geometry:
self.restoreGeometry(QByteArray(self.preferences.window_geometry))
if self.preferences.splitter_sizes and len(self.preferences.splitter_sizes) == 3:
self.splitter.setSizes([int(v) for v in self.preferences.splitter_sizes])
self.word_wrap_action.blockSignals(True)
self.word_wrap_action.setChecked(bool(self.preferences.word_wrap_default))
self.word_wrap_action.blockSignals(False)
self.toggle_word_wrap(bool(self.preferences.word_wrap_default))
self.left_sidebar_action.blockSignals(True)
self.left_sidebar_action.setChecked(bool(self.preferences.show_left_sidebar))
self.left_sidebar_action.blockSignals(False)
self.set_left_sidebar_visible(bool(self.preferences.show_left_sidebar))
self.right_sidebar_action.blockSignals(True)
self.right_sidebar_action.setChecked(bool(self.preferences.show_right_sidebar))
self.right_sidebar_action.blockSignals(False)
self.set_right_sidebar_visible(bool(self.preferences.show_right_sidebar))
def _restore_startup_state(self):
if (
self.preferences.reopen_last_project
and self.preferences.last_project_file
and os.path.exists(self.preferences.last_project_file)
):
self.load_project(self.preferences.last_project_file)
if self.preferences.restore_unsaved_tabs:
self._restore_session_snapshots()
def _save_preferences(self):
self.preferences.word_wrap_default = bool(self.word_wrap_enabled)
self.preferences.show_left_sidebar = bool(self.left_sidebar_action.isChecked())
self.preferences.show_right_sidebar = bool(self.right_sidebar_action.isChecked())
self.preferences.window_geometry = bytes(self.saveGeometry())
self.preferences.splitter_sizes = self.splitter.sizes()
if self.current_project_root:
self.preferences.last_project_file = os.path.join(
self.current_project_root, ".lyricproject"
)
self.app_settings.save(self.preferences)
def _current_workspace_root(self) -> str | None:
if self.current_project_root:
return os.path.abspath(self.current_project_root)
return None
def _collect_session_snapshots(self) -> list[SessionTabSnapshot]:
snapshots: list[SessionTabSnapshot] = []
workspace_root = self._current_workspace_root()
updated_at = datetime.now(timezone.utc).isoformat()
for index in range(self.tabs.count()):
widget = self.tabs.widget(index)
if not isinstance(widget, LyricEditor):
continue
file_path = self._path_for_editor(widget)
is_untitled = file_path is None
is_dirty = bool(widget.document().isModified())
content = widget.toPlainText()
if is_untitled:
if not content.strip():
continue
elif not is_dirty:
continue
snapshot_mtime = None
if file_path and os.path.exists(file_path):
try:
snapshot_mtime = os.path.getmtime(file_path)
except OSError:
snapshot_mtime = None
tab_title = self.tabs.tabText(index) or ("Untitled" if is_untitled else os.path.basename(file_path))
snapshots.append(
SessionTabSnapshot(
tab_id=f"{file_path or 'untitled'}::{index}",
file_path=file_path,
display_name=tab_title,
content=content,
cursor_position=widget.textCursor().position(),
is_dirty=is_dirty,
is_untitled=is_untitled,
snapshot_mtime=snapshot_mtime,
workspace_root=workspace_root,
updated_at=updated_at,
)
)
return snapshots
def _save_session_snapshots(self):
if not self.preferences.restore_unsaved_tabs:
return
snapshots = self._collect_session_snapshots()
self.session_store.save(self._current_workspace_root(), snapshots)
def _restore_session_snapshots(self):
snapshots = self.session_store.load(self._current_workspace_root())
if not snapshots:
return
for snapshot in snapshots:
if snapshot.file_path and not snapshot.is_untitled:
self._restore_snapshot_for_file(snapshot)
else:
self._restore_snapshot_as_untitled(snapshot)
def _restore_snapshot_for_file(self, snapshot: SessionTabSnapshot):
if not snapshot.file_path:
self._restore_snapshot_as_untitled(snapshot)
return
file_path = os.path.abspath(snapshot.file_path)
if not os.path.exists(file_path):
self._restore_snapshot_as_untitled(snapshot, f"{snapshot.display_name} (Recovered)")
return
if snapshot.snapshot_mtime is not None:
try:
current_mtime = os.path.getmtime(file_path)
except OSError:
current_mtime = snapshot.snapshot_mtime
if current_mtime > snapshot.snapshot_mtime + 1e-9:
decision = self._prompt_snapshot_conflict(file_path)
if decision == "skip":
return
if decision == "disk":
self.open_file_path(file_path)
return
if file_path in self.editors:
editor = self.editors[file_path]
else:
self.open_file_path(file_path)
editor = self.editors.get(file_path)
if not editor:
return
editor.setPlainText(snapshot.content)
self._set_editor_cursor(editor, snapshot.cursor_position)
editor.document().setModified(True)
self.tabs.setCurrentWidget(editor)
def _restore_snapshot_as_untitled(
self, snapshot: SessionTabSnapshot, tab_title: str | None = None
):
editor = LyricEditor()
self._setup_editor(editor)
editor.setPlainText(snapshot.content)
self._set_editor_cursor(editor, snapshot.cursor_position)
editor.document().setModified(True)
label = tab_title or snapshot.display_name or "Recovered"
idx = self.tabs.addTab(editor, label)
if snapshot.file_path:
self.tabs.setTabToolTip(idx, snapshot.file_path)
self.tabs.setCurrentIndex(idx)
def _prompt_snapshot_conflict(self, file_path: str) -> ConflictResolution:
message = QMessageBox(self)
message.setIcon(QMessageBox.Icon.Warning)
message.setWindowTitle("Recovered Session Conflict")
message.setText(
f"'{os.path.basename(file_path)}' changed on disk since the recovered snapshot."
)
message.setInformativeText("Choose which version to open.")
snapshot_btn = message.addButton(
"Use Recovered Snapshot", QMessageBox.ButtonRole.AcceptRole
)
disk_btn = message.addButton("Use Disk Version", QMessageBox.ButtonRole.DestructiveRole)
skip_btn = message.addButton("Skip This Tab", QMessageBox.ButtonRole.RejectRole)
message.exec()
clicked = message.clickedButton()
if clicked == snapshot_btn:
return "snapshot"
if clicked == disk_btn:
return "disk"
if clicked == skip_btn:
return "skip"
return "skip"
def _set_editor_cursor(self, editor: LyricEditor, position: int):
cursor = editor.textCursor()
clamped = max(0, min(position, len(editor.toPlainText())))
cursor.setPosition(clamped)
editor.setTextCursor(cursor)
def closeEvent(self, event: QCloseEvent):
if not self._confirm_unsaved_changes_for_scope("exiting"):
event.ignore()
return
self._save_session_snapshots()
if self.current_project_root:
self.save_project()
self._save_preferences()
super().closeEvent(event)

43
src/gui/theme.py Normal file
View File

@ -0,0 +1,43 @@
from PyQt6.QtGui import QColor
class Theme:
# Dracula Palette
BACKGROUND = "#282a36"
CURRENT_LINE = "#44475a"
FOREGROUND = "#f8f8f2"
COMMENT = "#6272a4"
CYAN = "#8be9fd"
GREEN = "#50fa7b"
ORANGE = "#ffb86c"
PINK = "#ff79c6"
PURPLE = "#bd93f9"
RED = "#ff5555"
YELLOW = "#f1fa8c"
# Semantic aliases
HEADER = PURPLE
METADATA = COMMENT
TAG = PINK
BACKGROUND_SECONDARY = CURRENT_LINE
ACCENT_COLOR = CYAN
SPELLCHECK_ERROR = RED
STATUS_BAR_BG = "#191a21"
TAB_INACTIVE = "#191a21"
TAB_ACTIVE = BACKGROUND
TAB_HOVER = CURRENT_LINE
TAB_BORDER = PURPLE
RHYME_COLORS = [
"#ff5555", # Red
"#50fa7b", # Green
"#8be9fd", # Blue/Cyan
"#f1fa8c", # Yellow
"#ff79c6", # Magenta/Pink
"#bd93f9", # Purple
"#ffb86c", # Orange
]
@classmethod
def get_rhyme_color(cls, index: int) -> QColor:
hex_color = cls.RHYME_COLORS[index % len(cls.RHYME_COLORS)]
return QColor(hex_color)

View File

@ -0,0 +1,43 @@
"""LyricFlow shared core logic package."""
from .api import (
LyricAnalysisService,
LyricFlowCoreFacade,
ProjectState,
ProjectStateService,
analysis_service,
core_api,
project_state_service,
)
from .engine.phonetics import PhoneticProcessor, processor
from .engine.rhyme_engine import RhymeEngine, engine
from .engine.spellcheck import SpellcheckEngine, spellcheck
from .storage.app_settings import AppPreferences, AppSettingsStore
from .storage.file_manager import FileManager
from .storage.session_store import (
GLOBAL_WORKSPACE_KEY,
SessionStore,
SessionTabSnapshot,
)
__all__ = [
"AppPreferences",
"AppSettingsStore",
"FileManager",
"GLOBAL_WORKSPACE_KEY",
"LyricAnalysisService",
"LyricFlowCoreFacade",
"PhoneticProcessor",
"ProjectState",
"ProjectStateService",
"RhymeEngine",
"SessionStore",
"SessionTabSnapshot",
"SpellcheckEngine",
"analysis_service",
"core_api",
"engine",
"project_state_service",
"processor",
"spellcheck",
]

View File

@ -0,0 +1,14 @@
from .analysis import LyricAnalysisService, analysis_service
from .facade import LyricFlowCoreFacade, core_api
from .project_state import ProjectState, ProjectStateService, project_state_service
__all__ = [
"LyricAnalysisService",
"LyricFlowCoreFacade",
"ProjectState",
"ProjectStateService",
"analysis_service",
"core_api",
"project_state_service",
]

View File

@ -0,0 +1,56 @@
from src.lyricflow_core.engine.phonetics import PhoneticProcessor, processor
from src.lyricflow_core.engine.rhyme_engine import RhymeEngine, engine
from src.lyricflow_core.engine.spellcheck import SpellcheckEngine, spellcheck
class LyricAnalysisService:
"""Stable analysis API for desktop and mobile clients."""
def __init__(
self,
rhyme_engine: RhymeEngine | None = None,
phonetic_processor: PhoneticProcessor | None = None,
spellcheck_engine: SpellcheckEngine | None = None,
):
self._engine = rhyme_engine or engine
self._processor = phonetic_processor or processor
self._spellcheck = spellcheck_engine or spellcheck
def normalize_word(self, word: str) -> str:
return self._processor.normalize_word(word)
def phonemes(self, word: str) -> tuple[tuple[str, ...], ...]:
return self._processor.get_phonemes(word)
def count_syllables(self, word: str) -> int:
return self._engine.count_syllables(word)
def rhyme_groups(self, text: str) -> list[dict]:
return self._engine.get_rhyme_groups(text)
def line_densities(self, text: str) -> list[float]:
return self._engine.get_line_densities(text)
def suggestions(self, word: str, limit: int = 20) -> dict[str, list[str]]:
return self._engine.find_suggestions(word, limit=limit)
def synonyms(self, word: str, limit: int = 15) -> dict[str, list[str]]:
return self._engine.find_synonyms(word, limit=limit)
def similarity(self, word1: str, word2: str) -> float:
return self._engine.calculate_similarity(word1, word2)
def is_known_word(self, word: str) -> bool:
return self._spellcheck.is_known_word(word)
def spelling_suggestions(self, word: str, limit: int = 6) -> list[str]:
return self._spellcheck.spelling_suggestions(word, limit=limit)
def autocorrect_candidate(self, word: str) -> str | None:
return self._spellcheck.autocorrect_candidate(word)
def spelling_issues(self, text: str, suggestion_limit: int = 6) -> list[dict]:
return self._spellcheck.text_spelling_issues(text, suggestion_limit=suggestion_limit)
analysis_service = LyricAnalysisService()

View File

@ -0,0 +1,18 @@
from src.lyricflow_core.api.analysis import LyricAnalysisService, analysis_service
from src.lyricflow_core.api.project_state import ProjectStateService, project_state_service
class LyricFlowCoreFacade:
"""Aggregate core facade for clients that prefer a single integration point."""
def __init__(
self,
analysis: LyricAnalysisService | None = None,
projects: ProjectStateService | None = None,
):
self.analysis = analysis or analysis_service
self.projects = projects or project_state_service
core_api = LyricFlowCoreFacade()

View File

@ -0,0 +1,95 @@
from dataclasses import dataclass, field
import json
import os
from typing import Any
@dataclass
class ProjectState:
version: int = 2
name: str = ""
open_files: list[str] = field(default_factory=list)
active_file: str | None = None
cursor_positions: dict[str, int] = field(default_factory=dict)
scratchpad_open: bool = False
class ProjectStateService:
"""Stable project file API for desktop and mobile clients."""
def parse_cursor_positions(self, raw: Any) -> dict[str, int]:
if not isinstance(raw, dict):
return {}
parsed: dict[str, int] = {}
for path, position in raw.items():
if not isinstance(path, str):
continue
try:
parsed[path] = max(0, int(position))
except (TypeError, ValueError):
continue
return parsed
def from_dict(self, payload: dict[str, Any], fallback_name: str = "") -> ProjectState:
open_files: list[str] = []
for path in payload.get("open_files", []):
if isinstance(path, str):
open_files.append(path)
active_file = payload.get("active_file")
if not isinstance(active_file, str):
active_file = None
name = payload.get("name")
if not isinstance(name, str):
name = fallback_name
version = payload.get("version", 2)
try:
version = int(version)
except (TypeError, ValueError):
version = 2
scratchpad_open = payload.get("scratchpad_open", False)
if not isinstance(scratchpad_open, bool):
scratchpad_open = False
return ProjectState(
version=max(1, version),
name=name,
open_files=open_files,
active_file=active_file,
cursor_positions=self.parse_cursor_positions(payload.get("cursor_positions")),
scratchpad_open=scratchpad_open,
)
def to_dict(self, state: ProjectState) -> dict[str, Any]:
active_file = state.active_file if isinstance(state.active_file, str) else None
return {
"version": max(1, int(state.version)),
"name": state.name,
"open_files": [p for p in state.open_files if isinstance(p, str)],
"active_file": active_file,
"cursor_positions": self.parse_cursor_positions(state.cursor_positions),
"scratchpad_open": state.scratchpad_open,
}
def read_project(self, project_file: str) -> ProjectState:
project_file = os.path.abspath(project_file)
with open(project_file, "r", encoding="utf-8") as f:
payload = json.load(f)
if not isinstance(payload, dict):
payload = {}
fallback_name = os.path.basename(os.path.dirname(project_file))
return self.from_dict(payload, fallback_name=fallback_name)
def write_project(self, project_file: str, state: ProjectState) -> None:
project_file = os.path.abspath(project_file)
payload = self.to_dict(state)
with open(project_file, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=4)
project_state_service = ProjectStateService()

View File

@ -0,0 +1,12 @@
from .phonetics import PhoneticProcessor, processor
from .rhyme_engine import RhymeEngine, engine
from .spellcheck import SpellcheckEngine, spellcheck
__all__ = [
"PhoneticProcessor",
"RhymeEngine",
"SpellcheckEngine",
"engine",
"processor",
"spellcheck",
]

View File

@ -0,0 +1,9 @@
from nltk.corpus import wordnet
def is_wordnet_available() -> bool:
"""Checks if NLTK WordNet is available and loaded."""
try:
wordnet.ensure_loaded()
return True
except LookupError:
return False

View File

@ -0,0 +1,37 @@
import re
from functools import lru_cache
from nltk.corpus import cmudict
class PhoneticProcessor:
def __init__(self):
self.dict = self._load_cmudict()
@staticmethod
def _load_cmudict():
try:
data = cmudict.dict()
return data
except LookupError:
return {}
@lru_cache(maxsize=8192)
def normalize_word(self, word: str) -> str:
"""Standardizes word for dictionary lookup."""
# Lowercase, remove non-alphanumeric except apostrophes
word = word.lower().strip()
word = re.sub(r"[^a-z']", "", word)
# Handle common rap contractions
if word.endswith("in'"):
word = word[:-1] + "g"
return word
@lru_cache(maxsize=8192)
def get_phonemes(self, word: str):
"""Returns a list of possible phoneme lists for a word."""
normalized = self.normalize_word(word)
return tuple(tuple(phones) for phones in self.dict.get(normalized, []))
# Singleton instance
processor = PhoneticProcessor()

View File

@ -0,0 +1,294 @@
import re
from functools import lru_cache
from typing import Dict, List
from nltk.corpus import wordnet
from .phonetics import processor
from .syntax import TAG_PATTERN
from .common import is_wordnet_available
class RhymeEngine:
def __init__(self, threshold: float = 0.5):
self.threshold = threshold
self._perfect_index: Dict[tuple[str, ...], set[str]] = {}
self._slant_index: Dict[str, set[str]] = {}
self._is_indexed = False
self._last_group_text = ""
self._last_group_results: List[Dict] = []
self._last_density_text = ""
self._last_density_results: List[float] = []
def find_synonyms(self, word: str, limit: int = 15) -> Dict[str, List[str]]:
"""Returns synonyms and related 'vibe' concepts."""
if not is_wordnet_available():
return {"synonyms": [], "vibe": []}
synonyms = set()
vibe = set()
for syn in wordnet.synsets(word):
for lemma in syn.lemmas():
name = lemma.name().replace("_", " ")
if name.lower() != word.lower():
synonyms.add(name)
# Add hypernyms as 'vibe'
for hyper in syn.hypernyms():
for lemma in hyper.lemmas():
name = lemma.name().replace("_", " ")
vibe.add(name)
return {
"synonyms": sorted(list(synonyms))[:limit],
"vibe": sorted(list(vibe))[:limit],
}
@lru_cache(maxsize=8192)
def count_syllables(self, word: str) -> int:
"""Counts syllables in a word using phonetic data if available."""
phones = processor.get_phonemes(word)
if phones:
return sum(1 for p in phones[0] if any(char.isdigit() for char in p))
word = word.lower()
count = 0
vowels = "aeiouy"
if not word:
return 0
if word[0] in vowels:
count += 1
for index in range(1, len(word)):
if word[index] in vowels and word[index - 1] not in vowels:
count += 1
if word.endswith("e"):
count -= 1
return max(1, count)
def _ensure_indexed(self):
"""Lazy-build indices for the entire dictionary."""
if self._is_indexed:
return
for word, phone_lists in processor.dict.items():
for phones in phone_lists:
if not phones:
continue
p_suffix = tuple(phones[-2:]) if len(phones) >= 2 else tuple(phones[-1:])
self._perfect_index.setdefault(p_suffix, set()).add(word)
for p in reversed(phones):
if any(char.isdigit() for char in p):
self._slant_index.setdefault(p, set()).add(word)
break
self._is_indexed = True
@staticmethod
def phon_match(first_phon, second_phon) -> float:
f_range = first_phon[::-1]
s_range = second_phon[::-1]
limit = min(len(f_range), len(s_range))
hits = 0
total = limit
for i in range(limit):
if f_range[i] == s_range[i]:
hits += 1
if f_range[i][-1].isdigit():
hits += 1
total += 1
else:
break
return hits / total if total > 0 else 0.0
@lru_cache(maxsize=4096)
def calculate_similarity(self, word1: str, word2: str) -> float:
phones1 = processor.get_phonemes(word1)
phones2 = processor.get_phonemes(word2)
if not phones1 or not phones2:
return 0.0
max_score = 0.0
for p1 in phones1:
for p2 in phones2:
max_score = max(max_score, self.phon_match(p1, p2))
return max_score
@lru_cache(maxsize=8192)
def _word_suffixes(self, word: str) -> tuple[tuple[str, ...], ...]:
suffixes: set[tuple[str, ...]] = set()
for phones in processor.get_phonemes(word):
if not phones:
continue
suffix = tuple(phones[-2:]) if len(phones) >= 2 else tuple(phones[-1:])
suffixes.add(suffix)
return tuple(sorted(suffixes))
@staticmethod
def _suffixes_overlap(
first_suffixes: tuple[tuple[str, ...], ...],
second_suffixes: tuple[tuple[str, ...], ...],
) -> bool:
if not first_suffixes or not second_suffixes:
return False
second_set = set(second_suffixes)
return any(suffix in second_set for suffix in first_suffixes)
def find_suggestions(self, word: str, limit: int = 20) -> Dict[str, List[str]]:
"""Returns perfect and slant rhymes for a given word."""
self._ensure_indexed()
word = processor.normalize_word(word)
phones_list = processor.get_phonemes(word)
if not phones_list:
return {"perfect": [], "slant": []}
perfect = set()
slant = set()
for phones in phones_list:
p_suffix = tuple(phones[-2:]) if len(phones) >= 2 else tuple(phones[-1:])
if p_suffix in self._perfect_index:
perfect.update(self._perfect_index[p_suffix])
for p in reversed(phones):
if any(char.isdigit() for char in p):
if p in self._slant_index:
slant.update(self._slant_index[p])
break
perfect.discard(word)
slant.discard(word)
slant = slant - perfect
return {
"perfect": sorted(list(perfect))[:limit],
"slant": sorted(list(slant))[:limit],
}
def get_rhyme_groups(self, text: str) -> List[Dict]:
"""Analyzes text to find rhyme groups, respecting LyricDown syntax."""
if text == self._last_group_text:
return list(self._last_group_results)
lines = text.split("\n")
flat_words = []
for line_idx, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith(("#", "@", ">")):
continue
analysis_text = re.sub(TAG_PATTERN, "", line)
words = re.findall(r"\b\w+\b", analysis_text)
for word in words:
clean = processor.normalize_word(word)
if clean:
flat_words.append({"orig": word, "clean": clean, "line": line_idx})
if not flat_words:
self._last_group_text = text
self._last_group_results = []
return []
word_to_group: Dict[str, int] = {}
group_members: Dict[int, List[int]] = {}
next_group_id = 0
self._ensure_indexed()
for i, word_data in enumerate(flat_words):
clean = word_data["clean"]
line_idx = word_data["line"]
suffixes = self._word_suffixes(clean)
if not suffixes:
continue
match_found = False
for j in range(max(0, i - 20), i):
prev_data = flat_words[j]
if line_idx - prev_data["line"] > 4:
continue
prev_suffixes = self._word_suffixes(prev_data["clean"])
if not prev_suffixes:
continue
if self._suffixes_overlap(suffixes, prev_suffixes) and clean != prev_data["clean"]:
if prev_data["clean"] in word_to_group:
gid = word_to_group[prev_data["clean"]]
word_to_group[clean] = gid
group_members[gid].append(i)
match_found = True
break
if not match_found:
for j in range(max(0, i - 20), i):
prev_data = flat_words[j]
if line_idx - prev_data["line"] > 4:
continue
prev_suffixes = self._word_suffixes(prev_data["clean"])
if not prev_suffixes:
continue
if self._suffixes_overlap(suffixes, prev_suffixes) and clean != prev_data["clean"]:
gid = next_group_id
next_group_id += 1
word_to_group[prev_data["clean"]] = gid
word_to_group[clean] = gid
group_members[gid] = [j, i]
break
results = []
for word_data in flat_words:
gid = word_to_group.get(word_data["clean"])
results.append({"word": word_data["clean"], "group": gid})
self._last_group_text = text
self._last_group_results = list(results)
return results
def get_line_densities(self, text: str) -> List[float]:
"""Calculates a density score (0.0 to 1.0) for each line."""
if text == self._last_density_text:
return list(self._last_density_results)
lines = text.split("\n")
if not lines:
return []
groups = self.get_rhyme_groups(text)
group_iter = iter(groups)
densities = []
for line in lines:
stripped = line.strip()
if stripped.startswith(("#", "@", ">")):
densities.append(0.0)
continue
analysis_text = re.sub(TAG_PATTERN, "", line)
words = re.findall(r"\b\w+\b", analysis_text)
if not words:
densities.append(0.0)
continue
rhyme_count = 0
for _ in words:
try:
res = next(group_iter)
if res["group"] is not None:
rhyme_count += 1
except StopIteration:
break
densities.append(rhyme_count / len(words))
self._last_density_text = text
self._last_density_results = list(densities)
return densities
engine = RhymeEngine()

View File

@ -0,0 +1,152 @@
import difflib
import re
from functools import lru_cache
from nltk.corpus import wordnet
from .phonetics import PhoneticProcessor, processor
from .syntax import TAG_PATTERN, strip_tags
from .common import is_wordnet_available
class SpellcheckEngine:
"""Dictionary-backed spell checking for lyrics text."""
def __init__(self, phonetic_processor: PhoneticProcessor | None = None):
self._processor = phonetic_processor or processor
self._cmu_by_initial: dict[str, list[str]] | None = None
def _build_cmu_index(self) -> dict[str, list[str]]:
by_initial: dict[str, list[str]] = {}
for word in self._processor.dict.keys():
if not word:
continue
by_initial.setdefault(word[0], []).append(word)
return by_initial
@property
def _cmu_words_by_initial(self) -> dict[str, list[str]]:
if self._cmu_by_initial is None:
self._cmu_by_initial = self._build_cmu_index()
return self._cmu_by_initial
@lru_cache(maxsize=16384)
def is_known_word(self, word: str) -> bool:
normalized = self._processor.normalize_word(word)
if not normalized:
return True
if self._processor.normalize_word(word) in self._processor.dict:
return True
if is_wordnet_available() and wordnet.synsets(normalized):
return True
return False
def spelling_suggestions(self, word: str, limit: int = 6) -> list[str]:
normalized = self._processor.normalize_word(word)
if not normalized or self.is_known_word(normalized):
return []
initial = normalized[0]
candidates = self._cmu_words_by_initial.get(initial, list(self._processor.dict.keys()))
length_filtered = [w for w in candidates if abs(len(w) - len(normalized)) <= 3]
if not length_filtered:
length_filtered = candidates
suggestions = difflib.get_close_matches(normalized, length_filtered, n=limit, cutoff=0.75)
if suggestions:
return suggestions
return difflib.get_close_matches(normalized, length_filtered, n=limit, cutoff=0.65)
@staticmethod
def _levenshtein_distance(a: str, b: str) -> int:
if a == b:
return 0
if not a:
return len(b)
if not b:
return len(a)
prev_row = list(range(len(b) + 1))
for i, ca in enumerate(a, start=1):
row = [i]
for j, cb in enumerate(b, start=1):
insert_cost = row[j - 1] + 1
delete_cost = prev_row[j] + 1
replace_cost = prev_row[j - 1] + (0 if ca == cb else 1)
row.append(min(insert_cost, delete_cost, replace_cost))
prev_row = row
return prev_row[-1]
def autocorrect_candidate(
self,
word: str,
min_ratio: float = 0.85,
max_edit_distance: int = 2,
) -> str | None:
normalized = self._processor.normalize_word(word)
if not normalized or len(normalized) < 3:
return None
if self.is_known_word(normalized):
return None
suggestions = self.spelling_suggestions(normalized, limit=3)
if not suggestions:
return None
scored: list[tuple[tuple[float, ...], str]] = []
for candidate in suggestions:
ratio = difflib.SequenceMatcher(a=normalized, b=candidate).ratio()
distance = self._levenshtein_distance(normalized, candidate)
if ratio < min_ratio:
continue
if distance > max_edit_distance:
continue
lexical_rank = 0
if is_wordnet_available() and wordnet.synsets(candidate):
lexical_rank = 1
apostrophe_penalty = 1 if "'" in candidate else 0
length_delta = abs(len(candidate) - len(normalized))
score = (
float(lexical_rank),
float(-apostrophe_penalty),
float(-length_delta),
float(-distance),
ratio,
)
scored.append((score, candidate))
if not scored:
return None
scored.sort(reverse=True, key=lambda item: item[0])
return scored[0][1]
def text_spelling_issues(self, text: str, suggestion_limit: int = 6) -> list[dict]:
issues: list[dict] = []
for line_idx, line in enumerate(text.split("\n")):
stripped = line.strip()
if stripped.startswith(("#", "@", ">")):
continue
analysis_text = strip_tags(line)
words = re.findall(r"\b\w+\b", analysis_text)
for raw_word in words:
normalized = self._processor.normalize_word(raw_word)
if not normalized:
continue
if self.is_known_word(normalized):
continue
issues.append(
{
"word": raw_word,
"normalized": normalized,
"line": line_idx,
"suggestions": self.spelling_suggestions(normalized, limit=suggestion_limit),
}
)
return issues
spellcheck = SpellcheckEngine()

View File

@ -0,0 +1,7 @@
import re
TAG_PATTERN = r"\[[^\]]*(?:\]|$)"
def strip_tags(text: str) -> str:
"""Removes LyricDown syntax tags from text."""
return re.sub(TAG_PATTERN, "", text)

View File

@ -0,0 +1,12 @@
from .app_settings import AppPreferences, AppSettingsStore
from .file_manager import FileManager
from .session_store import GLOBAL_WORKSPACE_KEY, SessionStore, SessionTabSnapshot
__all__ = [
"AppPreferences",
"AppSettingsStore",
"FileManager",
"GLOBAL_WORKSPACE_KEY",
"SessionStore",
"SessionTabSnapshot",
]

View File

@ -0,0 +1,80 @@
from dataclasses import dataclass
from typing import Optional
from PyQt6.QtCore import QByteArray, QSettings
@dataclass
class AppPreferences:
reopen_last_project: bool = True
restore_unsaved_tabs: bool = True
word_wrap_default: bool = False
show_left_sidebar: bool = True
show_right_sidebar: bool = True
last_project_file: str = ""
window_geometry: bytes | None = None
splitter_sizes: list[int] | None = None
class AppSettingsStore:
def __init__(self, settings: Optional[QSettings] = None):
self._settings = settings or QSettings()
def load(self) -> AppPreferences:
splitter_sizes = self._load_splitter_sizes()
window_geometry = self._load_window_geometry()
return AppPreferences(
reopen_last_project=self._settings.value("startup/reopen_last_project", True, type=bool),
restore_unsaved_tabs=self._settings.value("startup/restore_unsaved_tabs", True, type=bool),
word_wrap_default=self._settings.value("editor/word_wrap_default", False, type=bool),
show_left_sidebar=self._settings.value("appearance/show_left_sidebar", True, type=bool),
show_right_sidebar=self._settings.value("appearance/show_right_sidebar", True, type=bool),
last_project_file=self._settings.value("session/last_project_file", "", type=str) or "",
window_geometry=window_geometry,
splitter_sizes=splitter_sizes,
)
def save(self, prefs: AppPreferences) -> None:
self._settings.setValue("startup/reopen_last_project", bool(prefs.reopen_last_project))
self._settings.setValue("startup/restore_unsaved_tabs", bool(prefs.restore_unsaved_tabs))
self._settings.setValue("editor/word_wrap_default", bool(prefs.word_wrap_default))
self._settings.setValue("appearance/show_left_sidebar", bool(prefs.show_left_sidebar))
self._settings.setValue("appearance/show_right_sidebar", bool(prefs.show_right_sidebar))
self._settings.setValue("session/last_project_file", prefs.last_project_file or "")
if prefs.window_geometry is None:
self._settings.remove("ui/window_geometry")
else:
self._settings.setValue("ui/window_geometry", QByteArray(prefs.window_geometry))
if prefs.splitter_sizes and len(prefs.splitter_sizes) == 3:
self._settings.setValue("ui/splitter_sizes", [int(v) for v in prefs.splitter_sizes])
else:
self._settings.remove("ui/splitter_sizes")
self._settings.sync()
def _load_window_geometry(self) -> bytes | None:
value = self._settings.value("ui/window_geometry")
if isinstance(value, QByteArray):
return bytes(value)
if isinstance(value, (bytes, bytearray)):
return bytes(value)
return None
def _load_splitter_sizes(self) -> list[int] | None:
value = self._settings.value("ui/splitter_sizes")
if not isinstance(value, list):
return None
parsed: list[int] = []
for item in value:
try:
parsed.append(int(item))
except (TypeError, ValueError):
return None
if len(parsed) != 3:
return None
return parsed

View File

@ -0,0 +1,124 @@
import sqlite3
import os
import time
from typing import List, Dict, Optional
from dataclasses import dataclass
@dataclass
class Snapshot:
id: int
file_path: str
content: str
timestamp: float
class DatabaseManager:
"""Manages SQLite database for project history and scratchpads to avoid file clutter."""
def __init__(self, db_path: str):
self.db_path = db_path
self._init_db()
def _get_connection(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row
return conn
def _init_db(self) -> None:
"""Initialize the database schema if it doesn't exist."""
# Ensure the directory exists
os.makedirs(os.path.dirname(os.path.abspath(self.db_path)), exist_ok=True)
with self._get_connection() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
file_path TEXT NOT NULL,
content TEXT NOT NULL,
timestamp REAL NOT NULL
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS scratchpads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT UNIQUE NOT NULL,
content TEXT NOT NULL,
last_modified REAL NOT NULL
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_snapshots_file ON snapshots(file_path)")
conn.commit()
# --- Snapshots (Version History) ---
def save_snapshot(self, file_path: str, content: str) -> None:
"""Saves a new snapshot of the file content."""
if not file_path or not content.strip():
return
with self._get_connection() as conn:
# Check if the last snapshot for this file is identical
cursor = conn.execute(
"SELECT content FROM snapshots WHERE file_path = ? ORDER BY timestamp DESC LIMIT 1",
(file_path,)
)
last_row = cursor.fetchone()
if last_row and last_row['content'] == content:
return # Skip saving if content hasn't changed
conn.execute(
"INSERT INTO snapshots (file_path, content, timestamp) VALUES (?, ?, ?)",
(file_path, content, time.time())
)
conn.commit()
def get_snapshots(self, file_path: str) -> List[Snapshot]:
"""Retrieves all snapshots for a given file, ordered newest first."""
with self._get_connection() as conn:
cursor = conn.execute(
"SELECT id, file_path, content, timestamp FROM snapshots WHERE file_path = ? ORDER BY timestamp DESC",
(file_path,)
)
return [Snapshot(row['id'], row['file_path'], row['content'], row['timestamp']) for row in cursor]
def get_snapshot(self, snapshot_id: int) -> Optional[Snapshot]:
"""Retrieves a specific snapshot by ID."""
with self._get_connection() as conn:
cursor = conn.execute(
"SELECT id, file_path, content, timestamp FROM snapshots WHERE id = ?",
(snapshot_id,)
)
row = cursor.fetchone()
if row:
return Snapshot(row['id'], row['file_path'], row['content'], row['timestamp'])
return None
# --- Scratchpad ---
def save_scratchpad(self, project_id: str, content: str) -> None:
"""Saves or updates the scratchpad content for a project."""
with self._get_connection() as conn:
conn.execute(
"""
INSERT INTO scratchpads (project_id, content, last_modified)
VALUES (?, ?, ?)
ON CONFLICT(project_id) DO UPDATE SET
content=excluded.content,
last_modified=excluded.last_modified
""",
(project_id, content, time.time())
)
conn.commit()
def get_scratchpad(self, project_id: str) -> str:
"""Retrieves the scratchpad content for a project. Returns empty string if none found."""
with self._get_connection() as conn:
cursor = conn.execute("SELECT content FROM scratchpads WHERE project_id = ?", (project_id,))
row = cursor.fetchone()
return row['content'] if row else ""
def clear_all(self):
"""Clears all data from the database. Use with caution."""
with self._get_connection() as conn:
conn.execute("DELETE FROM snapshots")
conn.execute("DELETE FROM scratchpads")
conn.commit()

View File

@ -0,0 +1,34 @@
import os
class FileManager:
def __init__(self):
self.current_file = None
def save_file(self, content, path=None):
"""Saves content to the specified path or current_file."""
target_path = path or self.current_file
if not target_path:
return False, "No file path provided."
try:
with open(target_path, 'w', encoding='utf-8') as f:
f.write(content)
self.current_file = target_path
return True, f"Saved to {os.path.basename(target_path)}"
except Exception as e:
return False, str(e)
def load_file(self, path):
"""Loads content from the specified path."""
try:
with open(path, 'r', encoding='utf-8') as f:
content = f.read()
self.current_file = path
return content, f"Loaded {os.path.basename(path)}"
except Exception as e:
return None, str(e)
def get_current_filename(self):
if self.current_file:
return os.path.basename(self.current_file)
return "Untitled"

View File

@ -0,0 +1,142 @@
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
import json
import os
from typing import Any, Optional
from PyQt6.QtCore import QStandardPaths
GLOBAL_WORKSPACE_KEY = "__global__"
@dataclass
class SessionTabSnapshot:
tab_id: str
file_path: str | None
display_name: str
content: str
cursor_position: int
is_dirty: bool
is_untitled: bool
snapshot_mtime: float | None
workspace_root: str | None
updated_at: str
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "SessionTabSnapshot":
return cls(
tab_id=str(data.get("tab_id", "")),
file_path=data.get("file_path") if isinstance(data.get("file_path"), str) else None,
display_name=str(data.get("display_name", "Recovered")),
content=str(data.get("content", "")),
cursor_position=max(0, int(data.get("cursor_position", 0))),
is_dirty=bool(data.get("is_dirty", False)),
is_untitled=bool(data.get("is_untitled", False)),
snapshot_mtime=_to_float_or_none(data.get("snapshot_mtime")),
workspace_root=data.get("workspace_root") if isinstance(data.get("workspace_root"), str) else None,
updated_at=str(data.get("updated_at", datetime.now(timezone.utc).isoformat())),
)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
class SessionStore:
def __init__(self, storage_path: Optional[str] = None):
if storage_path:
self._storage_path = storage_path
else:
base_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.AppDataLocation)
if not base_dir:
base_dir = os.path.join(os.path.expanduser("~"), ".lyricflow")
self._storage_path = os.path.join(base_dir, "session_snapshots.json")
directory = os.path.dirname(self._storage_path)
if directory:
os.makedirs(directory, exist_ok=True)
def load(self, workspace_root: str | None) -> list[SessionTabSnapshot]:
payload = self._read_payload()
workspace_key = self._workspace_key(workspace_root)
raw_entries: list[dict[str, Any]] = []
if isinstance(payload, list):
raw_entries = [item for item in payload if isinstance(item, dict)]
elif isinstance(payload, dict):
if "workspaces" in payload and isinstance(payload["workspaces"], dict):
raw_workspace_entries = payload["workspaces"].get(workspace_key, [])
if isinstance(raw_workspace_entries, list):
raw_entries = [item for item in raw_workspace_entries if isinstance(item, dict)]
elif "snapshots" in payload and isinstance(payload["snapshots"], list):
raw_entries = [item for item in payload["snapshots"] if isinstance(item, dict)]
return [SessionTabSnapshot.from_dict(entry) for entry in raw_entries]
def save(self, workspace_root: str | None, snapshots: list[SessionTabSnapshot]) -> None:
payload = self._read_payload()
if not isinstance(payload, dict):
payload = {"version": 1, "workspaces": {}}
if not isinstance(payload.get("workspaces"), dict):
payload["workspaces"] = {}
workspace_key = self._workspace_key(workspace_root)
serialized = [snapshot.to_dict() for snapshot in snapshots]
if serialized:
payload["workspaces"][workspace_key] = serialized
else:
payload["workspaces"].pop(workspace_key, None)
payload["version"] = 1
self._write_payload(payload)
def clear(self, workspace_root: str | None = None) -> None:
if workspace_root is None:
if os.path.exists(self._storage_path):
os.remove(self._storage_path)
return
payload = self._read_payload()
if not isinstance(payload, dict):
return
workspaces = payload.get("workspaces")
if not isinstance(workspaces, dict):
return
workspaces.pop(self._workspace_key(workspace_root), None)
payload["workspaces"] = workspaces
payload["version"] = 1
if not workspaces:
if os.path.exists(self._storage_path):
os.remove(self._storage_path)
return
self._write_payload(payload)
def _workspace_key(self, workspace_root: str | None) -> str:
if not workspace_root:
return GLOBAL_WORKSPACE_KEY
return os.path.normcase(os.path.abspath(workspace_root))
def _read_payload(self) -> Any:
if not os.path.exists(self._storage_path):
return {"version": 1, "workspaces": {}}
try:
with open(self._storage_path, "r", encoding="utf-8") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return {"version": 1, "workspaces": {}}
def _write_payload(self, payload: dict[str, Any]) -> None:
with open(self._storage_path, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
def _to_float_or_none(value: Any) -> float | None:
try:
if value is None:
return None
return float(value)
except (TypeError, ValueError):
return None

View File

@ -0,0 +1,8 @@
"""Compatibility layer for legacy imports.
Primary implementation now lives in src.lyricflow_core.storage.app_settings.
"""
from src.lyricflow_core.storage.app_settings import AppPreferences, AppSettingsStore
__all__ = ["AppPreferences", "AppSettingsStore"]

View File

@ -0,0 +1,8 @@
"""Compatibility layer for legacy imports.
Primary implementation now lives in src.lyricflow_core.storage.file_manager.
"""
from src.lyricflow_core.storage.file_manager import FileManager
__all__ = ["FileManager"]

View File

@ -0,0 +1,12 @@
"""Compatibility layer for legacy imports.
Primary implementation now lives in src.lyricflow_core.storage.session_store.
"""
from src.lyricflow_core.storage.session_store import (
GLOBAL_WORKSPACE_KEY,
SessionStore,
SessionTabSnapshot,
)
__all__ = ["GLOBAL_WORKSPACE_KEY", "SessionStore", "SessionTabSnapshot"]

View File

@ -0,0 +1,54 @@
import os
import tempfile
import unittest
from PyQt6.QtCore import QCoreApplication, QSettings
from src.utils.app_settings import AppPreferences, AppSettingsStore
class TestAppSettingsStore(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls._app = QCoreApplication.instance() or QCoreApplication([])
def _make_store(self, root: str) -> AppSettingsStore:
settings_path = os.path.join(root, "app_settings.ini")
settings = QSettings(settings_path, QSettings.Format.IniFormat)
settings.clear()
settings.sync()
return AppSettingsStore(settings)
def test_defaults(self):
with tempfile.TemporaryDirectory() as tmp:
store = self._make_store(tmp)
prefs = store.load()
self.assertTrue(prefs.reopen_last_project)
self.assertTrue(prefs.restore_unsaved_tabs)
self.assertFalse(prefs.word_wrap_default)
self.assertTrue(prefs.show_left_sidebar)
self.assertTrue(prefs.show_right_sidebar)
self.assertEqual("", prefs.last_project_file)
self.assertIsNone(prefs.window_geometry)
self.assertIsNone(prefs.splitter_sizes)
def test_round_trip(self):
with tempfile.TemporaryDirectory() as tmp:
store = self._make_store(tmp)
original = AppPreferences(
reopen_last_project=False,
restore_unsaved_tabs=False,
word_wrap_default=True,
show_left_sidebar=False,
show_right_sidebar=True,
last_project_file="C:/demo/.lyricproject",
window_geometry=b"\x01\x02\x03",
splitter_sizes=[111, 777, 222],
)
store.save(original)
loaded = store.load()
self.assertEqual(original, loaded)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,23 @@
import unittest
from src.lyricflow_core.api.analysis import analysis_service
class TestLyricAnalysisService(unittest.TestCase):
def test_similarity_available(self):
score = analysis_service.similarity("cat", "mat")
self.assertGreaterEqual(score, 0.5)
def test_lmd_syntax_filtered_in_density(self):
text = "# Header\n[Voice: The Jester]\ncat bat"
densities = analysis_service.line_densities(text)
self.assertEqual([0.0, 0.0, 1.0], densities)
def test_suggestions_shape(self):
results = analysis_service.suggestions("cat")
self.assertIn("perfect", results)
self.assertIn("slant", results)
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,35 @@
import os
import tempfile
import unittest
from src.lyricflow_core.api.project_state import ProjectState, project_state_service
class TestProjectStateService(unittest.TestCase):
def test_parse_cursor_positions_defensive(self):
raw = {
"song1": "42",
"song2": -10,
"song3": "bad",
123: 7,
}
expected = {"song1": 42, "song2": 0}
self.assertEqual(expected, project_state_service.parse_cursor_positions(raw))
def test_round_trip_file(self):
with tempfile.TemporaryDirectory() as tmp:
project_file = os.path.join(tmp, ".lyricproject")
original = ProjectState(
version=2,
name="demo",
open_files=[os.path.join(tmp, "a.lmd"), os.path.join(tmp, "b.lmd")],
active_file=os.path.join(tmp, "b.lmd"),
cursor_positions={os.path.join(tmp, "a.lmd"): 3, os.path.join(tmp, "b.lmd"): 8},
)
project_state_service.write_project(project_file, original)
loaded = project_state_service.read_project(project_file)
self.assertEqual(original, loaded)
if __name__ == "__main__":
unittest.main()

30
tests/test_core_compat.py Normal file
View File

@ -0,0 +1,30 @@
import unittest
from src.lyricflow_core.engine.phonetics import processor as core_processor
from src.lyricflow_core.engine.rhyme_engine import engine as core_engine
from src.lyricflow_core.storage.app_settings import AppPreferences as CoreAppPreferences
from src.lyricflow_core.storage.file_manager import FileManager as CoreFileManager
from src.lyricflow_core.storage.session_store import SessionStore as CoreSessionStore
from src.engine.phonetics import processor as legacy_processor
from src.engine.rhyme_engine import engine as legacy_engine
from src.utils.app_settings import AppPreferences as LegacyAppPreferences
from src.utils.file_manager import FileManager as LegacyFileManager
from src.utils.session_store import SessionStore as LegacySessionStore
class TestCoreCompatibility(unittest.TestCase):
def test_engine_singleton_is_shared(self):
self.assertIs(core_engine, legacy_engine)
def test_processor_singleton_is_shared(self):
self.assertIs(core_processor, legacy_processor)
def test_storage_types_match(self):
self.assertIs(CoreAppPreferences, LegacyAppPreferences)
self.assertIs(CoreFileManager, LegacyFileManager)
self.assertIs(CoreSessionStore, LegacySessionStore)
if __name__ == "__main__":
unittest.main()

46
tests/test_db_manager.py Normal file
View File

@ -0,0 +1,46 @@
import os
import sqlite3
import pytest
from src.lyricflow_core.storage.db_manager import DatabaseManager
@pytest.fixture
def db_manager(tmp_path):
# Use an in-memory database path or a temp file
db_file = tmp_path / "test.db"
manager = DatabaseManager(str(db_file))
yield manager
# Teardown
manager.clear_all()
def test_save_and_get_snapshots(db_manager):
file_path = "/test/path.lmd"
# Save a snapshot
db_manager.save_snapshot(file_path, "Version 1 content")
# Save another snapshot
db_manager.save_snapshot(file_path, "Version 2 content")
# Identical snapshot should not save a duplicate
db_manager.save_snapshot(file_path, "Version 2 content")
snapshots = db_manager.get_snapshots(file_path)
assert len(snapshots) == 2
assert snapshots[0].content == "Version 2 content"
assert snapshots[1].content == "Version 1 content"
def test_save_and_get_scratchpad(db_manager):
project_id = "test_project"
# Initially empty
content = db_manager.get_scratchpad(project_id)
assert content == ""
# Save content
db_manager.save_scratchpad(project_id, "Idea 1")
assert db_manager.get_scratchpad(project_id) == "Idea 1"
# Update content (replace)
db_manager.save_scratchpad(project_id, "Idea 2")
assert db_manager.get_scratchpad(project_id) == "Idea 2"

47
tests/test_engine.py Normal file
View File

@ -0,0 +1,47 @@
import unittest
from src.engine.rhyme_engine import engine
class TestRhymeEngine(unittest.TestCase):
def test_perfect_rhyme(self):
score = engine.calculate_similarity("cat", "mat")
self.assertGreaterEqual(score, 0.5)
def test_near_rhyme(self):
# Specific examples might depend on CMUDict stressed phonemes
score = engine.calculate_similarity("power", "hour")
self.assertGreaterEqual(score, 0.5)
def test_no_rhyme(self):
score = engine.calculate_similarity("cat", "dog")
self.assertLess(score, 0.2)
def test_normalization(self):
score = engine.calculate_similarity("runnin'", "running")
self.assertGreaterEqual(score, 0.8)
def test_density_respects_lyricdown_syntax_lines(self):
text = "# Intro\n@tempo: 90\n> skip this\ncat bat"
densities = engine.get_line_densities(text)
self.assertEqual([0.0, 0.0, 0.0, 1.0], densities)
def test_density_ignores_bracket_tags(self):
text = "[Verse 1]\ncat bat [Hook]"
densities = engine.get_line_densities(text)
self.assertEqual([0.0, 1.0], densities)
def test_density_ignores_unclosed_bracket_tags(self):
text = "[Voice: The Jester\ncat bat"
densities = engine.get_line_densities(text)
self.assertEqual([0.0, 1.0], densities)
def test_grouping_considers_all_pronunciations(self):
text = "cat bat brat combat"
groups = engine.get_rhyme_groups(text)
by_word = {}
for item in groups:
by_word.setdefault(item["word"], item["group"])
self.assertIsNotNone(by_word.get("combat"))
self.assertEqual(by_word.get("bat"), by_word.get("combat"))
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,27 @@
import unittest
import os
from src.gui.components.explorer import _is_valid_entry_name, _is_within_root
class TestExplorerSafety(unittest.TestCase):
def test_is_valid_entry_name_rejects_path_segments(self):
self.assertFalse(_is_valid_entry_name("../outside.txt"))
self.assertFalse(_is_valid_entry_name("nested/file.txt"))
self.assertFalse(_is_valid_entry_name(r"nested\file.txt"))
self.assertFalse(_is_valid_entry_name(".."))
def test_is_valid_entry_name_accepts_simple_name(self):
self.assertTrue(_is_valid_entry_name("song.lmd"))
self.assertTrue(_is_valid_entry_name("verse_01"))
def test_is_within_root(self):
root = os.path.join("tmp", "project")
inside = os.path.join(root, "lyrics", "song.lmd")
outside = os.path.join("tmp", "other", "song.lmd")
self.assertTrue(_is_within_root(root, inside))
self.assertFalse(_is_within_root(root, outside))
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,32 @@
import unittest
from src.gui.main_window import MainWindow
class TestProjectStateCompat(unittest.TestCase):
def test_legacy_project_without_cursor_positions(self):
legacy = {
"name": "demo",
"open_files": ["C:/demo/song.lmd"],
"active_file": "C:/demo/song.lmd",
}
self.assertEqual({}, MainWindow._extract_cursor_positions(legacy))
def test_cursor_positions_parsing_is_defensive(self):
data = {
"cursor_positions": {
"C:/demo/song.lmd": "42",
"C:/demo/song2.lmd": -5,
"C:/demo/song3.lmd": "bad",
123: 7,
}
}
expected = {
"C:/demo/song.lmd": 42,
"C:/demo/song2.lmd": 0,
}
self.assertEqual(expected, MainWindow._extract_cursor_positions(data))
if __name__ == "__main__":
unittest.main()

View File

@ -0,0 +1,66 @@
import json
import os
import tempfile
import unittest
from src.utils.session_store import SessionStore, SessionTabSnapshot
class TestSessionStore(unittest.TestCase):
def _make_store(self, root: str) -> tuple[SessionStore, str]:
storage_path = os.path.join(root, "session_snapshots.json")
return SessionStore(storage_path=storage_path), storage_path
def _sample_snapshot(self) -> SessionTabSnapshot:
return SessionTabSnapshot(
tab_id="untitled::0",
file_path=None,
display_name="Untitled",
content="hello world",
cursor_position=5,
is_dirty=True,
is_untitled=True,
snapshot_mtime=None,
workspace_root=None,
updated_at="2026-02-19T00:00:00+00:00",
)
def test_save_and_load(self):
with tempfile.TemporaryDirectory() as tmp:
store, _ = self._make_store(tmp)
snapshot = self._sample_snapshot()
store.save(None, [snapshot])
loaded = store.load(None)
self.assertEqual([snapshot], loaded)
def test_workspace_scoping(self):
with tempfile.TemporaryDirectory() as tmp:
store, _ = self._make_store(tmp)
s1 = self._sample_snapshot()
s2 = self._sample_snapshot()
s2.tab_id = "untitled::1"
s2.content = "workspace two"
ws1 = os.path.join(tmp, "ws1")
ws2 = os.path.join(tmp, "ws2")
store.save(ws1, [s1])
store.save(ws2, [s2])
self.assertEqual([s1], store.load(ws1))
self.assertEqual([s2], store.load(ws2))
self.assertEqual([], store.load(os.path.join(tmp, "missing")))
def test_schema_version_handling(self):
with tempfile.TemporaryDirectory() as tmp:
store, storage_path = self._make_store(tmp)
snapshot = self._sample_snapshot()
with open(storage_path, "w", encoding="utf-8") as f:
json.dump({"version": 999, "snapshots": [snapshot.to_dict()]}, f, indent=2)
loaded = store.load(None)
self.assertEqual(1, len(loaded))
self.assertEqual(snapshot.content, loaded[0].content)
if __name__ == "__main__":
unittest.main()

39
tests/test_spellcheck.py Normal file
View File

@ -0,0 +1,39 @@
import unittest
from src.lyricflow_core.api.analysis import analysis_service
class TestSpellcheck(unittest.TestCase):
def test_unknown_word_detection(self):
self.assertFalse(analysis_service.is_known_word("gumguat"))
def test_known_word_detection(self):
self.assertTrue(analysis_service.is_known_word("combat"))
def test_suggestions_for_typo(self):
suggestions = analysis_service.spelling_suggestions("helo")
self.assertIn("hello", suggestions)
def test_autocorrect_candidate_for_typo(self):
self.assertEqual("nothing", analysis_service.autocorrect_candidate("nothign"))
def test_autocorrect_candidate_rejects_uncertain_word(self):
self.assertIsNone(analysis_service.autocorrect_candidate("gumguat"))
def test_spelling_issues_respect_lmd_syntax(self):
text = "# helo\n[Voice: gumguat]\nhelo world"
issues = analysis_service.spelling_issues(text)
normalized = [item["normalized"] for item in issues]
self.assertIn("helo", normalized)
self.assertNotIn("gumguat", normalized)
def test_spelling_issues_respect_unclosed_tag(self):
text = "[Voice: gumguat\nhelo world"
issues = analysis_service.spelling_issues(text)
normalized = [item["normalized"] for item in issues]
self.assertNotIn("gumguat", normalized)
self.assertIn("helo", normalized)
if __name__ == "__main__":
unittest.main()

20
tests/test_syntax.py Normal file
View File

@ -0,0 +1,20 @@
import unittest
from src.lyricflow_core.engine.syntax import strip_tags, TAG_PATTERN
import re
class TestSyntax(unittest.TestCase):
def test_tag_pattern(self):
self.assertTrue(re.match(TAG_PATTERN, "[Chorus]"))
self.assertTrue(re.match(TAG_PATTERN, "[Verse 1]"))
self.assertTrue(re.match(TAG_PATTERN, "[Bridge]"))
# Unclosed tags should also be caught by the current pattern logic if intended
self.assertTrue(re.match(TAG_PATTERN, "[Unclosed"))
def test_strip_tags(self):
self.assertEqual(strip_tags("Hello [Chorus] World"), "Hello World")
self.assertEqual(strip_tags("[Verse]Line 1"), "Line 1")
self.assertEqual(strip_tags("No tags here"), "No tags here")
self.assertEqual(strip_tags("[A][B]Concatenated"), "Concatenated")
if __name__ == "__main__":
unittest.main()