commit ae2ba3d8734855f1117be347fa33653e73e2b272 Author: stan44 Date: Tue Feb 24 13:22:10 2026 -0600 First commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1c9a11 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9f29ff --- /dev/null +++ b/README.md @@ -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" +``` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..27addcd --- /dev/null +++ b/docs/architecture.md @@ -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. diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 0000000..f53709b --- /dev/null +++ b/docs/features.md @@ -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. diff --git a/docs/lyricflow.md b/docs/lyricflow.md new file mode 100644 index 0000000..90b77ca --- /dev/null +++ b/docs/lyricflow.md @@ -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. diff --git a/lyricflow.spec b/lyricflow.spec new file mode 100644 index 0000000..53e73ca --- /dev/null +++ b/lyricflow.spec @@ -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', +) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..76f6b83 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +-r ../requirements-common.txt + +nltk>=3.9,<4 diff --git a/run.py b/run.py new file mode 100644 index 0000000..baa2372 --- /dev/null +++ b/run.py @@ -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() diff --git a/samples/.lyricproject b/samples/.lyricproject new file mode 100644 index 0000000..7f2761e --- /dev/null +++ b/samples/.lyricproject @@ -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 + } +} \ No newline at end of file diff --git a/samples/test.lmd b/samples/test.lmd new file mode 100644 index 0000000..dda9188 --- /dev/null +++ b/samples/test.lmd @@ -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] diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/engine/phonetics.py b/src/engine/phonetics.py new file mode 100644 index 0000000..f12d8fc --- /dev/null +++ b/src/engine/phonetics.py @@ -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"] diff --git a/src/engine/rhyme_engine.py b/src/engine/rhyme_engine.py new file mode 100644 index 0000000..ff135aa --- /dev/null +++ b/src/engine/rhyme_engine.py @@ -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"] diff --git a/src/gui/__init__.py b/src/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/gui/components/__init__.py b/src/gui/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/gui/components/editor.py b/src/gui/components/editor.py new file mode 100644 index 0000000..c74bcdf --- /dev/null +++ b/src/gui/components/editor.py @@ -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 diff --git a/src/gui/components/explorer.py b/src/gui/components/explorer.py new file mode 100644 index 0000000..caf09cb --- /dev/null +++ b/src/gui/components/explorer.py @@ -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()}") diff --git a/src/gui/components/history_dialog.py b/src/gui/components/history_dialog.py new file mode 100644 index 0000000..8aa62c6 --- /dev/null +++ b/src/gui/components/history_dialog.py @@ -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() diff --git a/src/gui/components/preferences_dialog.py b/src/gui/components/preferences_dialog.py new file mode 100644 index 0000000..375d16f --- /dev/null +++ b/src/gui/components/preferences_dialog.py @@ -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(), + ) diff --git a/src/gui/components/scratchpad.py b/src/gui/components/scratchpad.py new file mode 100644 index 0000000..01b4cd1 --- /dev/null +++ b/src/gui/components/scratchpad.py @@ -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() diff --git a/src/gui/components/sidebar.py b/src/gui/components/sidebar.py new file mode 100644 index 0000000..d551be3 --- /dev/null +++ b/src/gui/components/sidebar.py @@ -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 diff --git a/src/gui/main_window.py b/src/gui/main_window.py new file mode 100644 index 0000000..2a849e4 --- /dev/null +++ b/src/gui/main_window.py @@ -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) diff --git a/src/gui/theme.py b/src/gui/theme.py new file mode 100644 index 0000000..74241c2 --- /dev/null +++ b/src/gui/theme.py @@ -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) diff --git a/src/lyricflow_core/__init__.py b/src/lyricflow_core/__init__.py new file mode 100644 index 0000000..50ec884 --- /dev/null +++ b/src/lyricflow_core/__init__.py @@ -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", +] diff --git a/src/lyricflow_core/api/__init__.py b/src/lyricflow_core/api/__init__.py new file mode 100644 index 0000000..35913e9 --- /dev/null +++ b/src/lyricflow_core/api/__init__.py @@ -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", +] + diff --git a/src/lyricflow_core/api/analysis.py b/src/lyricflow_core/api/analysis.py new file mode 100644 index 0000000..8f4004e --- /dev/null +++ b/src/lyricflow_core/api/analysis.py @@ -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() diff --git a/src/lyricflow_core/api/facade.py b/src/lyricflow_core/api/facade.py new file mode 100644 index 0000000..7f594e7 --- /dev/null +++ b/src/lyricflow_core/api/facade.py @@ -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() + diff --git a/src/lyricflow_core/api/project_state.py b/src/lyricflow_core/api/project_state.py new file mode 100644 index 0000000..02e5516 --- /dev/null +++ b/src/lyricflow_core/api/project_state.py @@ -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() + diff --git a/src/lyricflow_core/engine/__init__.py b/src/lyricflow_core/engine/__init__.py new file mode 100644 index 0000000..def1f08 --- /dev/null +++ b/src/lyricflow_core/engine/__init__.py @@ -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", +] diff --git a/src/lyricflow_core/engine/common.py b/src/lyricflow_core/engine/common.py new file mode 100644 index 0000000..4139784 --- /dev/null +++ b/src/lyricflow_core/engine/common.py @@ -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 diff --git a/src/lyricflow_core/engine/phonetics.py b/src/lyricflow_core/engine/phonetics.py new file mode 100644 index 0000000..10dcb08 --- /dev/null +++ b/src/lyricflow_core/engine/phonetics.py @@ -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() diff --git a/src/lyricflow_core/engine/rhyme_engine.py b/src/lyricflow_core/engine/rhyme_engine.py new file mode 100644 index 0000000..4f20590 --- /dev/null +++ b/src/lyricflow_core/engine/rhyme_engine.py @@ -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() diff --git a/src/lyricflow_core/engine/spellcheck.py b/src/lyricflow_core/engine/spellcheck.py new file mode 100644 index 0000000..30cf67d --- /dev/null +++ b/src/lyricflow_core/engine/spellcheck.py @@ -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() diff --git a/src/lyricflow_core/engine/syntax.py b/src/lyricflow_core/engine/syntax.py new file mode 100644 index 0000000..632815b --- /dev/null +++ b/src/lyricflow_core/engine/syntax.py @@ -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) diff --git a/src/lyricflow_core/storage/__init__.py b/src/lyricflow_core/storage/__init__.py new file mode 100644 index 0000000..0c5d689 --- /dev/null +++ b/src/lyricflow_core/storage/__init__.py @@ -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", +] diff --git a/src/lyricflow_core/storage/app_settings.py b/src/lyricflow_core/storage/app_settings.py new file mode 100644 index 0000000..f7841bd --- /dev/null +++ b/src/lyricflow_core/storage/app_settings.py @@ -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 diff --git a/src/lyricflow_core/storage/db_manager.py b/src/lyricflow_core/storage/db_manager.py new file mode 100644 index 0000000..8a123b1 --- /dev/null +++ b/src/lyricflow_core/storage/db_manager.py @@ -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() diff --git a/src/lyricflow_core/storage/file_manager.py b/src/lyricflow_core/storage/file_manager.py new file mode 100644 index 0000000..836c7f9 --- /dev/null +++ b/src/lyricflow_core/storage/file_manager.py @@ -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" diff --git a/src/lyricflow_core/storage/session_store.py b/src/lyricflow_core/storage/session_store.py new file mode 100644 index 0000000..569b4dd --- /dev/null +++ b/src/lyricflow_core/storage/session_store.py @@ -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 diff --git a/src/utils/app_settings.py b/src/utils/app_settings.py new file mode 100644 index 0000000..fe8199c --- /dev/null +++ b/src/utils/app_settings.py @@ -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"] diff --git a/src/utils/file_manager.py b/src/utils/file_manager.py new file mode 100644 index 0000000..158c486 --- /dev/null +++ b/src/utils/file_manager.py @@ -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"] diff --git a/src/utils/session_store.py b/src/utils/session_store.py new file mode 100644 index 0000000..9427078 --- /dev/null +++ b/src/utils/session_store.py @@ -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"] diff --git a/tests/test_app_settings.py b/tests/test_app_settings.py new file mode 100644 index 0000000..5fc1cfb --- /dev/null +++ b/tests/test_app_settings.py @@ -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() diff --git a/tests/test_core_api_analysis.py b/tests/test_core_api_analysis.py new file mode 100644 index 0000000..7a1bf00 --- /dev/null +++ b/tests/test_core_api_analysis.py @@ -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() diff --git a/tests/test_core_api_project_state.py b/tests/test_core_api_project_state.py new file mode 100644 index 0000000..a6f8376 --- /dev/null +++ b/tests/test_core_api_project_state.py @@ -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() diff --git a/tests/test_core_compat.py b/tests/test_core_compat.py new file mode 100644 index 0000000..ac0dc1e --- /dev/null +++ b/tests/test_core_compat.py @@ -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() diff --git a/tests/test_db_manager.py b/tests/test_db_manager.py new file mode 100644 index 0000000..996e1d9 --- /dev/null +++ b/tests/test_db_manager.py @@ -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" diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..3a71de1 --- /dev/null +++ b/tests/test_engine.py @@ -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() diff --git a/tests/test_explorer_safety.py b/tests/test_explorer_safety.py new file mode 100644 index 0000000..b75cbc5 --- /dev/null +++ b/tests/test_explorer_safety.py @@ -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() diff --git a/tests/test_project_state_compat.py b/tests/test_project_state_compat.py new file mode 100644 index 0000000..938918f --- /dev/null +++ b/tests/test_project_state_compat.py @@ -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() diff --git a/tests/test_session_store.py b/tests/test_session_store.py new file mode 100644 index 0000000..e3f3228 --- /dev/null +++ b/tests/test_session_store.py @@ -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() diff --git a/tests/test_spellcheck.py b/tests/test_spellcheck.py new file mode 100644 index 0000000..823cff0 --- /dev/null +++ b/tests/test_spellcheck.py @@ -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() diff --git a/tests/test_syntax.py b/tests/test_syntax.py new file mode 100644 index 0000000..bb3cf65 --- /dev/null +++ b/tests/test_syntax.py @@ -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()