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