First commit
This commit is contained in:
commit
ae2ba3d873
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal 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
45
README.md
Normal 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
66
docs/architecture.md
Normal 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
83
docs/features.md
Normal 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
50
docs/lyricflow.md
Normal 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
88
lyricflow.spec
Normal 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
3
requirements.txt
Normal file
@ -0,0 +1,3 @@
|
||||
-r ../requirements-common.txt
|
||||
|
||||
nltk>=3.9,<4
|
||||
29
run.py
Normal file
29
run.py
Normal 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
13
samples/.lyricproject
Normal 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
6
samples/test.lmd
Normal 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
0
src/__init__.py
Normal file
8
src/engine/phonetics.py
Normal file
8
src/engine/phonetics.py
Normal 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"]
|
||||
8
src/engine/rhyme_engine.py
Normal file
8
src/engine/rhyme_engine.py
Normal 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
0
src/gui/__init__.py
Normal file
0
src/gui/components/__init__.py
Normal file
0
src/gui/components/__init__.py
Normal file
415
src/gui/components/editor.py
Normal file
415
src/gui/components/editor.py
Normal 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
|
||||
258
src/gui/components/explorer.py
Normal file
258
src/gui/components/explorer.py
Normal 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()}")
|
||||
136
src/gui/components/history_dialog.py
Normal file
136
src/gui/components/history_dialog.py
Normal 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()
|
||||
91
src/gui/components/preferences_dialog.py
Normal file
91
src/gui/components/preferences_dialog.py
Normal 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(),
|
||||
)
|
||||
48
src/gui/components/scratchpad.py
Normal file
48
src/gui/components/scratchpad.py
Normal 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()
|
||||
140
src/gui/components/sidebar.py
Normal file
140
src/gui/components/sidebar.py
Normal 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
950
src/gui/main_window.py
Normal 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
43
src/gui/theme.py
Normal 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)
|
||||
43
src/lyricflow_core/__init__.py
Normal file
43
src/lyricflow_core/__init__.py
Normal 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",
|
||||
]
|
||||
14
src/lyricflow_core/api/__init__.py
Normal file
14
src/lyricflow_core/api/__init__.py
Normal 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",
|
||||
]
|
||||
|
||||
56
src/lyricflow_core/api/analysis.py
Normal file
56
src/lyricflow_core/api/analysis.py
Normal 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()
|
||||
18
src/lyricflow_core/api/facade.py
Normal file
18
src/lyricflow_core/api/facade.py
Normal 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()
|
||||
|
||||
95
src/lyricflow_core/api/project_state.py
Normal file
95
src/lyricflow_core/api/project_state.py
Normal 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()
|
||||
|
||||
12
src/lyricflow_core/engine/__init__.py
Normal file
12
src/lyricflow_core/engine/__init__.py
Normal 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",
|
||||
]
|
||||
9
src/lyricflow_core/engine/common.py
Normal file
9
src/lyricflow_core/engine/common.py
Normal 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
|
||||
37
src/lyricflow_core/engine/phonetics.py
Normal file
37
src/lyricflow_core/engine/phonetics.py
Normal 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()
|
||||
294
src/lyricflow_core/engine/rhyme_engine.py
Normal file
294
src/lyricflow_core/engine/rhyme_engine.py
Normal 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()
|
||||
152
src/lyricflow_core/engine/spellcheck.py
Normal file
152
src/lyricflow_core/engine/spellcheck.py
Normal 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()
|
||||
7
src/lyricflow_core/engine/syntax.py
Normal file
7
src/lyricflow_core/engine/syntax.py
Normal 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)
|
||||
12
src/lyricflow_core/storage/__init__.py
Normal file
12
src/lyricflow_core/storage/__init__.py
Normal 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",
|
||||
]
|
||||
80
src/lyricflow_core/storage/app_settings.py
Normal file
80
src/lyricflow_core/storage/app_settings.py
Normal 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
|
||||
124
src/lyricflow_core/storage/db_manager.py
Normal file
124
src/lyricflow_core/storage/db_manager.py
Normal 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()
|
||||
34
src/lyricflow_core/storage/file_manager.py
Normal file
34
src/lyricflow_core/storage/file_manager.py
Normal 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"
|
||||
142
src/lyricflow_core/storage/session_store.py
Normal file
142
src/lyricflow_core/storage/session_store.py
Normal 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
|
||||
8
src/utils/app_settings.py
Normal file
8
src/utils/app_settings.py
Normal 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"]
|
||||
8
src/utils/file_manager.py
Normal file
8
src/utils/file_manager.py
Normal 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"]
|
||||
12
src/utils/session_store.py
Normal file
12
src/utils/session_store.py
Normal 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"]
|
||||
54
tests/test_app_settings.py
Normal file
54
tests/test_app_settings.py
Normal 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()
|
||||
23
tests/test_core_api_analysis.py
Normal file
23
tests/test_core_api_analysis.py
Normal 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()
|
||||
35
tests/test_core_api_project_state.py
Normal file
35
tests/test_core_api_project_state.py
Normal 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
30
tests/test_core_compat.py
Normal 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
46
tests/test_db_manager.py
Normal 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
47
tests/test_engine.py
Normal 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()
|
||||
27
tests/test_explorer_safety.py
Normal file
27
tests/test_explorer_safety.py
Normal 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()
|
||||
32
tests/test_project_state_compat.py
Normal file
32
tests/test_project_state_compat.py
Normal 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()
|
||||
66
tests/test_session_store.py
Normal file
66
tests/test_session_store.py
Normal 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
39
tests/test_spellcheck.py
Normal 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
20
tests/test_syntax.py
Normal 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()
|
||||
Loading…
x
Reference in New Issue
Block a user