commit 9f3a599b790a9f4746a81b17de89e3fb762f531f Author: git_alhan Date: Thu May 21 04:13:45 2026 +0300 Initial commit: Noteki v1.0 - Modern sticky notes app for Windows - PySide6 full-screen transparent canvas - System tray integration, global Alt+N hotkey - Session management, undo/redo, auto-save - Custom color palette with dynamic contrast - Pin notes always-on-top, Tab navigation - Windows Named Mutex single-instance guard diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f6e5da8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,164 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +bin/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script, you may want to delete them afterwards +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nosenv/ +.pytest_cache/ +.mypy_cache/ +.pyre/ +.pytype/ +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.run/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or app, you might want to share this in the repository +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if lock is not desired: +# Pipfile.lock + +# poetry +# Similar to Pipfile.lock, poetry.lock is generally recommended to be committed +# poetry.lock + +# pdm +# Similar to Pipfile.lock, pdm.lock is generally recommended to be committed +# pdm.lock + +# PEP 582; modern alternative to virtualenv, used by pdm and rye +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# OS-specific +.DS_Store +Thumbs.db + +# IDEs +.idea/ +.vscode/ +*.swp +*.swo + +# Local App Data (Personal Sticky Note DBs) +# Do NOT upload your personal notes to GitHub +session.json +deepseek.env +*.env +session.backup.json +settings.json +sticky_canvas.lock +noteki.lock +logs/ +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1f08f24 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Noteki Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cd545b6 --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +# Noteki — Sticky Notes for Windows + +

+ Noteki +

+ +

+ Modern, lightweight, customizable sticky notes that stay out of your way. +

+ +

+ Python + PySide6 + Windows + MIT + Release + EXE +

+ +--- + +**Noteki** is a modern sticky notes app that lives in your system tray. Press a hotkey, jot something down, and move on. It uses a transparent full-screen canvas — notes appear exactly where you left them, and disappear when you click away. + +No accounts, no cloud, no bloat. Your notes stay on your machine. + +--- + +## Features + +| Feature | Description | +|---------|-------------| +| **Always-on-Top** | Pin individual notes to stay above all other windows. | +| **Custom Colors** | Add, remove, or change note colors dynamically. Text contrast is auto-calculated. | +| **Session Management** | Create, clone, and switch between separate workspaces — perfect for different projects. | +| **Global Hotkey** | `Alt+N` (configurable) toggles all notes from anywhere. | +| **Undo/Redo** | Accidentally deleted a note? `Ctrl+Z` brings it right back. | +| **Auto-Save** | Notes save instantly on focus-out. No manual saving needed. | +| **Click-Through** | Click empty canvas space to hide notes and interact with windows behind them. | +| **Keyboard Navigation** | `Tab` / `Shift+Tab` to cycle between notes. `Esc` to hide. | +| **Start with Windows** | Optional — toggle in Settings. No admin rights needed. | +| **Zero Latency** | Single-canvas architecture keeps CPU/RAM usage minimal when idle. | + +--- + +## Quick Start + +### Option 1 — Download EXE (no Python required) + +Grab the latest `Noteki.exe` from [Releases](https://github.com/kullaniciadi/noteki/releases/latest) and run it. That's it. + +### Option 2 — Run from source + +```bash +git clone https://github.com/kullaniciadi/noteki.git +cd noteki +pip install -r requirements.txt +python main.py +``` + +**Requirements:** Python 3.8+, PySide6, Pillow. + +--- + +## Keyboard Shortcuts + +| Shortcut | Action | +|----------|--------| +| `Alt+N` *(configurable)* | Show / hide all notes | +| `Ctrl+Z` | Undo last deletion | +| `Tab` / `Shift+Tab` | Cycle focus between notes | +| `Esc` | Hide notes | +| Right-click (empty area) | Quick menu: new note, bring all to front | +| Right-click (on note) | Color picker, copy, paste, delete | +| Double-click (note header) | Focus text editor | + +--- + +## Data & Privacy + +All data is stored locally under: + +``` +%APPDATA%\Noteki\ + sessions\ One .json file per workspace + settings.json Your preferences + logs\ Rotating logs (7-day retention) +``` + +**Your notes never leave your computer.** No telemetry, no analytics, no network calls. + +If `session.json` gets corrupted, Noteki automatically recovers from `session.backup.json`. + +--- + +## Building the EXE + +Want to build the standalone executable yourself? + +```bash +pip install pyinstaller pillow + +# Convert PNG icon to ICO +python -c "from PIL import Image; Image.open('assets/icon.png').save('assets/icon.ico', format='ICO')" + +# Build single-file EXE +pyinstaller --noconsole --onefile --icon="assets/icon.ico" --add-data "assets;assets" main.py +``` + +The output will be in `dist/main.exe`. Rename it to `Noteki.exe` and you're done. + +--- + +## Project Structure + +``` +noteki/ + main.py Entry point + requirements.txt Dependencies + assets/ + icon.png App & tray icon + src/ + app.py Orchestrator — wires everything together + canvas_window.py Full-screen transparent canvas + note_widget.py Individual sticky note (header, text, resize, drag) + add_button.py Floating "+" button + data_manager.py JSON persistence, auto-save, sessions + hotkey_manager.py Windows global hotkey (ctypes + QThread) + tray_manager.py System tray icon & context menu + settings_dialog.py Settings window (font, color, sessions, hotkey) + undo_manager.py RAM-based undo queue (10-deep) + constants.py App-wide constants & color math +``` + +--- + +## License + +MIT — see [LICENSE](LICENSE) for details. + +--- + +

+ Built with and PySide6 +

diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..65e6578 Binary files /dev/null and b/assets/icon.png differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..52ad6e1 --- /dev/null +++ b/main.py @@ -0,0 +1,51 @@ +""" +main.py — Sticky Canvas uygulama giriş noktası +""" + +import sys +import os + + +def main() -> None: + # PySide6 yüklü mu? + try: + from PySide6.QtWidgets import QApplication + except ImportError: + print( + "PySide6 bulunamadı. Lütfen şu komutu çalıştırın:\n" + " pip install PySide6", + file=sys.stderr, + ) + sys.exit(1) + + # src paketinin konumunu path'e ekle + src_dir = os.path.dirname(os.path.abspath(__file__)) + if src_dir not in sys.path: + sys.path.insert(0, src_dir) + + try: + from src.app import StickyCanvasApp + app = StickyCanvasApp() + sys.exit(app.run()) + except Exception as exc: + import logging + import traceback + logging.getLogger(__name__).critical("Kritik hata: %s", exc, exc_info=True) + # Kullanıcıya hata göster + try: + from PySide6.QtWidgets import QApplication, QMessageBox + q = QApplication.instance() or QApplication(sys.argv) + QMessageBox.critical( + None, + "Noteki — Kritik Hata", + f"Uygulama beklenmedik bir hatayla karşılaştı:\n\n{exc}\n\n" + "Detaylar için log dosyasını kontrol edin.", + ) + except Exception: + print(f"KRITIK HATA: {exc}", file=sys.stderr) + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/prd.md b/prd.md new file mode 100644 index 0000000..34a82b4 --- /dev/null +++ b/prd.md @@ -0,0 +1,278 @@ +# Product Requirement Document (PRD) - Sticky Canvas + + + +## 1. Proje Özeti + +Windows işletim sistemi üzerinde çalışan, minimum kaynak tüketimi ve yüksek performans odaklı, ekranın tüm yüzeyini aktif bir tuval (canvas) olarak kullanan bir masaüstü yapışkan not (Sticky Notes) uygulamasıdır. + + + +## 2. Teknik Mimari ve Altyapı + +* **Geliştirme Dili ve Kütüphane:** Python 3.x / PySide6 (Qt for Python). + +* **Hedef İşletim Sistemi:** Windows (Master Monitör odaklı). Kod yapısı Linux için derlemeye uygun mimaride kurulacaktır. + +* **Pencere Yönetimi:** Tek bir tam ekran transparan katman (Canvas Window) oluşturulacaktır. `Qt.FramelessWindowHint` ve `Qt.WA_TranslucentBackground` öznitelikleri kullanılacaktır. + +* **Tek Instance Kilidi (Single Instance Lock):** Uygulama aynı anda yalnızca tek bir kopya olarak çalışabilecektir. İkinci bir instance başlatılmaya çalışıldığında, mevcut instance öne getirilecek ve yeni instance sessizce kapanacaktır. Bu mekanizma `QLockFile` veya Named Mutex ile sağlanacaktır. + + + +## 3. Fonksiyonel Gereksinimler + + + +### 3.1. Katman ve Görünürlük Yönetimi (Toggle State) + +* Uygulama, global `Alt + N` kısayolu veya sistem tepsisi (System Tray) ikonu aracılığıyla tetiklendiğinde tüm not katmanıyla birlikte en üste (`Always on Top`) gelecektir. + +* Görünürlük bir "Toggle" mekanizmasıyla çalışacaktır: Kısayola tekrar basıldığında veya kullanıcı not alanları dışında boş bir alana tıkladığında, uygulama odağını (focus) kaybederek kendini arka plana atacak ve tıklama geçirgenliği (`Click-Through`) aktifleşecektir. + +* **Kısayol Çakışması:** `Alt+N` kısayolu başka bir uygulama tarafından kullanılıyorsa veya kayıt başarısız olursa, kullanıcıya System Tray üzerinden bilgi verilecek ve kısayol Ayarlar penceresinden değiştirilebilecektir. + + + +### 3.2. Not Ekleme, Düzenleme ve Silme + +* **Yeni Not Oluşturma:** Ekranın sağ alt köşesinde sabit duran bir "+" butonu veya ekranın boş bir yerine sağ tıklayarak açılan bağlam menüsü (Context Menu) üzerinden yeni not eklenecektir. + +* **Varsayılan Yeni Not Pozisyonu:** Yeni not, ekranın merkezine yakın bir alanda, mevcut notlarla çakışmayacak şekilde kaskad (cascade) düzeninde oluşturulacaktır (her yeni not bir öncekinden ~20px sağa ve aşağı kaydırılır). + +* **Not Silme:** Her not kutusunun sağ üst köşesinde bir "X" butonu yer alacaktır. Undo mekanizması mevcut olduğundan, silme işlemi için onay diyaloğu gösterilmeyecektir. + +* **Boş Not Davranışı:** İçeriği boş olan notlar, kullanıcı notu terk ettiğinde (başka bir nota tıkladığında veya canvas dışına çıktığında) otomatik olarak silinecektir. + +* **Z-Index (Katman Sırası):** Notlar üst üste bindiğinde, kullanıcının son tıkladığı veya sürüklediği not otomatik olarak en üste çıkacaktır (Auto Z-Index). + + + +### 3.3. Not Kutusu Özellikleri + +* **Varsayılan Boyut:** Yeni oluşturulan notlar 220x180 piksel boyutunda olacaktır. + +* **Boyut Sınırları:** Minimum boyut: 150x100 px. Maksimum boyut: 600x500 px. + +* **Yeniden Boyutlandırma (Resize):** Notlar, sağ alt köşelerindeki tutamaç (resize grip) ile yeniden boyutlandırılabilecektir. + +* **Renk Paleti:** Notlar aşağıdaki 6 renk seçeneğinden birine sahip olabilecektir: + - Sarı (varsayılan): `#FFF9C4` + - Yeşil: `#C8E6C9` + - Mavi: `#BBDEFB` + - Pembe: `#F8BBD0` + - Mor: `#E1BEE7` + - Turuncu: `#FFE0B2` + +* **Metin Desteği:** Yalnızca düz metin (plain text) desteklenecektir. Zengin metin biçimlendirme (bold, italic vb.) bu sürümde kapsam dışıdır. + +* **Metin Taşması:** İçerik nota sığmadığında dikey kaydırma çubuğu (vertical scrollbar) otomatik olarak görünür olacaktır. + +* **Karakter Limiti:** Tek bir notta maksimum 2000 karakter girilebilecektir. + +* **Büyük Metin Yapıştırma:** Clipboard'dan 2000 karakterden fazla metin yapıştırılmaya çalışıldığında, metin 2000 karaktere kırpılacak ve kullanıcıya kısa bir tooltip ile bilgi verilecektir. + + + +### 3.4. Not Üzerinde Sağ Tıklama Menüsü (Context Menu) + +Not üzerine sağ tıklandığında aşağıdaki menü gösterilecektir: + +``` +├── Renk Değiştir ──► [Sarı] [Yeşil] [Mavi] [Pembe] [Mor] [Turuncu] +├── Kopyala (Ctrl+C) +├── Yapıştır (Ctrl+V) +├── ───────────── +└── Notu Sil +``` + + + +### 3.5. Yapılandırma ve Ayarlar + +* **Yönetim Merkezi:** Grafiksel bir ana pencere olmayacaktır. Tüm yönetim Sistem Tepsi (System Tray) ikonuna sağ tıklanarak açılan menüden yapılacaktır. + +* **Ayarlar Penceresi:** Tray menüsünden erişilebilir olacak ve aşağıdaki ayarları içerecektir: + - Varsayılan font tipi + - Varsayılan font boyutu (8-32 px arası) + - Varsayılan not rengi + - Global kısayol tuşu değiştirme + - Windows başlangıcında otomatik çalıştır (varsayılan: kapalı) + + + +### 3.6. System Tray Menü Yapısı + +System Tray ikonu üzerine sol tıklandığında Toggle (Göster/Gizle) tetiklenecektir. + +Sağ tıklandığında aşağıdaki menü gösterilecektir: + +``` +├── Notları Göster/Gizle +├── Yeni Not Ekle +├── ───────────── +├── Tüm Notları Sil (onay diyaloğu ile) +├── Geri Al (Ctrl+Z) +├── ───────────── +├── Ayarlar... +├── ───────────── +└── Çıkış +``` + +* **Tray Tooltip:** İkon üzerine gelindiğinde "Sticky Canvas — X Not" şeklinde tooltip gösterilecektir. + + + +## 4. Kullanıcı Etkileşimi ve Akış (UX/UI) + + + +### 4.1. Boş Alan ve Tıklama Geçirgenliği Mekanizması + +[Alt+N / Tray Tıklaması] ──> Canvas Öne Çıkar (Notlar Düzenlenebilir) + +│ + +┌─────────────────────────┴────────────────────────┐ + +▼ ▼ + +[Not Üzerine Tıklama] [Boş Alana Tıklama] + +│ │ + +├─ Sürükle/Bırak └─ Odak Kaybolur (Focus Lost) + +├─ Metin Düzenle Canvas Arka Plana Atılır + +└─ Z-Index Artır (Öne Getir) OS Pencereleri Üste Çıkar + + + +### 4.2. Not Kutusu Davranışları + +* Notlar çerçevesiz, düz renk arka plana sahip kutulardan oluşacaktır. Hafif bir drop-shadow (gölge) uygulanacaktır. + +* Her not kutusu başlık çubuğu olmadan, gövdesinden tutularak ekranın herhangi bir yerine sürüklenebilecektir. + +* **Sürükleme Göstergesi:** Sürükleme sırasında notun opaklığı %85'e düşürülecek, gölge derinliği artırılacaktır. + +* **Aktif Not Göstergesi:** Düzenlenmekte olan not, sol kenarında 3px kalınlığında vurgu rengi (accent border) ile belirginleştirilecektir. + +* **"+" Butonu Stili:** 48x48 px, yarı-saydam koyu arka plan, beyaz "+" ikonu, üzerine gelindiğinde (hover) opaklık artışı. + + + +### 4.3. Sağ Tıklama — Boş Alan + +Canvasın boş bir alanına sağ tıklandığında: + +``` +├── Yeni Not Ekle +├── ───────────── +├── Tüm Notları Göster +└── Geri Al (Ctrl+Z) +``` + + + +## 5. Veri Yönetimi ve Performans + + + +### 5.1. Kalıcılık (Persistence) + +* **Dosya Konumu:** Tüm veriler `%APPDATA%/StickyCanvas/` dizini altında saklanacaktır. + +* **Oturum Dosyası:** Aktif notların tüm bilgileri `session.json` dosyasında tutulacaktır. + +* **Ayarlar Dosyası:** Kullanıcı tercihleri `settings.json` dosyasında ayrı olarak saklanacaktır. + +* **Yedekleme (Backup):** Her başarılı kayıtta mevcut dosya `session.backup.json` olarak yedeklenecektir. Ana dosya bozulursa yedek dosyadan otomatik olarak kurtarma yapılacaktır. + +* **Auto-Save:** Kullanıcı metin alanından çıktığında, bir notu sürükleyip bıraktığında veya yeni not ekleyip sildiğinde JSON dosyası asenkron olarak anında güncellenecektir. + + + +### 5.2. Veri Şeması (Data Schema) + +**session.json:** +```json +{ + "version": "1.0", + "notes": [ + { + "id": "uuid-v4", + "content": "Not içeriği...", + "x": 100, + "y": 200, + "width": 220, + "height": 180, + "color": "#FFF9C4", + "z_index": 1, + "created_at": "2026-05-20T22:00:00Z", + "updated_at": "2026-05-20T22:05:00Z" + } + ] +} +``` + +**settings.json:** +```json +{ + "version": "1.0", + "default_font_family": "Segoe UI", + "default_font_size": 14, + "default_note_color": "#FFF9C4", + "hotkey": "Alt+N", + "auto_start": false +} +``` + + + +### 5.3. Geri Alma (Undo History) + +* Yanlışlıkla silinen notları kurtarmak amacıyla bellek üzerinde (RAM) 10 işlem kapasiteli bir geçmiş kuyruğu (History Queue) tutulacaktır. + +* Uygulama tamamen kapatılmadığı sürece, silinen notlar bu kuyruktan sırayla geri getirilebilecektir (`Ctrl + Z` entegrasyonu ile). Uygulama kapatıldığında bu geçici kuyruk temizlenecektir. + + + +### 5.4. Kaynak Tüketim Sınırları + +* Tek pencere (Canvas) mimarisi sayesinde uygulama boşta çalışırken RAM tüketimi minimum düzeyde tutulacak, CPU kullanımı ise yalnızca çizim (render) ve metin girişi esnasında tetiklenecektir. + +* **Maksimum Not Sayısı:** Performans garantisi açısından maksimum 50 aktif not desteklenecektir. Limite ulaşıldığında "+" butonu devre dışı kalacak ve kullanıcıya bilgi verilecektir. + + + +## 6. Hata Yönetimi ve Sınır Durumları (Edge Cases) + +* **Ekran Dışı Not Kurtarma:** Çözünürlük değiştiğinde veya monitör bağlantısı kesildiğinde, ekran sınırları dışında kalan notlar otomatik olarak görünür alana (ekranın sol üst köşesine kaskad düzeninde) taşınacaktır. Bu kontrol uygulama başlatıldığında yapılacaktır. + +* **Dosya Bozulması Kurtarma:** `session.json` okunamazsa, `session.backup.json` dosyasından kurtarma denenecektir. Her iki dosya da bozuksa uygulama boş bir oturumla başlayacak ve kullanıcıya bilgi verilecektir. + +* **Loglama:** Hata ve kritik olaylar `%APPDATA%/StickyCanvas/logs/` dizininde günlük bazlı log dosyalarında tutulacaktır (rotating file handler, 7 günlük). Standart Python `logging` modülü kullanılacaktır. + + + +## 7. Uygulama Yaşam Döngüsü + +* **İlk Açılış:** Uygulama ilk kez çalıştırıldığında, kullanımı anlatan kısa bir "Hoş Geldin" notu otomatik olarak oluşturulacaktır. + +* **Windows Başlangıcında Otomatik Çalıştırma:** Ayarlar penceresinden etkinleştirilebilir. Windows Registry (`HKCU\Software\Microsoft\Windows\CurrentVersion\Run`) üzerinden yönetilecektir. + +* **Çıkış Davranışı:** Tray menüsünden "Çıkış" seçildiğinde onay diyaloğu gösterilmeden uygulama kapanacaktır. Veriler zaten auto-save ile kayıtlıdır. + +* **Tray'e Küçültme:** "X" veya pencere kapatma eylemi uygulamayı kapatmaz, System Tray'e küçültür. + + + +## 8. Erişilebilirlik (Accessibility) + +* **Klavye Navigasyonu:** Notlar arasında `Tab` tuşu ile geçiş yapılabilecektir. `Shift+Tab` ters yönde geçiş sağlayacaktır. + +* **Ekran Okuyucu Desteği:** Qt Accessibility API kullanılarak, notlar ve butonlar için uygun erişilebilirlik etiketleri (accessible names/descriptions) tanımlanacaktır. + +* **Dil:** Uygulama arayüzü Türkçe olacaktır. İlk sürümde çoklu dil desteği planlanmamaktadır. diff --git a/pyinstaller_guide.md b/pyinstaller_guide.md new file mode 100644 index 0000000..423310e --- /dev/null +++ b/pyinstaller_guide.md @@ -0,0 +1,64 @@ +# Sticky Canvas — EXE Paketleme ve Windows Başlangıç Kılavuzu + +Bu kılavuz, uygulamanızı tek bir `.exe` dosyası haline getirmeyi ve "Windows ile Birlikte Başlat" seçeneğinin arka planda nasıl çalıştığını açıklar. + +--- + +## 1. Uygulamayı Standart EXE Haline Getirme (PyInstaller) + +Uygulamanızı tek bir dosya (`.exe`) haline getirmek için **PyInstaller** kütüphanesini kullanabilirsiniz. + +### Adım 1: Gerekli Kütüphaneleri Yükleyin +PowerShell veya Komut İstemi'ni açıp şu komutları çalıştırın: +```bash +pip install pyinstaller pillow +``` +*(Not: `pillow`, ikon dönüştürme için kullanılacaktır.)* + +### Adım 2: PNG İkonunu ICO Formatına Dönüştürün +Windows, `.exe` dosyalarının masaüstü ve dosya gezgini ikonları için özel `.ico` formatını şart koşar. Aşağıdaki komutla mevcut `icon.png` dosyasını otomatik olarak `icon.ico` formatına dönüştürebilirsiniz: +```bash +python -c "from PIL import Image; Image.open('assets/icon.png').save('assets/icon.ico', format='ICO')" +``` +Bu komut, `assets` klasörü altında `icon.ico` adında uyumlu bir Windows ikon dosyası oluşturacaktır. + +### Adım 3: PyInstaller ile Paketleyin +Şimdi projeyi tek bir `.exe` olarak paketlemek için şu komutu çalıştırın: +```bash +pyinstaller --noconsole --onefile --icon="assets/icon.ico" --add-data "assets;assets" main.py +``` + +**Parametrelerin Anlamları:** +* `--noconsole`: Uygulama açıldığında arkada siyah bir komut satırı (CMD) penceresinin açılmasını engeller. Tamamen pürüzsüz bir arayüz sunar. +* `--onefile`: Tüm kaynakları, ikonları ve kütüphaneleri (PySide6 vb.) tek bir `.exe` dosyasında birleştirir. +* `--icon="assets/icon.ico"`: EXE dosyasının Windows üzerindeki resmi simgesini ayarlar. +* `--add-data "assets;assets"`: Uygulamanın kullandığı resim, ikon ve diğer varlıkları içeren `assets` klasörünü paket içerisine gömer. + +### Adım 4: Sonuç +İşlem tamamlandığında, proje klasörünüzde iki yeni dizin oluşacaktır: +1. `build/` (Geçici derleme dosyaları) +2. `dist/` (Paketlenmiş nihai uygulamayı barındırır) + +`dist/main.exe` dosyasını bulup adını `sticky_canvas.exe` veya istediğiniz gibi değiştirebilir, doğrudan çalıştırıp kullanmaya başlayabilirsiniz! `build` klasörünü ve `.spec` dosyasını güvenle silebilirsiniz. + +--- + +## 2. "Windows ile Birlikte Başlat" Nasıl Çalışıyor? + +Uygulamanın Ayarlar penceresindeki **"Windows ile birlikte başlat"** seçeneği, Windows işletim sisteminin yerleşik otomatik başlatma mekanizmasını kullanır. + +### Arka Plandaki Çalışma Mekanizması: +1. **Windows Kayıt Defteri (Registry) Entegrasyonu:** + Windows, sistem açıldığında otomatik olarak çalıştırılması gereken programların listesini Registry üzerinde şu adreste tutar: + `HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run` + +2. **Kayıt Ekleme (Aktif Edildiğinde):** + Bu ayar açıldığında, uygulamamız Registry'de bu dizinin altına `"Sticky Canvas"` adında yeni bir anahtar (String Value) ekler. + * **Geliştirici Modunda (Script):** Değer olarak Python yorumlayıcısının yolunu ve `main.py` dosyamızın tam yolunu yazar: `"[PYTHON_YOLU]\python.exe" "[PROJE_YOLU]\main.py"` + * **Paketlenmiş Modda (EXE):** Değer olarak doğrudan derlenmiş `.exe` dosyamızın tam yolunu yazar: `"[EXE_YOLU]\sticky_canvas.exe"` + +3. **Kayıt Silme (Devre Dışı Bırakıldığında):** + Bu ayar kapatıldığında, uygulamamız Registry'deki `"Sticky Canvas"` anahtarını siler ve Windows açılışında otomatik başlama durdurulur. + +4. **Kullanıcı Yetkisi:** + `HKEY_CURRENT_USER` (HKCU) kullanıldığı için bu işlem **Yönetici yetkisi (UAC / Administrator) gerektirmez.** Herhangi bir kullanıcı hesabı altında tamamen güvenli ve sorunsuz şekilde çalışır. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bad0f75 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +PySide6>=6.6.0 +Pillow>=10.0.0 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..0bf814f --- /dev/null +++ b/src/__init__.py @@ -0,0 +1 @@ +# src/__init__.py diff --git a/src/add_button.py b/src/add_button.py new file mode 100644 index 0000000..343ed56 --- /dev/null +++ b/src/add_button.py @@ -0,0 +1,77 @@ +""" +add_button.py — Sağ alt köşede sabit duran "+" butonu +""" + +from PySide6.QtCore import Qt, QPropertyAnimation, QEasingCurve +from PySide6.QtGui import QFont +from PySide6.QtWidgets import QPushButton, QWidget +from typing import Optional + +from .constants import ADD_BUTTON_SIZE + + +class AddButton(QPushButton): + """ + Ekranın sağ alt köşesinde sabit duran, yarı-saydam "+" butonu. + Hover'da belirginleşir. + """ + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__("+", parent) + self.setFixedSize(ADD_BUTTON_SIZE, ADD_BUTTON_SIZE) + self.setCursor(Qt.PointingHandCursor) + self.setToolTip("Yeni not ekle (sağ tıkla veya buraya tıkla)") + self.setAttribute(Qt.WA_TranslucentBackground) + + font = QFont("Segoe UI", 22, QFont.Light) + self.setFont(font) + + self._apply_style(hovered=False) + + def _apply_style(self, hovered: bool) -> None: + if hovered: + self.setStyleSheet(""" + QPushButton { + background-color: rgba(30, 30, 30, 210); + color: white; + border-radius: 24px; + border: 1.5px solid rgba(255,255,255,0.25); + font-size: 22px; + } + """) + else: + self.setStyleSheet(""" + QPushButton { + background-color: rgba(30, 30, 30, 140); + color: rgba(255,255,255,0.85); + border-radius: 24px; + border: 1.5px solid rgba(255,255,255,0.12); + font-size: 22px; + } + """) + + def enterEvent(self, event) -> None: + self._apply_style(hovered=True) + super().enterEvent(event) + + def leaveEvent(self, event) -> None: + self._apply_style(hovered=False) + super().leaveEvent(event) + + def set_enabled_state(self, enabled: bool) -> None: + """50 not limitine ulaşıldığında butonu devre dışı bırakır.""" + self.setEnabled(enabled) + if enabled: + self._apply_style(hovered=False) + self.setToolTip("Yeni not ekle (sağ tıkla veya buraya tıkla)") + else: + self.setStyleSheet(""" + QPushButton { + background-color: rgba(30, 30, 30, 60); + color: rgba(255,255,255,0.3); + border-radius: 24px; + border: 1.5px solid rgba(255,255,255,0.06); + font-size: 22px; + } + """) + self.setToolTip("Maksimum 50 not limitine ulaşıldı.") diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..94c833b --- /dev/null +++ b/src/app.py @@ -0,0 +1,443 @@ +""" +app.py — StickyCanvasApp: tüm bileşenleri bir araya getiren orkestratör +""" + +import logging +import logging.handlers +import os +import sys +import winreg +from typing import Any, Dict, Optional + +from PySide6.QtCore import QPoint +from PySide6.QtGui import QIcon +from PySide6.QtWidgets import QApplication, QMessageBox + +from .canvas_window import CanvasWindow +from .constants import ( + APP_DATA_DIR, + APP_NAME, + DEFAULT_FONT_FAMILY, + DEFAULT_FONT_SIZE, + LOGS_DIR, + NOTE_COLORS, + NOTE_DEFAULT_COLOR, + NOTE_DEFAULT_HEIGHT, + NOTE_DEFAULT_WIDTH, + REGISTRY_APP_KEY, + REGISTRY_RUN_KEY, +) +from .data_manager import DataManager +from .hotkey_manager import HotkeyManager +from .settings_dialog import SettingsDialog +from .tray_manager import TrayManager +from .undo_manager import UndoManager + +logger = logging.getLogger(__name__) + + +def _setup_logging() -> None: + os.makedirs(LOGS_DIR, exist_ok=True) + log_file = os.path.join(LOGS_DIR, "sticky_canvas.log") + root = logging.getLogger() + root.setLevel(logging.DEBUG) + fh = logging.handlers.TimedRotatingFileHandler( + log_file, when="midnight", backupCount=7, encoding="utf-8" + ) + fh.setLevel(logging.DEBUG) + fh.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")) + root.addHandler(fh) + ch = logging.StreamHandler() + ch.setLevel(logging.INFO) + ch.setFormatter(logging.Formatter("[%(levelname)s] %(name)s: %(message)s")) + root.addHandler(ch) + + +class SingleInstanceGuard: + """Windows Named Mutex ile tek instance kontrolu.""" + + _MUTEX_NAME = r"Global\Noteki_SingleInstance_Mutex" + + def __init__(self) -> None: + self._mutex_handle = None + + def acquire(self) -> bool: + """Mutex'i al. Basariliysa True, zaten calisiyorsa False doner.""" + import ctypes + import ctypes.wintypes + + kernel32 = ctypes.windll.kernel32 + mutex_name = ctypes.c_wchar_p(self._MUTEX_NAME) + self._mutex_handle = kernel32.CreateMutexW(None, False, mutex_name) + if self._mutex_handle == 0 or self._mutex_handle is None: + logger.error("Mutex olusturulamadi.") + return True # olabilecek en kotu: calismaya izin ver + + last_error = kernel32.GetLastError() + if last_error == 183: # ERROR_ALREADY_EXISTS + kernel32.CloseHandle(self._mutex_handle) + self._mutex_handle = None + return False + return True + + def release(self) -> None: + """Mutex'i serbest birak.""" + if self._mutex_handle: + import ctypes + ctypes.windll.kernel32.CloseHandle(self._mutex_handle) + self._mutex_handle = None + + +class StickyCanvasApp: + """Ana uygulama orkestratörü.""" + + def __init__(self) -> None: + _setup_logging() + logger.info("=== %s baslatiliyor ===", APP_NAME) + + self._guard = SingleInstanceGuard() + if not self._guard.acquire(): + app = QApplication.instance() or QApplication(sys.argv) + QMessageBox.information(None, APP_NAME, f"{APP_NAME} zaten calisiyor.") + sys.exit(0) + + # Windows Taskbar Grouping & Icon Fix + if sys.platform == "win32": + import ctypes + try: + myappid = f"antigravity.noteki.stickynotes.1.0" + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) + except Exception as e: + logger.warning("SetCurrentProcessExplicitAppUserModelID basarisiz: %s", e) + + self._app = QApplication.instance() or QApplication(sys.argv) + self._app.setApplicationName(APP_NAME) + self._app.setQuitOnLastWindowClosed(False) + + icon_path = self._icon_path() + if icon_path: + self._app.setWindowIcon(QIcon(icon_path)) + + self._data = DataManager() + self._undo = UndoManager() + self._canvas = CanvasWindow() + settings = self._data.get_settings() + + # Propagate custom/saved background colors to the canvas + self._canvas.set_colors_dict(settings.get("note_colors", NOTE_COLORS)) + + # Apply taskbar/tray visibility setting + self._canvas.set_only_show_in_tray(settings.get("only_show_in_tray", True)) + + self._tray = TrayManager(icon_path=self._icon_path()) + self._hotkey = HotkeyManager(hotkey_str=settings.get("hotkey", "Alt+N")) + + self._connect_signals() + self._load_all_notes() + self._check_first_run() + self._canvas.clamp_notes_to_screen() + self._tray.update_note_count(self._canvas.note_count) + logger.info("Uygulama hazir. %d not yuklendi.", self._canvas.note_count) + + # ── İkon ──────────────────────────────────────────────────────── + + def _icon_path(self) -> Optional[str]: + base = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + p = os.path.join(base, "assets", "icon.png") + return p if os.path.exists(p) else None + + # ── Sinyal Bağlantıları ───────────────────────────────────────── + + def _connect_signals(self) -> None: + # Tray + self._tray.toggle_requested.connect(self._on_toggle) + self._tray.new_note_requested.connect(self._on_new_note_from_tray) + self._tray.settings_requested.connect(self._on_settings) + self._tray.quit_requested.connect(self._on_quit) + self._tray.undo_requested.connect(self._on_undo) + self._tray.clear_all_requested.connect(self._on_clear_all) + + # Canvas + self._canvas.new_note_requested.connect(self._on_new_note) + self._canvas.note_deleted.connect(self._on_note_deleted) + self._canvas.note_content_changed.connect( + lambda nid, c: self._data.update_note(nid, content=c) + ) + self._canvas.note_position_changed.connect( + lambda nid, x, y: self._data.update_note(nid, x=x, y=y) + ) + self._canvas.note_size_changed.connect( + lambda nid, w, h: self._data.update_note(nid, width=w, height=h) + ) + self._canvas.note_color_changed.connect( + lambda nid, c: self._data.update_note(nid, color=c) + ) + self._canvas.note_font_size_changed.connect(self._on_font_size_changed) + self._canvas.note_save_requested.connect(self._on_save_requested) + self._canvas.note_new_requested.connect(self._on_new_note_near) + self._canvas.note_pin_toggled.connect( + lambda nid, p: self._data.update_note(nid, pinned=p) + ) + self._canvas.undo_requested.connect(self._on_undo) + self._canvas.note_settings_requested.connect(self._on_settings) + + # Hotkey + self._hotkey.hotkey_triggered.connect(self._on_toggle) + self._hotkey.registration_failed.connect(self._on_hotkey_failed) + + # Undo restore callback + self._undo.set_restore_callback(self._restore_note) + + self._data.save_completed.connect( + lambda: logger.debug("Auto-save tamamlandi.") + ) + + # ── Not Yükleme ───────────────────────────────────────────────── + + def _load_all_notes(self) -> None: + for nd in self._data.get_notes(): + self._canvas.add_note(nd) + + def _reload_all_notes(self) -> None: + """Session değiştiğinde tüm notları yeniden yükle.""" + # Mevcut widget'ları temizle (data_manager zaten güncel) + for nid, _ in self._canvas.iter_notes(): + self._canvas.remove_note(nid) + for nd in self._data.get_notes(): + self._canvas.add_note(nd) + self._tray.update_note_count(self._canvas.note_count) + + # ── İlk Açılış ────────────────────────────────────────────────── + + def _check_first_run(self) -> None: + if self._data.get_notes(): + return + s = self._data.get_settings() + wd = self._data.create_note( + x=80, y=80, width=280, height=210, + color=NOTE_DEFAULT_COLOR, + font_family=s.get("default_font_family", DEFAULT_FONT_FAMILY), + font_size=s.get("default_font_size", DEFAULT_FONT_SIZE), + ) + widget = self._canvas.add_note(wd) + txt = ( + "Noteki'ye Hos Geldin!\n\n" + " Alt+N Notlari goster/gizle\n" + " Sag tikla Yeni not ekle\n" + " Header Surukle / tasima\n" + " Ctrl+Z Geri al\n\n" + "Bu notu silebilirsin (X butonu)" + ) + self._data.update_note(wd["id"], content=txt) + widget._text_edit.blockSignals(True) + widget._text_edit.setPlainText(txt) + widget._text_edit.blockSignals(False) + self._canvas.show_canvas() + self._tray.update_toggle_label(True) + + # ── Toggle ────────────────────────────────────────────────────── + + def _on_toggle(self) -> None: + self._canvas.toggle_visibility() + self._tray.update_toggle_label(self._canvas.is_visible) + + # ── Yeni Not ──────────────────────────────────────────────────── + + def _on_new_note(self, x: int, y: int) -> None: + if not self._canvas.is_visible: + self._canvas.show_canvas() + self._tray.update_toggle_label(True) + s = self._data.get_settings() + nd = self._data.create_note( + x=x, y=y, + width=NOTE_DEFAULT_WIDTH, height=NOTE_DEFAULT_HEIGHT, + color=s.get("default_note_color", NOTE_DEFAULT_COLOR), + font_family=s.get("default_font_family", DEFAULT_FONT_FAMILY), + font_size=s.get("default_font_size", DEFAULT_FONT_SIZE), + ) + widget = self._canvas.add_note(nd) + widget.focus_text() + self._tray.update_note_count(self._canvas.note_count) + logger.info("Yeni not olusturuldu: %s", nd["id"]) + + def _on_new_note_from_tray(self) -> None: + if not self._canvas.is_visible: + self._canvas.show_canvas() + self._tray.update_toggle_label(True) + pos = self._canvas.get_next_cascade_position() + self._on_new_note(pos.x(), pos.y()) + + def _on_new_note_near(self, source_note_id: str) -> None: + """Icon bar'dan '+' butonuna basıldığında yeni not ekle.""" + pos = self._canvas.get_next_cascade_position() + self._on_new_note(pos.x(), pos.y()) + + # ── Not Silme ─────────────────────────────────────────────────── + + def _on_note_deleted(self, note_id: str) -> None: + widget = self._canvas.get_note_widget(note_id) + is_empty = widget.is_empty() if widget else False + removed = self._data.delete_note(note_id) + self._canvas.remove_note(note_id) + if removed and not is_empty: + self._undo.push(removed) + self._tray.update_note_count(self._canvas.note_count) + logger.info("Not silindi: %s (bos=%s)", note_id, is_empty) + + # ── Font Boyutu ────────────────────────────────────────────────── + + def _on_font_size_changed(self, note_id: str, size: int) -> None: + self._data.update_note(note_id, font_size=size) + + # ── Kaydet (OK butonu / focus out) ─────────────────────────────── + + def _on_save_requested(self, note_id: str) -> None: + """Hem focus-out hem OK butonu için.""" + widget = self._canvas.get_note_widget(note_id) + if widget and widget.is_empty(): + # Boş not → sil + self._on_note_deleted(note_id) + return + self._data.force_save() + logger.debug("Force save: %s", note_id) + + # ── Undo ──────────────────────────────────────────────────────── + + def _on_undo(self) -> None: + if not self._undo.can_undo(): + self._tray.show_message(APP_NAME, "Geri alinacak islem yok.") + return + self._undo.pop() + + def _restore_note(self, nd: Dict[str, Any]) -> None: + self._data.restore_note(nd) + self._canvas.add_note(nd) + self._tray.update_note_count(self._canvas.note_count) + if not self._canvas.is_visible: + self._canvas.show_canvas() + self._tray.update_toggle_label(True) + + # ── Tümünü Sil ────────────────────────────────────────────────── + + def _on_clear_all(self) -> None: + removed = self._data.clear_all_notes() + for n in removed: + self._canvas.remove_note(n["id"]) + for n in removed[-10:]: + self._undo.push(n) + self._tray.update_note_count(0) + + # ── Ayarlar ───────────────────────────────────────────────────── + + def _on_settings(self) -> None: + dlg = SettingsDialog( + current_settings=self._data.get_settings(), + sessions=self._data.list_sessions(), + current_session=self._data.current_session_name(), + ) + dlg.settings_saved.connect(self._apply_settings) + dlg.session_switch_requested.connect(self._on_session_switch) + dlg.session_create_requested.connect(self._on_session_create) + dlg.session_copy_requested.connect(self._on_session_copy) + dlg.session_delete_requested.connect(self._on_session_delete) + dlg.exec() + + def _apply_settings(self, new: Dict[str, Any]) -> None: + old = self._data.get_settings() + old_font_family = old.get("default_font_family", DEFAULT_FONT_FAMILY) + old_font_size = old.get("default_font_size", DEFAULT_FONT_SIZE) + + new_font_family = new.get("default_font_family", old_font_family) + new_font_size = new.get("default_font_size", old_font_size) + + font_family_changed = (new_font_family != old_font_family) + + self._data.save_settings(new) + # Kısayol + if new.get("hotkey") != self._hotkey._hotkey_str: + self._hotkey.change_hotkey(new["hotkey"]) + # Otomatik başlatma + self._apply_auto_start(new.get("auto_start", False)) + # Görev çubuğu/tepsi gösterimi + self._canvas.set_only_show_in_tray(new.get("only_show_in_tray", True)) + + # Renk paleti + if "note_colors" in new: + self._canvas.set_colors_dict(new["note_colors"]) + + if font_family_changed: + for nid, w in self._canvas.iter_notes(): + w._apply_font(new_font_family, w._font_size) + self._data.update_note(nid, font_family=new_font_family) + + logger.info("Ayarlar uygulandi.") + + def _apply_auto_start(self, enabled: bool) -> None: + try: + if getattr(sys, 'frozen', False): + val = f'"{sys.executable}"' + else: + exe = sys.executable + main = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "main.py")) + val = f'"{exe}" "{main}"' + + with winreg.OpenKey( + winreg.HKEY_CURRENT_USER, REGISTRY_RUN_KEY, 0, winreg.KEY_SET_VALUE + ) as key: + if enabled: + winreg.SetValueEx(key, REGISTRY_APP_KEY, 0, winreg.REG_SZ, val) + else: + try: + winreg.DeleteValue(key, REGISTRY_APP_KEY) + except FileNotFoundError: + pass + except Exception as exc: + logger.error("Registry islemi basarisiz: %s", exc) + + # ── Session Yönetimi ───────────────────────────────────────────── + + def _on_session_switch(self, name: str) -> None: + ok, notes = self._data.switch_session(name) + if ok: + self._reload_all_notes() + self._tray.show_message(APP_NAME, f"Oturum: {name}") + else: + QMessageBox.warning(None, APP_NAME, f"Oturum yuklenemedi: {name}") + + def _on_session_create(self, name: str) -> None: + ok, result = self._data.create_session(name) + if ok: + self._on_session_switch(result) + self._tray.show_message(APP_NAME, f"Yeni oturum olusturuldu: {result}") + else: + QMessageBox.warning(None, APP_NAME, result) + + def _on_session_copy(self, source_name: str, target_name: str) -> None: + ok, result = self._data.copy_session(source_name, target_name) + if ok: + self._on_session_switch(result) + self._tray.show_message(APP_NAME, f"Oturum kopyalandi: {result}") + else: + QMessageBox.warning(None, APP_NAME, result) + + def _on_session_delete(self, name: str) -> None: + if not self._data.delete_session(name): + QMessageBox.warning(None, APP_NAME, "Aktif oturum silinemez.") + + # ── Kısayol Hatası ─────────────────────────────────────────────── + + def _on_hotkey_failed(self, msg: str) -> None: + self._tray.show_message(APP_NAME, f"Kisayol hatasi: {msg}") + + # ── Çıkış ─────────────────────────────────────────────────────── + + def _on_quit(self) -> None: + logger.info("Uygulama kapatiliyor...") + self._data.force_save() + self._hotkey.cleanup() + self._undo.clear() + self._guard.release() + self._app.quit() + + def run(self) -> int: + return self._app.exec() diff --git a/src/canvas_window.py b/src/canvas_window.py new file mode 100644 index 0000000..0720c9d --- /dev/null +++ b/src/canvas_window.py @@ -0,0 +1,417 @@ +""" +canvas_window.py — Tam ekran transparan canvas penceresi +- WindowStaysOnTopHint KALDIRILDI (notlar arkaya geçebilir) +- App focus kaybettiğinde auto-hide +- "+" butonu sadece not yokken görünür +- Pinned not → top-level window yönetimi +""" + +import ctypes +import ctypes.wintypes +import logging +from typing import Any, Dict, Optional + +from PySide6.QtCore import QPoint, Qt, Signal, QTimer +from PySide6.QtGui import QColor, QPainter, QShortcut, QKeySequence +from PySide6.QtWidgets import QApplication, QMenu, QWidget + +from .add_button import AddButton +from .constants import ( + ADD_BUTTON_MARGIN, + ADD_BUTTON_SIZE, + NOTE_CASCADE_OFFSET, + NOTE_COLORS, + NOTE_DEFAULT_HEIGHT, + NOTE_DEFAULT_WIDTH, + NOTE_MAX_COUNT, +) +from .note_widget import NoteWidget + +logger = logging.getLogger(__name__) + +GWL_EXSTYLE = -20 +WS_EX_TRANSPARENT = 0x00000020 +WS_EX_LAYERED = 0x00080000 + + +def _set_click_through(hwnd: int, enabled: bool) -> None: + try: + style = ctypes.windll.user32.GetWindowLongW(hwnd, GWL_EXSTYLE) + if enabled: + style |= WS_EX_TRANSPARENT | WS_EX_LAYERED + else: + style &= ~WS_EX_TRANSPARENT + ctypes.windll.user32.SetWindowLongW(hwnd, GWL_EXSTYLE, style) + except Exception as exc: + logger.warning("Click-through ayarlanamadi: %s", exc) + + +class CanvasWindow(QWidget): + """Tam ekran transparan canvas.""" + + # Sinyaller + new_note_requested = Signal(int, int) + note_deleted = Signal(str) + note_content_changed = Signal(str, str) + note_position_changed = Signal(str, int, int) + note_size_changed = Signal(str, int, int) + note_color_changed = Signal(str, str) + note_font_size_changed = Signal(str, int) + note_save_requested = Signal(str) + note_new_requested = Signal(str) + note_pin_toggled = Signal(str, bool) + undo_requested = Signal() + note_settings_requested = Signal() # Settings gear request signal + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self._notes: Dict[str, NoteWidget] = {} + self._pinned_notes: Dict[str, NoteWidget] = {} # pin edilmiş notlar (top-level) + self._visible: bool = False + self._last_cascade = QPoint(80, 80) + self._only_show_in_tray = True + self._colors_dict = NOTE_COLORS + + self._setup_window() + self._setup_add_button() + self._setup_app_state_watcher() + + # Canvas-level Shortcut for Ctrl+Z to undo deleted notes + self._undo_shortcut = QShortcut(QKeySequence("Ctrl+Z"), self) + self._undo_shortcut.activated.connect(self.undo_requested.emit) + + # ── Pencere Kurulum ───────────────────────────────────────────── + + def _setup_window(self) -> None: + # WindowStaysOnTopHint KALDIRILD — arkaya geçebilsin + flags = Qt.FramelessWindowHint | Qt.Tool + self.setWindowFlags(flags) + self.setAttribute(Qt.WA_TranslucentBackground, True) + self.setAttribute(Qt.WA_NoSystemBackground, True) + screen = QApplication.primaryScreen().geometry() + self.setGeometry(screen) + self.hide() + + def _setup_add_button(self) -> None: + self._add_btn = AddButton(self) + self._reposition_add_button() + self._add_btn.clicked.connect(self._on_add_clicked) + + def _reposition_add_button(self) -> None: + x = self.width() - ADD_BUTTON_SIZE - ADD_BUTTON_MARGIN + y = self.height() - ADD_BUTTON_SIZE - ADD_BUTTON_MARGIN + self._add_btn.move(x, y) + + def _setup_app_state_watcher(self) -> None: + """Uygulama focus kaybettiğinde canvas'ı gizle.""" + QApplication.instance().applicationStateChanged.connect( + self._on_app_state_changed + ) + + def _on_app_state_changed(self, state) -> None: + from PySide6.QtCore import Qt as _Qt + if state == _Qt.ApplicationInactive and self._visible: + # Kısa gecikme — kendi dialog'larımızın açılmasını engelleme + QTimer.singleShot(100, self._check_still_inactive) + + def _check_still_inactive(self) -> None: + if QApplication.applicationState() != Qt.ApplicationActive and self._visible: + self.hide_canvas() + + # ── Toggle ────────────────────────────────────────────────────── + + def toggle_visibility(self) -> None: + if self._visible: + self.hide_canvas() + else: + self.show_canvas() + + def show_canvas(self) -> None: + if self._visible: + return + self._visible = True + self.show() + self.raise_() + self.activateWindow() + _set_click_through(int(self.winId()), False) + self._add_btn.raise_() + logger.debug("Canvas gosterildi.") + + def hide_canvas(self) -> None: + if not self._visible: + return + self._visible = False + self.hide() + logger.debug("Canvas gizlendi.") + + @property + def is_visible(self) -> bool: + return self._visible + + # ── Çizim ─────────────────────────────────────────────────────── + + def paintEvent(self, event) -> None: + painter = QPainter(self) + painter.setCompositionMode(QPainter.CompositionMode_Clear) + painter.fillRect(self.rect(), Qt.transparent) + painter.setCompositionMode(QPainter.CompositionMode_SourceOver) + painter.end() + + # ── Fare Olayları ──────────────────────────────────────────────── + + def mousePressEvent(self, event) -> None: + if event.button() == Qt.LeftButton: + self.hide_canvas() + elif event.button() == Qt.RightButton: + self._show_ctx(event.globalPosition().toPoint()) + event.accept() + + def keyPressEvent(self, event) -> None: + if event.key() == Qt.Key_Z and event.modifiers() & Qt.ControlModifier: + self.undo_requested.emit() + elif event.key() == Qt.Key_Escape: + self.hide_canvas() + else: + super().keyPressEvent(event) + + # ── Context Menü (boş alan) ────────────────────────────────────── + + def _show_ctx(self, global_pos: QPoint) -> None: + menu = QMenu(self) + menu.setStyleSheet(self._menu_style()) + add_a = menu.addAction("✚ Yeni Not Ekle") + menu.addSeparator() + show_all_a = menu.addAction("👁 Tum Notlari One Getir") + undo_a = menu.addAction("↩ Geri Al (Ctrl+Z)") + show_all_a.setEnabled(len(self._notes) > 0) + + action = menu.exec(global_pos) + if action == add_a: + local = self.mapFromGlobal(global_pos) + self.new_note_requested.emit(local.x(), local.y()) + elif action == show_all_a: + for w in self._notes.values(): w.raise_() + elif action == undo_a: + self.undo_requested.emit() + + # ── Not Ekleme / Silme ─────────────────────────────────────────── + + def add_note(self, note_data: Dict[str, Any]) -> NoteWidget: + widget = NoteWidget(note_data, colors_dict=self._colors_dict, parent=self) + nid = widget.note_id + + widget.delete_requested.connect(lambda i: self.note_deleted.emit(i)) + widget.content_changed.connect(self.note_content_changed) + widget.position_changed.connect(self.note_position_changed) + widget.size_changed.connect(self.note_size_changed) + widget.color_changed.connect(self.note_color_changed) + widget.font_size_changed.connect(self.note_font_size_changed) + widget.save_requested.connect(self.note_save_requested) + widget.new_note_requested.connect(self.note_new_requested) + widget.pin_toggled.connect(self._on_pin_toggled) + widget.raise_requested.connect(self._raise_note) + widget.focus_gained.connect(self._on_note_focus) + widget.settings_requested.connect(self.note_settings_requested.emit) + widget.tab_requested.connect(self._on_tab_navigate) + + self._notes[nid] = widget + widget.show() + widget.raise_() + self._add_btn.raise_() + self._update_add_btn() + # Eğer not başlangıçta pinned ise, onu top-level yapalım! + if note_data.get("pinned", False): + self._on_pin_toggled(nid, True) + return widget + + def set_colors_dict(self, colors: Dict[str, str]) -> None: + self._colors_dict = colors + for nid, w in self.iter_notes(): + w.set_colors_dict(colors) + + + def remove_note(self, note_id: str) -> None: + # Hem normal hem pinned dict'ten kontrol et + widget = self._notes.pop(note_id, None) or self._pinned_notes.pop(note_id, None) + if widget: + widget.hide() + widget.deleteLater() + self._update_add_btn() + + # ── Pin (always-on-top per note) ───────────────────────────────── + + def _on_pin_toggled(self, note_id: str, pinned: bool) -> None: + widget = self._notes.get(note_id) or self._pinned_notes.get(note_id) + if not widget: + return + + if pinned: + # Canvas child → top-level WindowStaysOnTopHint + global_pos = widget.mapToGlobal(QPoint(0, 0)) + self._notes.pop(note_id) + widget.setParent(None) + + pflags = Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint + if getattr(self, "_only_show_in_tray", True): + pflags |= Qt.Tool + else: + pflags |= Qt.Window + + widget.setWindowFlags(pflags) + widget.setAttribute(Qt.WA_StyledBackground, True) + widget.move(global_pos) + widget.show() + widget.raise_() + self._pinned_notes[note_id] = widget + logger.debug("Not pinlendi (top-level): %s", note_id) + else: + # Top-level → canvas child + if note_id in self._pinned_notes: + self._pinned_notes.pop(note_id) + global_pos = widget.pos() # zaten global (top-level iken) + widget.setParent(self) + widget.setWindowFlags(Qt.Widget) + widget.setAttribute(Qt.WA_StyledBackground, True) + local_pos = self.mapFromGlobal(global_pos) + widget.move(local_pos) + if self._visible: + widget.show() + self._notes[note_id] = widget + self._add_btn.raise_() + logger.debug("Not unpin edildi (canvas child): %s", note_id) + + self.note_pin_toggled.emit(note_id, pinned) + + # ── Kaskad Pozisyon ───────────────────────────────────────────── + + def get_next_cascade_position(self) -> QPoint: + if not self._notes and not self._pinned_notes: + self._last_cascade = QPoint(80, 80) + return self._last_cascade + pos = QPoint( + self._last_cascade.x() + NOTE_CASCADE_OFFSET, + self._last_cascade.y() + NOTE_CASCADE_OFFSET, + ) + screen = QApplication.primaryScreen().geometry() + if pos.x() + NOTE_DEFAULT_WIDTH > screen.width(): pos.setX(80) + if pos.y() + NOTE_DEFAULT_HEIGHT > screen.height(): pos.setY(80) + self._last_cascade = pos + return pos + + def clamp_notes_to_screen(self) -> None: + screen = QApplication.primaryScreen().geometry() + cx, cy = 20, 20 + for nid, w in list(self._notes.items()) + list(self._pinned_notes.items()): + x, y, ww, hh = w.x(), w.y(), w.width(), w.height() + if x < 0 or x + ww > screen.width() or y < 0 or y + hh > screen.height(): + w.move(cx, cy) + self.note_position_changed.emit(nid, cx, cy) + cx += NOTE_CASCADE_OFFSET; cy += NOTE_CASCADE_OFFSET + + # ── Slot'lar ──────────────────────────────────────────────────── + + def _on_add_clicked(self) -> None: + pos = self.get_next_cascade_position() + self.new_note_requested.emit(pos.x(), pos.y()) + + def _raise_note(self, note_id: str) -> None: + w = self._notes.get(note_id) or self._pinned_notes.get(note_id) + if w: + w.raise_() + if note_id in self._notes: + self._add_btn.raise_() + + def _on_note_focus(self, note_id: str) -> None: + for nid, w in {**self._notes, **self._pinned_notes}.items(): + w.set_active(nid == note_id) + + def _on_tab_navigate(self, note_id: str, shift: bool) -> None: + """Tab/Shift+Tab ile notlar arasi gezinme.""" + # Butun notlari sirali liste haline getir + all_notes = list(self._notes.items()) + list(self._pinned_notes.items()) + if len(all_notes) < 2: + return + # Su anki notun indeksini bul + current_idx = None + for i, (nid, _) in enumerate(all_notes): + if nid == note_id: + current_idx = i + break + if current_idx is None: + return + # Hedef indeks + if shift: + target_idx = (current_idx - 1) % len(all_notes) + else: + target_idx = (current_idx + 1) % len(all_notes) + target_id, target_widget = all_notes[target_idx] + target_widget.focus_text() + + def _update_add_btn(self) -> None: + total = len(self._notes) + len(self._pinned_notes) + # + butonu sadece hiç not yokken görünür + if total == 0: + self._add_btn.setVisible(True) + self._add_btn.set_enabled_state(True) + elif total >= NOTE_MAX_COUNT: + self._add_btn.setVisible(True) + self._add_btn.set_enabled_state(False) + else: + self._add_btn.setVisible(False) + + # ── Public API ─────────────────────────────────────────────────── + + @property + def note_count(self) -> int: + return len(self._notes) + len(self._pinned_notes) + + def get_note_widget(self, note_id: str) -> Optional[NoteWidget]: + return self._notes.get(note_id) or self._pinned_notes.get(note_id) + + def iter_notes(self): + return list({**self._notes, **self._pinned_notes}.items()) + + def focus_note(self, note_id: str) -> None: + w = self.get_note_widget(note_id) + if w: w.focus_text() + + def set_only_show_in_tray(self, enabled: bool) -> None: + """Sadece trayda göster veya görev çubuğunda göster ayarını dinamik uygular.""" + self._only_show_in_tray = enabled + flags = Qt.FramelessWindowHint + if enabled: + flags |= Qt.Tool + else: + flags |= Qt.Window + + self.setWindowFlags(flags) + if self._visible: + self.show() + + # Tüm pinned notların pencerelerini de güncelle + for nid, w in list(self._pinned_notes.items()): + pos = w.pos() + w.setParent(None) + pflags = Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint + if enabled: + pflags |= Qt.Tool + else: + pflags |= Qt.Window + w.setWindowFlags(pflags) + w.setAttribute(Qt.WA_StyledBackground, True) + w.move(pos) + w.show() + w.raise_() + + def _menu_style(self) -> str: + return """ + QMenu { + background:#1E1E2E; color:#CDD6F4; + border:1px solid #45475A; border-radius:10px; padding:6px; + font-family:'Segoe UI'; font-size:13px; + } + QMenu::item { padding:7px 22px; border-radius:6px; } + QMenu::item:selected { background:#313244; } + QMenu::item:disabled { color:#585B70; } + QMenu::separator { height:1px; background:#45475A; margin:4px 0; } + """ diff --git a/src/constants.py b/src/constants.py new file mode 100644 index 0000000..ba8a256 --- /dev/null +++ b/src/constants.py @@ -0,0 +1,154 @@ +""" +constants.py — Sticky Canvas uygulama sabitleri +""" + +import os + +# ─── Uygulama Bilgisi ─────────────────────────────────────────────── +APP_NAME = "Noteki" +APP_VERSION = "1.0" +LOCK_FILE_NAME = "noteki.lock" + +# ─── Dosya Yolları ────────────────────────────────────────────────── +APP_DATA_DIR = os.path.join(os.environ.get("APPDATA", os.path.expanduser("~")), "Noteki") +SESSION_FILE = os.path.join(APP_DATA_DIR, "session.json") +SESSION_BACKUP_FILE = os.path.join(APP_DATA_DIR, "session.backup.json") +SETTINGS_FILE = os.path.join(APP_DATA_DIR, "settings.json") +LOGS_DIR = os.path.join(APP_DATA_DIR, "logs") + +# ─── Not Boyutları ────────────────────────────────────────────────── +NOTE_DEFAULT_WIDTH = 220 +NOTE_DEFAULT_HEIGHT = 180 +NOTE_MIN_WIDTH = 150 +NOTE_MIN_HEIGHT = 100 +NOTE_MAX_WIDTH = 600 +NOTE_MAX_HEIGHT = 500 +NOTE_CASCADE_OFFSET = 20 # Yeni not konumu için kaskad kayma px +NOTE_MAX_COUNT = 50 # Maksimum not sayısı +NOTE_CHAR_LIMIT = 2000 # Maksimum karakter sayısı + +# ─── Renk Paleti ──────────────────────────────────────────────────── +NOTE_COLORS = { + "Sarı": "#FFF9C4", + "Yeşil": "#C8E6C9", + "Mavi": "#BBDEFB", + "Pembe": "#F8BBD0", + "Mor": "#E1BEE7", + "Turuncu": "#FFE0B2", +} +NOTE_COLOR_KEYS = list(NOTE_COLORS.keys()) +NOTE_DEFAULT_COLOR = "#FFF9C4" # Sarı + +# Her renk için metin ve accent rengi dinamik olarak hesaplanir: +# get_contrast_text_color() → okunabilir metin rengi +# get_dynamic_accent_color() → aktif not vurgu rengi + +# ─── Varsayılan Ayarlar ───────────────────────────────────────────── +DEFAULT_FONT_FAMILY = "Segoe UI" +DEFAULT_FONT_SIZE = 14 +DEFAULT_HOTKEY = "Alt+N" +DEFAULT_AUTO_START = False + +# ─── Undo ─────────────────────────────────────────────────────────── +UNDO_CAPACITY = 10 + +# ─── Auto-Save ────────────────────────────────────────────────────── +AUTOSAVE_DELAY_MS = 500 # Debounce süresi (ms) + +# ─── Sürükleme ────────────────────────────────────────────────────── +DRAG_OPACITY = 0.85 # Sürükleme sırasında opaklık + +# ─── "+" Butonu ───────────────────────────────────────────────────── +ADD_BUTTON_SIZE = 48 +ADD_BUTTON_MARGIN = 24 # Sağ alt köşeden uzaklık + +# ─── Windows Registry ─────────────────────────────────────────────── +REGISTRY_RUN_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run" +REGISTRY_APP_KEY = APP_NAME + +def get_contrast_text_color(hex_str: str) -> str: + """Arka plan rengine (hex_str) göre en yüksek kontrastlı metin rengini (#1A1A1A veya #FFFFFF) döner.""" + try: + hex_str = hex_str.lstrip('#') + r, g, b = int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16) + # Parlaklık (Luminance) formülü + l = 0.299 * r + 0.587 * g + 0.114 * b + return "#1A1A1A" if l > 130 else "#FFFFFF" + except Exception: + return "#1A1A1A" + +def get_dynamic_accent_color(hex_str: str) -> str: + """ + Arka plan rengine (hex_str) göre dinamik olarak bir accent (border/seçim) rengi hesaplar. + Açık renkler için rengi koyulaştırır/doygunlaştırır, koyu renkler için açar. + """ + try: + hex_str = hex_str.lstrip('#') + r, g, b = int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16) + + # RGB to HSV conversion + r_f, g_f, b_f = r / 255.0, g / 255.0, b / 255.0 + cmax = max(r_f, g_f, b_f) + cmin = min(r_f, g_f, b_f) + diff = cmax - cmin + + # Hue + if cmax == cmin: + h = 0 + elif cmax == r_f: + h = (60 * ((g_f - b_f) / diff) + 360) % 360 + elif cmax == g_f: + h = (60 * ((b_f - r_f) / diff) + 120) % 360 + else: + h = (60 * ((r_f - g_f) / diff) + 240) % 360 + + # Saturation + s = 0 if cmax == 0 else (diff / cmax) + + # Value + v = cmax + + # Parlaklık + l = 0.299 * r + 0.587 * g + 0.114 * b + + if l > 130: + # Açık renk: Rengi koyulaştır ve doygunluğu artır + v = max(0.0, v - 0.25) + s = min(1.0, s + 0.3) + else: + # Koyu renk: Rengi aç ve doygunluğu azalt + v = min(1.0, v + 0.25) + s = max(0.0, s - 0.2) + + # HSV to RGB conversion + c = v * s + x = c * (1 - abs((h / 60) % 2 - 1)) + m = v - c + + if 0 <= h < 60: + r_f, g_f, b_f = c, x, 0 + elif 60 <= h < 120: + r_f, g_f, b_f = x, c, 0 + elif 120 <= h < 180: + r_f, g_f, b_f = 0, c, x + elif 180 <= h < 240: + r_f, g_f, b_f = 0, x, c + elif 240 <= h < 300: + r_f, g_f, b_f = x, 0, c + else: + r_f, g_f, b_f = c, 0, x + + r = int((r_f + m) * 255) + g = int((g_f + m) * 255) + b = int((b_f + m) * 255) + + # Sınır kontrolü + r = max(0, min(255, r)) + g = max(0, min(255, g)) + b = max(0, min(255, b)) + + return f"#{r:02X}{g:02X}{b:02X}" + except Exception: + # Hata durumunda güvenli bir gri ton + return "#7F7F7F" + diff --git a/src/data_manager.py b/src/data_manager.py new file mode 100644 index 0000000..36bff39 --- /dev/null +++ b/src/data_manager.py @@ -0,0 +1,361 @@ +""" +data_manager.py — JSON tabanlı veri yönetimi (session + settings) +""" + +import json +import logging +import os +import re +import shutil +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple + +from PySide6.QtCore import QObject, QTimer, Signal + +from .constants import ( + APP_DATA_DIR, + APP_VERSION, + AUTOSAVE_DELAY_MS, + DEFAULT_AUTO_START, + DEFAULT_FONT_FAMILY, + DEFAULT_FONT_SIZE, + DEFAULT_HOTKEY, + LOGS_DIR, + NOTE_COLORS, + NOTE_DEFAULT_COLOR, + SESSION_BACKUP_FILE, + SESSION_FILE, + SETTINGS_FILE, +) + +DEFAULT_SESSION_NAME = "session" + +logger = logging.getLogger(__name__) + + +class DataManager(QObject): + """ + Tüm JSON okuma/yazma/yedekleme işlemlerini yönetir. + Asenkron auto-save için debounce timer kullanır. + """ + + save_completed = Signal() # Kayıt tamamlandığında yayınlanır + + def __init__(self, parent: Optional[QObject] = None) -> None: + super().__init__(parent) + self._pending_save = False + self._notes: List[Dict[str, Any]] = [] + self._settings: Dict[str, Any] = {} + self._current_session = DEFAULT_SESSION_NAME + self._current_session_file = SESSION_FILE + self._current_backup_file = SESSION_BACKUP_FILE + + # Auto-save debounce timer + self._save_timer = QTimer(self) + self._save_timer.setSingleShot(True) + self._save_timer.setInterval(AUTOSAVE_DELAY_MS) + self._save_timer.timeout.connect(self._flush_save) + + self._ensure_dirs() + self._load_settings() + self._load_session() + + # ─── Dizin Oluşturma ──────────────────────────────────────────── + + def _ensure_dirs(self) -> None: + """Gerekli dizinleri oluşturur.""" + os.makedirs(APP_DATA_DIR, exist_ok=True) + os.makedirs(LOGS_DIR, exist_ok=True) + logger.debug("Uygulama dizinleri hazır: %s", APP_DATA_DIR) + + # ─── Ayarlar ──────────────────────────────────────────────────── + + def _default_settings(self) -> Dict[str, Any]: + return { + "version": APP_VERSION, + "default_font_family": DEFAULT_FONT_FAMILY, + "default_font_size": DEFAULT_FONT_SIZE, + "default_note_color": NOTE_DEFAULT_COLOR, + "hotkey": DEFAULT_HOTKEY, + "auto_start": DEFAULT_AUTO_START, + "current_session": DEFAULT_SESSION_NAME, + "only_show_in_tray": True, + "note_colors": NOTE_COLORS, + } + + def _load_settings(self) -> None: + """settings.json dosyasını yükler; yoksa varsayılanları kullanır.""" + if not os.path.exists(SETTINGS_FILE): + self._settings = self._default_settings() + self._write_json(SETTINGS_FILE, self._settings) + logger.info("Varsayılan ayarlar oluşturuldu.") + return + + data = self._read_json(SETTINGS_FILE) + if data is None: + self._settings = self._default_settings() + logger.warning("settings.json okunamadı, varsayılanlar kullanılıyor.") + else: + # Eksik anahtarları varsayılanlarla doldur + defaults = self._default_settings() + for key, val in defaults.items(): + data.setdefault(key, val) + self._settings = data + + def get_settings(self) -> Dict[str, Any]: + return dict(self._settings) + + def save_settings(self, settings: Dict[str, Any]) -> bool: + """Ayarları kaydeder.""" + self._settings.update(settings) + self._settings["version"] = APP_VERSION + ok = self._write_json(SETTINGS_FILE, self._settings) + if ok: + logger.info("Ayarlar kaydedildi.") + return ok + + # ─── Oturum / Notlar ──────────────────────────────────────────── + + def _default_session(self) -> Dict[str, Any]: + return {"version": APP_VERSION, "notes": []} + + def _load_session(self, session_file: Optional[str] = None, + backup_file: Optional[str] = None) -> None: + """Belirtilen session dosyasini yukler; bozuksa backup'tan kurtar.""" + sf = session_file or self._current_session_file + bf = backup_file or self._current_backup_file + data = self._read_json(sf) + + if data is not None and isinstance(data.get("notes"), list): + self._notes = data["notes"] + logger.info("%d not yuklendi (%s).", len(self._notes), sf) + return + + logger.warning("%s okunamadi, yedekten kurtariliyor...", sf) + backup = self._read_json(bf) + if backup is not None and isinstance(backup.get("notes"), list): + self._notes = backup["notes"] + logger.info("Yedekten %d not kurtarildi.", len(self._notes)) + self._write_json(sf, {"version": APP_VERSION, "notes": self._notes}) + else: + logger.warning("Yedek de okunamadi. Bos oturumla baslaniyor.") + self._notes = [] + + def get_notes(self) -> List[Dict[str, Any]]: + """Tüm notların kopyasını döndürür.""" + return list(self._notes) + + def create_note(self, x: int, y: int, width: int, height: int, + color: str, font_family: str, font_size: int) -> Dict[str, Any]: + """Yeni bir not verisi olusturur ve listeye ekler.""" + now = datetime.now(timezone.utc).isoformat() + note = { + "id": str(uuid.uuid4()), + "content": "", + "x": x, "y": y, + "width": width, "height": height, + "color": color, + "font_family": font_family, + "font_size": font_size, + "pinned": False, + "z_index": self._next_z_index(), + "created_at": now, + "updated_at": now, + } + self._notes.append(note) + self.schedule_save() + return note + + def update_note(self, note_id: str, **kwargs) -> bool: + """Belirtilen alanları günceller.""" + for note in self._notes: + if note["id"] == note_id: + now = datetime.now(timezone.utc).isoformat() + note.update(kwargs) + note["updated_at"] = now + self.schedule_save() + return True + return False + + def delete_note(self, note_id: str) -> Optional[Dict[str, Any]]: + """Notu siler ve silinen not verisini döndürür (undo için).""" + for i, note in enumerate(self._notes): + if note["id"] == note_id: + removed = self._notes.pop(i) + self.schedule_save() + logger.debug("Not silindi: %s", note_id) + return removed + return None + + def restore_note(self, note_data: Dict[str, Any]) -> None: + """Silinen bir notu geri yükler.""" + note_data["z_index"] = self._next_z_index() + self._notes.append(note_data) + self.schedule_save() + logger.debug("Not geri yüklendi: %s", note_data["id"]) + + def clear_all_notes(self) -> List[Dict[str, Any]]: + """Tüm notları siler ve silinen verileri döndürür.""" + removed = list(self._notes) + self._notes.clear() + self.schedule_save() + return removed + + def _next_z_index(self) -> int: + if not self._notes: + return 1 + return max(n.get("z_index", 0) for n in self._notes) + 1 + + # ─── Auto-Save (Debounce) ──────────────────────────────────────── + + def schedule_save(self) -> None: + """Debounce timer'ı başlatır / sıfırlar.""" + self._save_timer.start() + + def _flush_save(self) -> None: + """Asil kayit islemini gerceklestirir.""" + payload = {"version": APP_VERSION, "notes": self._notes} + sf = self._current_session_file + bf = self._current_backup_file + if os.path.exists(sf): + try: + shutil.copy2(sf, bf) + except OSError as exc: + logger.warning("Yedek olusturulamadi: %s", exc) + if self._write_json(sf, payload): + logger.debug("Session kaydedildi (%d not) → %s", len(self._notes), sf) + self.save_completed.emit() + + def force_save(self) -> None: + """Timer'i beklemeden aninda kaydeder.""" + self._save_timer.stop() + self._flush_save() + + # ─── Session Yonetimi ──────────────────────────────────────────── + + def list_sessions(self) -> List[str]: + """APP_DATA_DIR icerisindeki session dosyalarini listeler.""" + sessions = [] + for fname in os.listdir(APP_DATA_DIR): + if (fname.endswith(".json") + and "backup" not in fname + and fname != os.path.basename(SETTINGS_FILE)): + sessions.append(fname[:-5]) # .json uzantisini kaldir + return sorted(sessions) + + def current_session_name(self) -> str: + return self._current_session + + def create_session(self, name: str) -> Tuple[bool, str]: + """ + Yeni bos bir session olusturur. + Returns: (success, error_message) + """ + safe = re.sub(r"[^\w\-]", "_", name.strip()) + if not safe: + return False, "Gecersiz oturum adi." + sf = os.path.join(APP_DATA_DIR, f"{safe}.json") + if os.path.exists(sf): + return False, f"'{safe}' adli oturum zaten var." + self._write_json(sf, {"version": APP_VERSION, "notes": []}) + logger.info("Yeni session olusturuldu: %s", sf) + return True, safe + + def switch_session(self, session_name: str) -> Tuple[bool, List[Dict]]: + """ + Mevcut sessionu kaydedip yeni sessionu yukler. + Returns: (success, notes_list) + """ + sf = os.path.join(APP_DATA_DIR, f"{session_name}.json") + bf = os.path.join(APP_DATA_DIR, f"{session_name}.backup.json") + if not os.path.exists(sf): + return False, [] + # Mevcut sessionu kaydet + self.force_save() + # Yeni session + self._current_session = session_name + self._current_session_file = sf + self._current_backup_file = bf + self._load_session(sf, bf) + # Settings'e kaydet + self._settings["current_session"] = session_name + self.save_settings(self._settings) + logger.info("Session degistirildi: %s (%d not)", session_name, len(self._notes)) + return True, list(self._notes) + + def delete_session(self, session_name: str) -> bool: + """Bir session dosyasini siler (aktif session silinemez).""" + if session_name == self._current_session: + return False + sf = os.path.join(APP_DATA_DIR, f"{session_name}.json") + bf = os.path.join(APP_DATA_DIR, f"{session_name}.backup.json") + try: + if os.path.exists(sf): os.remove(sf) + if os.path.exists(bf): os.remove(bf) + logger.info("Session silindi: %s", session_name) + return True + except OSError as exc: + logger.error("Session silinemedi: %s", exc) + return False + + def copy_session(self, source_name: str, target_name: str) -> Tuple[bool, str]: + """ + Mevcut bir session dosyasini yeni bir adla kopyalar/klonlar. + Returns: (success, error_message/new_session_name) + """ + safe_source = re.sub(r"[^\w\-]", "_", source_name.strip()) + safe_target = re.sub(r"[^\w\-]", "_", target_name.strip()) + if not safe_target: + return False, "Gecersiz yeni oturum adi." + + source_file = os.path.join(APP_DATA_DIR, f"{safe_source}.json") + target_file = os.path.join(APP_DATA_DIR, f"{safe_target}.json") + target_backup = os.path.join(APP_DATA_DIR, f"{safe_target}.backup.json") + + if not os.path.exists(source_file): + return False, f"Kaynak oturum bulunamadi: '{source_name}'" + + if os.path.exists(target_file): + return False, f"'{safe_target}' adli oturum zaten var." + + try: + # Force save current session if it matches the source + if source_name == self._current_session: + self.force_save() + + shutil.copy2(source_file, target_file) + # Create a backup as well + source_backup = os.path.join(APP_DATA_DIR, f"{safe_source}.backup.json") + if os.path.exists(source_backup): + shutil.copy2(source_backup, target_backup) + else: + shutil.copy2(target_file, target_backup) + + logger.info("Oturum kopyalandi: %s -> %s", source_file, target_file) + return True, safe_target + except OSError as exc: + logger.error("Oturum kopyalanamadi: %s", exc) + return False, f"Kopyalama sirasinda hata olustu: {exc}" + + + # ─── Yardımcı Metodlar ────────────────────────────────────────── + + def _read_json(self, path: str) -> Optional[Dict]: + if not os.path.exists(path): + return None + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError) as exc: + logger.error("JSON okunamadı (%s): %s", path, exc) + return None + + def _write_json(self, path: str, data: Dict) -> bool: + try: + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + return True + except OSError as exc: + logger.error("JSON yazılamadı (%s): %s", path, exc) + return False diff --git a/src/hotkey_manager.py b/src/hotkey_manager.py new file mode 100644 index 0000000..86a6063 --- /dev/null +++ b/src/hotkey_manager.py @@ -0,0 +1,168 @@ +""" +hotkey_manager.py — Windows global kısayol tuşu yönetimi (ctypes + QThread) +""" + +import ctypes +import ctypes.wintypes +import logging +from typing import Optional + +from PySide6.QtCore import QObject, QThread, Signal + +logger = logging.getLogger(__name__) + +# Windows API +MOD_ALT = 0x0001 +MOD_CONTROL = 0x0002 +MOD_SHIFT = 0x0004 +MOD_WIN = 0x0008 +HOTKEY_ID = 0x1337 + + +def _parse_hotkey(hotkey_str: str): + """ + "Alt+N" gibi bir string'i (modifiers, vk_code) tuple'ına çevirir. + Desteklenen modifier'lar: Alt, Ctrl, Shift, Win + """ + parts = [p.strip() for p in hotkey_str.split("+")] + modifiers = 0 + key_char = None + + for part in parts: + upper = part.upper() + if upper == "ALT": + modifiers |= MOD_ALT + elif upper in ("CTRL", "CONTROL"): + modifiers |= MOD_CONTROL + elif upper == "SHIFT": + modifiers |= MOD_SHIFT + elif upper in ("WIN", "META"): + modifiers |= MOD_WIN + else: + key_char = part + + if key_char is None: + return modifiers, 0 + + # Tek karakter → VK kodu + if len(key_char) == 1: + vk_code = ctypes.windll.user32.VkKeyScanW(ord(key_char)) & 0xFF + else: + # Özel tuşlar (F1-F12 vb.) — basit destek + special = { + "F1": 0x70, "F2": 0x71, "F3": 0x72, "F4": 0x73, + "F5": 0x74, "F6": 0x75, "F7": 0x76, "F8": 0x77, + "F9": 0x78, "F10": 0x79, "F11": 0x7A, "F12": 0x7B, + "SPACE": 0x20, "RETURN": 0x0D, "ESCAPE": 0x1B, + } + vk_code = special.get(key_char.upper(), 0) + + return modifiers, vk_code + + +class HotkeyListener(QThread): + """ + Ayrı bir thread'de Windows mesaj döngüsünü dinler. + Kısayol tespit edildiğinde sinyal yayınlar. + """ + hotkey_triggered = Signal() + registration_result = Signal(bool) + + def __init__(self, modifiers: int, vk_code: int, parent: Optional[QObject] = None) -> None: + super().__init__(parent) + self._running = False + self._modifiers = modifiers + self._vk_code = vk_code + + def run(self) -> None: + self._running = True + + # Windows API'ye göre, RegisterHotKey çağrılmadan önce thread'in bir mesaj kuyruğuna (message queue) sahip olması gerekir. + # Boş bir PeekMessageW çağrısı yaparak Windows'un bu thread için bir mesaj kuyruğu oluşturmasını zorluyoruz. + msg = ctypes.wintypes.MSG() + ctypes.windll.user32.PeekMessageW(ctypes.byref(msg), None, 0, 0, 0) + + # Kısayol kaydını dinleyici thread'in kendi bağlamında yapıyoruz, + # böylece thread'e gönderilen WM_HOTKEY mesajlarını PeekMessageW ile yakalayabiliriz. + result = ctypes.windll.user32.RegisterHotKey(None, HOTKEY_ID, self._modifiers, self._vk_code) + self.registration_result.emit(bool(result)) + + if not result: + self._running = False + return + + msg = ctypes.wintypes.MSG() + while self._running: + result = ctypes.windll.user32.PeekMessageW( + ctypes.byref(msg), None, 0x0312, 0x0312, 1 # WM_HOTKEY, PM_REMOVE + ) + if result and msg.message == 0x0312: + if msg.wParam == HOTKEY_ID: + self.hotkey_triggered.emit() + self.msleep(30) # CPU'yu yormamak için 30ms bekle + + ctypes.windll.user32.UnregisterHotKey(None, HOTKEY_ID) + + def stop(self) -> None: + self._running = False + self.quit() + self.wait(1000) + + +class HotkeyManager(QObject): + """ + Global kısayol tuşunu kaydeder ve dinler. + Kısayol değiştirilebilir; hata durumunda sinyal yayınlar. + """ + + hotkey_triggered = Signal() + registration_failed = Signal(str) # hata mesajı + + def __init__(self, hotkey_str: str = "Alt+N", parent: Optional[QObject] = None) -> None: + super().__init__(parent) + self._hotkey_str = hotkey_str + self._listener: Optional[HotkeyListener] = None + + self._register(hotkey_str) + + def _register(self, hotkey_str: str) -> bool: + """Kısayolu Windows'a kaydeder.""" + self._unregister() + modifiers, vk_code = _parse_hotkey(hotkey_str) + if vk_code == 0: + msg = f"Geçersiz kısayol tuşu: {hotkey_str}" + logger.error(msg) + self.registration_failed.emit(msg) + return False + + self._hotkey_str = hotkey_str + + # Dinleyici thread'i başlat ve kaydı thread içinde yap + self._listener = HotkeyListener(modifiers, vk_code, self) + self._listener.hotkey_triggered.connect(self.hotkey_triggered) + self._listener.registration_result.connect(self._on_registration_result) + self._listener.start() + return True + + def _on_registration_result(self, success: bool) -> None: + if not success: + msg = f"Kısayol kaydedilemedi: {self._hotkey_str} (başka bir uygulama kullanıyor olabilir)" + logger.warning(msg) + self.registration_failed.emit(msg) + else: + logger.info("Global kısayol kaydedildi: %s", self._hotkey_str) + + def _unregister(self) -> None: + if self._listener: + if self._listener.isRunning(): + self._listener.stop() + self._listener = None + logger.debug("Global kısayol kaydı silindi.") + + def change_hotkey(self, new_hotkey: str) -> bool: + """Kısayolu değiştirir. Başarılı olursa True döner.""" + return self._register(new_hotkey) + + def cleanup(self) -> None: + """Uygulama kapanırken çağrılır.""" + self._unregister() diff --git a/src/note_widget.py b/src/note_widget.py new file mode 100644 index 0000000..dada7fa --- /dev/null +++ b/src/note_widget.py @@ -0,0 +1,647 @@ +""" +note_widget.py — Yapışkan not kutusu +Header drag bar + icon bar + metin alanı + custom resize +""" + +import logging +from typing import Any, Dict, Optional + +from PySide6.QtCore import QEvent, QPoint, QSize, Qt, Signal +from PySide6.QtGui import QColor, QFont, QTextCursor +from PySide6.QtWidgets import ( + QApplication, + QFrame, + QGraphicsDropShadowEffect, + QHBoxLayout, + QLabel, + QMenu, + QPlainTextEdit, + QPushButton, + QToolTip, + QVBoxLayout, + QWidget, +) + +from .constants import ( + NOTE_CHAR_LIMIT, + NOTE_COLORS, + NOTE_MAX_HEIGHT, + NOTE_MAX_WIDTH, + NOTE_MIN_HEIGHT, + NOTE_MIN_WIDTH, + get_contrast_text_color, + get_dynamic_accent_color, +) + +logger = logging.getLogger(__name__) + +HEADER_H = 18 # Header yüksekliği (px) +RESIZE_ZONE = 18 # Sağ alt köşe resize bölgesi (px) +FONT_SIZE_MIN = 8 +FONT_SIZE_MAX = 32 + + +def _darken(hex_color: str, amount: int = 30) -> str: + c = QColor(hex_color) + h, s, v, a = c.getHsv() + c.setHsv(h, s, max(0, v - amount), a) + return c.name() + + +# ──────────────────────────────────────────────────────────────────── +# Color Picker Popup +# ──────────────────────────────────────────────────────────────────── + +class ColorPickerPopup(QFrame): + """Küçük renk seçici popup penceresi.""" + + color_selected = Signal(str) + + def __init__(self, colors: dict, current: str, parent=None) -> None: + super().__init__(parent, Qt.Popup | Qt.FramelessWindowHint) + self.setAttribute(Qt.WA_TranslucentBackground) + + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(8) + + for name, hc in colors.items(): + selected = hc == current + btn = QPushButton(self) + btn.setFixedSize(28, 28) + btn.setToolTip(name) + btn.setCursor(Qt.PointingHandCursor) + btn.setStyleSheet(f""" + QPushButton {{ + background: {hc}; + border: {'3px solid #2196F3' if selected else '2px solid rgba(0,0,0,0.25)'}; + border-radius: 14px; + }} + QPushButton:hover {{ + border: 3px solid #42A5F5; + }} + """) + btn.clicked.connect(lambda _, c=hc: self._pick(c)) + layout.addWidget(btn) + + self.setStyleSheet(""" + ColorPickerPopup { + background: #1E1E2E; + border: 1px solid #45475A; + border-radius: 12px; + } + """) + + def _pick(self, color: str) -> None: + self.color_selected.emit(color) + self.close() + + +# ──────────────────────────────────────────────────────────────────── +# Text Edit +# ──────────────────────────────────────────────────────────────────── + +class NoteTextEdit(QPlainTextEdit): + """Karakter limiti destekli metin alanı.""" + + tab_pressed = Signal(bool) # True = Shift+Tab (geri), False = Tab (ileri) + + def __init__(self, limit: int, parent=None) -> None: + super().__init__(parent) + self._limit = limit + self.setFrameShape(QFrame.NoFrame) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.textChanged.connect(self._enforce) + + def keyPressEvent(self, event) -> None: + if event.key() == Qt.Key_Tab or ( + event.key() == Qt.Key_Backtab + ): + self.tab_pressed.emit(event.key() == Qt.Key_Backtab) + event.accept() + return + super().keyPressEvent(event) + + def _enforce(self) -> None: + text = self.toPlainText() + if len(text) > self._limit: + cursor = self.textCursor() + pos = cursor.position() + self.blockSignals(True) + self.setPlainText(text[: self._limit]) + cursor.setPosition(min(pos, self._limit)) + self.setTextCursor(cursor) + self.blockSignals(False) + QToolTip.showText( + self.mapToGlobal(QPoint(0, 0)), + f"Maksimum {self._limit} karakter girilebilir.", + self, + ) + + def insertFromMimeData(self, source) -> None: + text = source.text() + current = self.toPlainText() + available = self._limit - len(current) + if available <= 0: + return + if len(text) > available: + text = text[:available] + QToolTip.showText( + self.mapToGlobal(QPoint(0, 0)), + f"Metin {self._limit} karaktere kisaltildi.", + self, + ) + self.textCursor().insertText(text) + + +# ──────────────────────────────────────────────────────────────────── +# Icon Button +# ──────────────────────────────────────────────────────────────────── + +class IconBtn(QPushButton): + """Header/Bottom bar için küçük ikon butonu.""" + + def __init__(self, text: str, tip: str, parent=None) -> None: + super().__init__(text, parent) + self.setFixedSize(28, 28) + self.setCursor(Qt.PointingHandCursor) + self.setToolTip(tip) + self.setObjectName("iconBtn") + + def apply_style(self, text_color: str, accent: str, active: bool = False) -> None: + if active: + self.setStyleSheet(f""" + QPushButton#iconBtn {{ + background: rgba(0, 0, 0, 0.15); + color: #000000; + border: 1.5px solid #000000; + border-radius: 6px; + font-size: 13px; + font-weight: bold; + }} + QPushButton#iconBtn:hover {{ + background: rgba(0, 0, 0, 0.25); + }} + """) + else: + self.setStyleSheet(f""" + QPushButton#iconBtn {{ + background: transparent; + color: #000000; + border: none; + border-radius: 6px; + font-size: 13px; + }} + QPushButton#iconBtn:hover {{ + background: rgba(0, 0, 0, 0.1); + color: #000000; + }} + """) + + +# ──────────────────────────────────────────────────────────────────── +# NoteWidget +# ──────────────────────────────────────────────────────────────────── + +class NoteWidget(QWidget): + """Yapışkan not kutusu — header + icon bar + metin alanı + custom resize.""" + + # Sinyaller + delete_requested = Signal(str) # note_id + content_changed = Signal(str, str) # note_id, content + position_changed = Signal(str, int, int) # note_id, x, y + size_changed = Signal(str, int, int) # note_id, w, h + color_changed = Signal(str, str) # note_id, color + font_size_changed = Signal(str, int) # note_id, new_size + raise_requested = Signal(str) # note_id + focus_gained = Signal(str) # note_id + save_requested = Signal(str) # note_id + new_note_requested = Signal(str) # note_id (create near me) + pin_toggled = Signal(str, bool) # note_id, pinned + settings_requested = Signal() # settings dialog request + tab_requested = Signal(str, bool) # note_id, shift_pressed + + def __init__(self, note_data: Dict[str, Any], colors_dict: Optional[Dict[str, str]] = None, parent=None) -> None: + super().__init__(parent) + self._id = note_data["id"] + self._color = note_data.get("color", "#FFF9C4") + self._font_size = note_data.get("font_size", 14) + self._font_family = note_data.get("font_family", "Segoe UI") + self._pinned = note_data.get("pinned", False) + self._colors_dict = colors_dict or NOTE_COLORS + + # Resize state + self._resizing = False + self._resize_start_pos = QPoint() + self._resize_start_w = 0 + self._resize_start_h = 0 + + # Drag state + self._dragging = False + self._drag_offset = QPoint() + + self.setMinimumSize(NOTE_MIN_WIDTH, NOTE_MIN_HEIGHT) + self.setMaximumSize(NOTE_MAX_WIDTH, NOTE_MAX_HEIGHT) + self.resize(note_data.get("width", 220), note_data.get("height", 180)) + self.move(note_data.get("x", 100), note_data.get("y", 100)) + self.setWindowTitle("Noteki") + + self.setAttribute(Qt.WA_StyledBackground, True) + self.setMouseTracking(True) + + self._build_ui() + self._apply_color(self._color) + self._apply_font(self._font_family, self._font_size) + self._setup_shadow() + + # İçerik yükle + content = note_data.get("content", "") + if content: + self._text_edit.blockSignals(True) + self._text_edit.setPlainText(content) + self._text_edit.blockSignals(False) + + # Pinned state + if self._pinned: + self._pin_btn.apply_style("", "", active=True) + + # ── UI Kurulumu ───────────────────────────────────────────────── + + def _build_ui(self) -> None: + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # ── Header (drag only, thinned) ─────────────────────────── + self._header = QWidget() + self._header.setParent(self) + self._header.setFixedHeight(HEADER_H) + self._header.setCursor(Qt.SizeAllCursor) + self._header.setObjectName("noteHeader") + self._header.setMouseTracking(True) + self._header.installEventFilter(self) + + hbox = QHBoxLayout(self._header) + hbox.setContentsMargins(0, 0, 0, 0) + hbox.setSpacing(0) + + hbox.addStretch() + self._drag_lbl = QLabel("✥") # Centered move/drag icon + self._drag_lbl.setObjectName("dragLbl") + self._drag_lbl.setAlignment(Qt.AlignCenter) + hbox.addWidget(self._drag_lbl) + hbox.addStretch() + + # ── Icon butonları (Bottom bar için hazırlanıyor) ─────────── + self._pin_btn = IconBtn("📌", "Her Zaman Üstte (toggle)") + self._new_btn = IconBtn("+", "Yeni Not Ekle") + self._color_btn = IconBtn("🎨", "Renk Degistir") + self._font_dn = IconBtn("A-", "Yazi Boyutunu Kucult") + self._font_up = IconBtn("A+", "Yazi Boyutunu Buyut") + self._settings_btn = IconBtn("⚙️", "Ayarlar") + self._save_btn = IconBtn("✓", "Kaydet") + self._del_btn = IconBtn("✕", "Notu Sil") + + # Bağlantılar + self._pin_btn.clicked.connect(self._on_pin_toggle) + self._new_btn.clicked.connect(lambda: self.new_note_requested.emit(self._id)) + self._color_btn.clicked.connect(self._on_color_btn) + self._font_dn.clicked.connect(lambda: self._change_font_size(-1)) + self._font_up.clicked.connect(lambda: self._change_font_size(+1)) + self._settings_btn.clicked.connect(lambda: self.settings_requested.emit()) + self._save_btn.clicked.connect(lambda: self.save_requested.emit(self._id)) + self._del_btn.clicked.connect(lambda: self.delete_requested.emit(self._id)) + + root.addWidget(self._header) + + # ── Metin alanı ─────────────────────────────────────────── + self._text_edit = NoteTextEdit(NOTE_CHAR_LIMIT, self) + self._text_edit.setObjectName("noteText") + self._text_edit.textChanged.connect( + lambda: self.content_changed.emit(self._id, self._text_edit.toPlainText()) + ) + self._text_edit.tab_pressed.connect( + lambda shift: self.tab_requested.emit(self._id, shift) + ) + self._text_edit.setContextMenuPolicy(Qt.CustomContextMenu) + self._text_edit.customContextMenuRequested.connect( + lambda p: self._show_ctx(self._text_edit.mapToGlobal(p)) + ) + self._text_edit.installEventFilter(self) + root.addWidget(self._text_edit, stretch=1) + + # ── Bottom Bar (left-aligned icons) ─────────────────────── + self._bottom_bar = QWidget() + self._bottom_bar.setObjectName("noteBottomBar") + self._bottom_bar.setFixedHeight(34) + self._bottom_bar.setMouseTracking(True) + + bot_layout = QHBoxLayout(self._bottom_bar) + # Sağ kenar boşluğu 18px yapılarak sağ alttaki 18x18 resize grip ile çakışma önlenir + bot_layout.setContentsMargins(8, 0, 18, 0) + bot_layout.setSpacing(6) + + # Sola yaslı butonlar (Silme en solda) + bot_layout.addWidget(self._del_btn) + bot_layout.addWidget(self._pin_btn) + bot_layout.addWidget(self._color_btn) + bot_layout.addWidget(self._font_dn) + bot_layout.addWidget(self._font_up) + bot_layout.addWidget(self._settings_btn) + bot_layout.addWidget(self._save_btn) + + # Araya esnek boşluk + bot_layout.addStretch() + + # En sağda yeni not (+) butonu + bot_layout.addWidget(self._new_btn) + + root.addWidget(self._bottom_bar) + + # Resize göstergesi (köşe üçgeni — sadece görsel) + self._resize_corner = QLabel("◢", self) + self._resize_corner.setFixedSize(RESIZE_ZONE, RESIZE_ZONE) + self._resize_corner.setAlignment(Qt.AlignRight | Qt.AlignBottom) + self._resize_corner.setCursor(Qt.SizeFDiagCursor) + self._resize_corner.setObjectName("resizeCorner") + self._resize_corner.installEventFilter(self) + + def _setup_shadow(self) -> None: + shadow = QGraphicsDropShadowEffect(self) + shadow.setBlurRadius(20) + shadow.setOffset(0, 4) + shadow.setColor(QColor(0, 0, 0, 110)) + self.setGraphicsEffect(shadow) + + # ── Renk & Font ───────────────────────────────────────────────── + + def _apply_color(self, color: str) -> None: + self._color = color + tc = get_contrast_text_color(color) + accent = get_dynamic_accent_color(color) + dark = _darken(color, 12) + + self.setStyleSheet(f""" + NoteWidget {{ + background-color: {color}; + border-radius: 12px; + border: 1.5px solid {dark}; + }} + """) + + self._header.setStyleSheet(f""" + QWidget#noteHeader {{ + background-color: {dark}33; + border-top-left-radius: 12px; + border-top-right-radius: 12px; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + }} + QLabel#dragLbl {{ + color: {tc}88; + font-size: 13px; + font-weight: bold; + background: transparent; + }} + """) + + self._text_edit.setStyleSheet(f""" + QPlainTextEdit#noteText {{ + background-color: {color}; + border: none; + color: {tc}; + padding: 6px 8px; + selection-background-color: {accent}50; + }} + QScrollBar:vertical {{ + width: 5px; background: transparent; + }} + QScrollBar::handle:vertical {{ + background: {tc}40; border-radius: 2px; min-height: 14px; + }} + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ + height: 0; + }} + """) + + self._bottom_bar.setStyleSheet(f""" + QWidget#noteBottomBar {{ + background-color: {dark}22; + border-bottom-left-radius: 12px; + border-bottom-right-radius: 12px; + border-top: 1px solid rgba(0, 0, 0, 0.05); + }} + """) + + self._resize_corner.setStyleSheet(f""" + QLabel#resizeCorner {{ + color: {tc}44; + background: transparent; + font-size: 12px; + padding: 0; + }} + """) + + # Icon buton stillerini güncelle + for btn in (self._pin_btn, self._new_btn, self._color_btn, self._font_dn, + self._font_up, self._settings_btn, self._save_btn, self._del_btn): + btn.apply_style(tc, accent) + + # Pin buton durumuna göre + self._pin_btn.apply_style(tc, accent, active=self._pinned) + + def _apply_font(self, family: str, size: int) -> None: + self._font_family = family + self._font_size = size + self._text_edit.setFont(QFont(family, size)) + + # ── Pozisyon & Boyut ───────────────────────────────────────────── + + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + # Resize köşesini sağ alta yerleştir + self._resize_corner.move(self.width() - RESIZE_ZONE, self.height() - RESIZE_ZONE) + + # ── Event Filter (header drag + text focus + resize corner) ────── + + def eventFilter(self, obj, event) -> bool: + # ── Header mouse events ── + if obj is self._header: + if event.type() == QEvent.MouseButtonPress: + if event.button() == Qt.LeftButton: + self._dragging = True + self._drag_offset = event.globalPosition().toPoint() - self.pos() + self.raise_requested.emit(self._id) + self.setWindowOpacity(0.88) + return True + elif event.button() == Qt.RightButton: + self._show_ctx(event.globalPosition().toPoint()) + return True + elif event.type() == QEvent.MouseMove: + if self._dragging and event.buttons() & Qt.LeftButton: + new_pos = event.globalPosition().toPoint() - self._drag_offset + if self.parent(): + pr = self.parent().rect() + new_pos.setX(max(0, min(new_pos.x(), pr.width() - self.width()))) + new_pos.setY(max(0, min(new_pos.y(), pr.height() - self.height()))) + self.move(new_pos) + return True + elif event.type() == QEvent.MouseButtonRelease: + if self._dragging: + self._dragging = False + self.setWindowOpacity(1.0) + self.position_changed.emit(self._id, self.x(), self.y()) + return True + elif event.type() == QEvent.MouseButtonDblClick: + self._text_edit.setFocus() + self._text_edit.moveCursor(QTextCursor.End) + return True + + # ── Resize corner events ── + elif obj is getattr(self, '_resize_corner', None): + if event.type() == QEvent.MouseButtonPress: + if event.button() == Qt.LeftButton: + self._resizing = True + self._resize_start_pos = event.globalPosition().toPoint() + self._resize_start_w = self.width() + self._resize_start_h = self.height() + self.raise_requested.emit(self._id) + return True + elif event.type() == QEvent.MouseMove: + if self._resizing and event.buttons() & Qt.LeftButton: + delta = event.globalPosition().toPoint() - self._resize_start_pos + new_w = max(NOTE_MIN_WIDTH, min(NOTE_MAX_WIDTH, self._resize_start_w + int(delta.x()))) + new_h = max(NOTE_MIN_HEIGHT, min(NOTE_MAX_HEIGHT, self._resize_start_h + int(delta.y()))) + self.resize(new_w, new_h) + return True + elif event.type() == QEvent.MouseButtonRelease: + if self._resizing: + self._resizing = False + self.size_changed.emit(self._id, self.width(), self.height()) + return True + + # ── Text edit focus events ── + elif obj is getattr(self, '_text_edit', None): + if event.type() == QEvent.FocusIn: + self.raise_requested.emit(self._id) + self.focus_gained.emit(self._id) + elif event.type() == QEvent.FocusOut: + self.save_requested.emit(self._id) + + return super().eventFilter(obj, event) + + # ── NoteWidget mouse events ───────────────────────────────────── + + def mousePressEvent(self, event) -> None: + self.raise_requested.emit(self._id) + event.accept() + + # ── Context Menü ──────────────────────────────────────────────── + + def _show_ctx(self, global_pos: QPoint) -> None: + menu = QMenu(self) + menu.setStyleSheet(""" + QMenu { + background:#1E1E2E; color:#CDD6F4; + border:1px solid #45475A; border-radius:9px; padding:5px; + font-family:'Segoe UI'; font-size:13px; min-width:160px; + } + QMenu::item { padding:6px 18px; border-radius:5px; } + QMenu::item:selected { background:#313244; } + QMenu::separator { height:1px; background:#45475A; margin:3px 0; } + """) + color_menu = menu.addMenu("🎨 Renk Degistir") + for name, hc in self._colors_dict.items(): + a = color_menu.addAction(name) + a.setData(hc) + if hc == self._color: + a.setCheckable(True); a.setChecked(True) + menu.addSeparator() + copy_a = menu.addAction("📋 Kopyala") + paste_a = menu.addAction("📌 Yapistir") + menu.addSeparator() + del_a = menu.addAction("🗑️ Notu Sil") + + chosen = menu.exec(global_pos) + if not chosen: return + if chosen == copy_a: + QApplication.clipboard().setText(self._text_edit.toPlainText()) + elif chosen == paste_a: + self._text_edit.paste() + elif chosen == del_a: + self.delete_requested.emit(self._id) + elif chosen.data() and isinstance(chosen.data(), str): + self._change_color(chosen.data()) + + # ── Icon Bar Slot'ları ─────────────────────────────────────────── + + def _on_pin_toggle(self) -> None: + self._pinned = not self._pinned + tc = get_contrast_text_color(self._color) + accent = get_dynamic_accent_color(self._color) + self._pin_btn.apply_style(tc, accent, active=self._pinned) + self.pin_toggled.emit(self._id, self._pinned) + + def _on_color_btn(self) -> None: + popup = ColorPickerPopup(self._colors_dict, self._color, self) + popup.color_selected.connect(self._change_color) + # Butonun altında aç + btn_pos = self._color_btn.mapToGlobal(QPoint(0, self._color_btn.height() + 4)) + popup.move(btn_pos) + popup.show() + + def _change_color(self, color: str) -> None: + self._apply_color(color) + self.color_changed.emit(self._id, color) + + def _change_font_size(self, delta: int) -> None: + new_size = max(FONT_SIZE_MIN, min(FONT_SIZE_MAX, self._font_size + delta)) + if new_size == self._font_size: + return + self._apply_font(self._font_family, new_size) + self.font_size_changed.emit(self._id, new_size) + + # ── Public API ─────────────────────────────────────────────────── + + @property + def note_id(self) -> str: + return self._id + + def is_empty(self) -> bool: + return self._text_edit.toPlainText().strip() == "" + + def get_content(self) -> str: + return self._text_edit.toPlainText() + + def set_font_settings(self, family: str, size: int) -> None: + self._apply_font(family, size) + + def set_color(self, color: str) -> None: + self._apply_color(color) + + def set_colors_dict(self, colors: Dict[str, str]) -> None: + self._colors_dict = colors + + def set_active(self, active: bool) -> None: + color = self._color + accent = get_dynamic_accent_color(color) + dark = _darken(color, 12) + if active: + border = f"1.5px solid {dark}; border-left: 3px solid {accent};" + else: + border = f"1.5px solid {dark}; border-left: 1.5px solid {dark};" + self.setStyleSheet(f""" + NoteWidget {{ + background-color: {color}; + border-radius: 12px; + border: {border}; + }} + """) + + def focus_text(self) -> None: + self._text_edit.setFocus() + self._text_edit.moveCursor(QTextCursor.End) + + @property + def is_pinned(self) -> bool: + return self._pinned diff --git a/src/settings_dialog.py b/src/settings_dialog.py new file mode 100644 index 0000000..61eaef1 --- /dev/null +++ b/src/settings_dialog.py @@ -0,0 +1,488 @@ +""" +settings_dialog.py — Ayarlar + Oturum yönetimi penceresi +""" + +import logging +from typing import Any, Dict, List, Optional + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QColor, QFont +from PySide6.QtWidgets import ( + QCheckBox, + QColorDialog, + QDialog, + QDialogButtonBox, + QFontComboBox, + QFormLayout, + QGroupBox, + QHBoxLayout, + QInputDialog, + QKeySequenceEdit, + QLabel, + QListWidget, + QListWidgetItem, + QMessageBox, + QPushButton, + QSpinBox, + QTabWidget, + QVBoxLayout, + QWidget, +) + +from .constants import NOTE_COLORS + +logger = logging.getLogger(__name__) + + +# ─── Color picker button ───────────────────────────────────────────── + +class ColorPickerButton(QPushButton): + color_selected = Signal(str) + + def __init__(self, name: str, hc: str, parent=None) -> None: + super().__init__(parent) + self._hex = hc + self.setFixedSize(32, 32) + self.setToolTip(name) + self.setCursor(Qt.PointingHandCursor) + self._selected = False + self._apply() + self.clicked.connect(lambda: self.color_selected.emit(self._hex)) + + def set_selected(self, v: bool) -> None: + self._selected = v + self._apply() + + def _apply(self) -> None: + border = "3px solid #2196F3" if self._selected else "2px solid #888" + self.setStyleSheet(f""" + QPushButton {{ + background:{self._hex}; + border:{border}; + border-radius:16px; + }} + QPushButton:hover {{ border:3px solid #42A5F5; }} + """) + + +# ─── Settings Dialog ───────────────────────────────────────────────── + +class SettingsDialog(QDialog): + """Font, renk, kısayol, sistem ve oturum yönetimi.""" + + settings_saved = Signal(dict) + session_switch_requested = Signal(str) # session_name + session_create_requested = Signal(str) # session_name + session_copy_requested = Signal(str, str) # source_name, target_name + session_delete_requested = Signal(str) # session_name + + def __init__(self, current_settings: Dict[str, Any], + sessions: Optional[List[str]] = None, + current_session: str = "session", + parent=None) -> None: + super().__init__(parent) + self._settings = dict(current_settings) + self._sessions = sessions or [] + self._current_session = current_session + self._note_colors = dict(current_settings.get("note_colors", NOTE_COLORS)) + self._selected_color = current_settings.get("default_note_color", "#FFF9C4") + self._color_buttons: Dict[str, ColorPickerButton] = {} + + self.setWindowTitle("Noteki — Ayarlar") + self.setWindowFlags(Qt.Dialog | Qt.WindowCloseButtonHint) + self.setModal(True) + self.setMinimumWidth(440) + self.setFixedSize(460, 520) + + self._build_ui() + self._load_values() + self._apply_style() + + # ── UI ─────────────────────────────────────────────────────────── + + def _build_ui(self) -> None: + layout = QVBoxLayout(self) + layout.setContentsMargins(16, 16, 16, 12) + layout.setSpacing(12) + + title = QLabel("⚙️ Ayarlar") + title.setStyleSheet("font-size:18px;font-weight:600;color:#1A1A2E;") + layout.addWidget(title) + + tabs = QTabWidget() + tabs.addTab(self._build_font_tab(), " Font & Renk ") + tabs.addTab(self._build_system_tab(), " Sistem ") + tabs.addTab(self._build_session_tab(), " Oturumlar ") + layout.addWidget(tabs) + + btn_box = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) + btn_box.button(QDialogButtonBox.Save).setText("Kaydet") + btn_box.button(QDialogButtonBox.Cancel).setText("Iptal") + btn_box.accepted.connect(self._on_save) + btn_box.rejected.connect(self.reject) + layout.addWidget(btn_box) + + # ── Font & Renk sekmesi ────────────────────────────────────────── + + def _build_font_tab(self) -> QWidget: + w = QWidget() + layout = QVBoxLayout(w) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(12) + + # Font grubu + font_grp = QGroupBox("Varsayilan Font") + fl = QFormLayout(font_grp) + fl.setLabelAlignment(Qt.AlignRight) + fl.setContentsMargins(12, 14, 12, 12) + fl.setSpacing(10) + + self._font_combo = QFontComboBox() + self._font_combo.setFixedHeight(34) + fl.addRow("Font:", self._font_combo) + + self._font_size = QSpinBox() + self._font_size.setRange(8, 32) + self._font_size.setFixedHeight(34) + self._font_size.setSuffix(" pt") + fl.addRow("Boyut:", self._font_size) + layout.addWidget(font_grp) + + # Renk grubu (Dynamic structure) + color_grp = QGroupBox("Varsayilan Not Rengi") + color_grp_layout = QVBoxLayout(color_grp) + color_grp_layout.setContentsMargins(12, 14, 12, 12) + color_grp_layout.setSpacing(10) + + # Color picker buttons horizontal container + self._color_buttons_layout = QHBoxLayout() + self._color_buttons_layout.setSpacing(8) + color_grp_layout.addLayout(self._color_buttons_layout) + + # Controls row (Add / Delete buttons) + ctrl_layout = QHBoxLayout() + self._add_color_btn = QPushButton("🎨 Yeni Renk Ekle") + self._del_color_btn = QPushButton("🗑 Secili Rengi Sil") + self._add_color_btn.setFixedHeight(30) + self._del_color_btn.setFixedHeight(30) + self._add_color_btn.setCursor(Qt.PointingHandCursor) + self._del_color_btn.setCursor(Qt.PointingHandCursor) + + ctrl_layout.addWidget(self._add_color_btn) + ctrl_layout.addWidget(self._del_color_btn) + ctrl_layout.addStretch() + color_grp_layout.addLayout(ctrl_layout) + + # Connections + self._add_color_btn.clicked.connect(self._on_add_custom_color) + self._del_color_btn.clicked.connect(self._on_del_custom_color) + + layout.addWidget(color_grp) + layout.addStretch() + return w + + # ── Sistem sekmesi ─────────────────────────────────────────────── + + def _build_system_tab(self) -> QWidget: + w = QWidget() + layout = QVBoxLayout(w) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(12) + + # Kısayol + hk_grp = QGroupBox("Global Kisayol") + hl = QFormLayout(hk_grp) + hl.setLabelAlignment(Qt.AlignRight) + hl.setContentsMargins(12, 14, 12, 12) + hl.setSpacing(10) + self._hotkey_edit = QKeySequenceEdit() + self._hotkey_edit.setFixedHeight(34) + hl.addRow("Kisayol:", self._hotkey_edit) + layout.addWidget(hk_grp) + + # Sistem + sys_grp = QGroupBox("Sistem") + sl = QVBoxLayout(sys_grp) + sl.setContentsMargins(12, 14, 12, 12) + sl.setSpacing(10) + self._auto_start_cb = QCheckBox("Windows baslangicindan otomatik calistir") + sl.addWidget(self._auto_start_cb) + + self._only_show_in_tray_cb = QCheckBox("Sadece sistem tepsisinde goster (Gorev cubugunda gizle)") + sl.addWidget(self._only_show_in_tray_cb) + + layout.addWidget(sys_grp) + layout.addStretch() + return w + + # ── Oturumlar sekmesi ──────────────────────────────────────────── + + def _build_session_tab(self) -> QWidget: + w = QWidget() + layout = QVBoxLayout(w) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(10) + + info = QLabel(f"Aktif oturum: {self._current_session}") + info.setStyleSheet("color:#333;font-size:13px;") + layout.addWidget(info) + + # Oturum listesi + self._session_list = QListWidget() + self._session_list.setFixedHeight(150) + self._session_list.setStyleSheet(""" + QListWidget { + border:1px solid #CCC;border-radius:6px; + background:#FAFAFA;font-size:13px; + } + QListWidget::item { padding:6px 10px; } + QListWidget::item:selected { background:#BBDEFB; color:#1A1A2E; } + """) + self._populate_sessions() + layout.addWidget(self._session_list) + + # Butonlar + btn_row = QHBoxLayout() + self._switch_btn = QPushButton("▶ Ac / Gec") + self._new_btn = QPushButton("✚ Yeni Oturum") + self._copy_btn = QPushButton("📋 Kopyala") + self._del_btn = QPushButton("🗑 Sil") + + for b in (self._switch_btn, self._new_btn, self._copy_btn, self._del_btn): + b.setCursor(Qt.PointingHandCursor) + b.setFixedHeight(32) + btn_row.addWidget(b) + + self._switch_btn.clicked.connect(self._on_switch) + self._new_btn.clicked.connect(self._on_new_session) + self._copy_btn.clicked.connect(self._on_copy_session) + self._del_btn.clicked.connect(self._on_del_session) + layout.addLayout(btn_row) + layout.addStretch() + return w + + def _populate_sessions(self) -> None: + self._session_list.clear() + for s in self._sessions: + item = QListWidgetItem( + ("▶ " if s == self._current_session else " ") + s + ) + item.setData(Qt.UserRole, s) + self._session_list.addItem(item) + + # ── Değer Yükleme ──────────────────────────────────────────────── + + def _load_values(self) -> None: + self._font_combo.setCurrentFont( + QFont(self._settings.get("default_font_family", "Segoe UI")) + ) + self._font_size.setValue(self._settings.get("default_font_size", 14)) + + self._rebuild_color_buttons() + + from PySide6.QtGui import QKeySequence + self._hotkey_edit.setKeySequence( + QKeySequence(self._settings.get("hotkey", "Alt+N")) + ) + self._auto_start_cb.setChecked(self._settings.get("auto_start", False)) + self._only_show_in_tray_cb.setChecked(self._settings.get("only_show_in_tray", True)) + + # ── Slot'lar ───────────────────────────────────────────────────── + + def _on_color(self, hc: str) -> None: + self._selected_color = hc + for c, btn in self._color_buttons.items(): + btn.set_selected(c == hc) + + def _rebuild_color_buttons(self) -> None: + # Clear existing buttons from layout + while self._color_buttons_layout.count() > 0: + item = self._color_buttons_layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + self._color_buttons.clear() + + # Add new buttons from self._note_colors + for name, hc in self._note_colors.items(): + btn = ColorPickerButton(name, hc, self) + btn.color_selected.connect(self._on_color) + self._color_buttons[hc] = btn + self._color_buttons_layout.addWidget(btn) + + # Select the active one + if self._selected_color not in self._note_colors.values(): + if self._note_colors: + self._selected_color = list(self._note_colors.values())[0] + else: + self._selected_color = "#FFF9C4" + + for hc, btn in self._color_buttons.items(): + btn.set_selected(hc == self._selected_color) + + def _on_add_custom_color(self) -> None: + initial = QColor(self._selected_color) + color = QColorDialog.getColor(initial, self, "Yeni Renk Sec") + if not color.isValid(): + return + + hex_str = color.name().upper() + if hex_str in self._note_colors.values(): + QMessageBox.information(self, "Renk Ekle", "Bu renk zaten mevcut.") + return + + name, ok = QInputDialog.getText( + self, "Renk Adi", + "Yeni renk icin bir isim girin (Ornegin: Mint, Lavanta):" + ) + if not ok or not name.strip(): + i = 1 + while f"Renk {i}" in self._note_colors: + i += 1 + name = f"Renk {i}" + else: + name = name.strip() + + self._note_colors[name] = hex_str + self._selected_color = hex_str + self._rebuild_color_buttons() + + def _on_del_custom_color(self) -> None: + if len(self._note_colors) <= 1: + QMessageBox.warning(self, "Hata", "En az bir renk secenegi kalmalidir.") + return + + selected_key = None + for k, v in self._note_colors.items(): + if v == self._selected_color: + selected_key = k + break + + if selected_key: + reply = QMessageBox.question( + self, "Rengi Sil", + f"'{selected_key}' rengini silmek istediginizden emin misiniz?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No + ) + if reply == QMessageBox.Yes: + self._note_colors.pop(selected_key) + if self._selected_color not in self._note_colors.values(): + self._selected_color = list(self._note_colors.values())[0] + self._rebuild_color_buttons() + + def _on_save(self) -> None: + hk = self._hotkey_edit.keySequence().toString() + family = self._font_combo.currentFont().family() + if not family: + family = "Segoe UI" + new = { + "default_font_family": family, + "default_font_size": self._font_size.value(), + "default_note_color": self._selected_color, + "hotkey": hk or "Alt+N", + "auto_start": self._auto_start_cb.isChecked(), + "only_show_in_tray": self._only_show_in_tray_cb.isChecked(), + "note_colors": self._note_colors, + } + self._settings.update(new) + self.settings_saved.emit(new) + self.accept() + + def _on_switch(self) -> None: + item = self._session_list.currentItem() + if not item: + QMessageBox.information(self, "Oturum", "Lutfen bir oturum secin.") + return + name = item.data(Qt.UserRole) + if name == self._current_session: + QMessageBox.information(self, "Oturum", "Bu oturum zaten aktif.") + return + reply = QMessageBox.question( + self, "Oturum Degistir", + f"'{name}' oturumuna gecmek istiyor musunuz?\n" + "Mevcut oturum kaydedilecek.", + QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes, + ) + if reply == QMessageBox.Yes: + self.session_switch_requested.emit(name) + self.accept() + + def _on_new_session(self) -> None: + name, ok = QInputDialog.getText( + self, "Yeni Oturum", + "Oturum adi (harf, rakam, - ve _ kullanabilirsiniz):" + ) + if ok and name.strip(): + self.session_create_requested.emit(name.strip()) + self.accept() + + def _on_copy_session(self) -> None: + item = self._session_list.currentItem() + if not item: + QMessageBox.information(self, "Oturum", "Lutfen kopyalamak istediginiz oturumu secin.") + return + source_name = item.data(Qt.UserRole) + target_name, ok = QInputDialog.getText( + self, "Oturumu Kopyala", + f"'{source_name}' oturumunun yeni adi (harf, rakam, - ve _ kullanabilirsiniz):" + ) + if ok and target_name.strip(): + self.session_copy_requested.emit(source_name, target_name.strip()) + self.accept() + + def _on_del_session(self) -> None: + item = self._session_list.currentItem() + if not item: + return + name = item.data(Qt.UserRole) + if name == self._current_session: + QMessageBox.warning(self, "Hata", "Aktif oturum silinemez.") + return + reply = QMessageBox.question( + self, "Oturumu Sil", + f"'{name}' oturumunu kalici olarak silmek istiyor musunuz?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No, + ) + if reply == QMessageBox.Yes: + self.session_delete_requested.emit(name) + self._sessions = [s for s in self._sessions if s != name] + self._populate_sessions() + + # ── Stil ───────────────────────────────────────────────────────── + + def _apply_style(self) -> None: + self.setStyleSheet(""" + QDialog { background:#F5F5F5; font-family:'Segoe UI'; } + QTabWidget::pane { border:1px solid #DDD; border-radius:8px; background:white; } + QTabBar::tab { + background:#EEE; color:#555; padding:7px 16px; + border-radius:6px 6px 0 0; font-size:13px; margin-right:2px; + } + QTabBar::tab:selected { background:white; color:#1A1A2E; font-weight:600; } + QGroupBox { + font-size:13px; font-weight:600; color:#333; + border:1px solid #DDD; border-radius:8px; + margin-top:8px; background:white; + } + QGroupBox::title { + subcontrol-origin:margin; subcontrol-position:top left; + padding:0 6px; left:10px; + } + QFontComboBox, QSpinBox, QKeySequenceEdit { + border:1px solid #CCC; border-radius:6px; + padding:4px 8px; font-size:13px; background:#FAFAFA; + } + QFontComboBox:focus, QSpinBox:focus, QKeySequenceEdit:focus { + border:1.5px solid #2196F3; + } + QCheckBox { font-size:13px; color:#333; spacing:8px; } + QPushButton { + font-size:13px; padding:6px 16px; border-radius:7px; + background:#EEE; color:#333; border:1px solid #CCC; + } + QPushButton:hover { background:#DDD; } + QDialogButtonBox QPushButton[text="Kaydet"] { + background:#2196F3; color:white; border:none; + } + QDialogButtonBox QPushButton[text="Kaydet"]:hover { background:#1976D2; } + """) diff --git a/src/tray_manager.py b/src/tray_manager.py new file mode 100644 index 0000000..f6567c3 --- /dev/null +++ b/src/tray_manager.py @@ -0,0 +1,181 @@ +""" +tray_manager.py — System Tray ikonu ve menü yönetimi +""" + +import logging +import os +from typing import Optional + +from PySide6.QtCore import QObject, Signal +from PySide6.QtGui import QIcon, QPixmap, QColor, QPainter, QBrush, QPen, QFont +from PySide6.QtWidgets import QMenu, QMessageBox, QSystemTrayIcon, QWidget + +from .constants import APP_NAME + +logger = logging.getLogger(__name__) + + +def _make_default_icon(size: int = 32) -> QIcon: + """ + Assets klasöründe ikon yoksa programatik olarak basit bir ikon oluşturur. + """ + pixmap = QPixmap(size, size) + pixmap.fill(QColor(0, 0, 0, 0)) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.Antialiasing) + + # Arka plan (sarı not rengi) + painter.setBrush(QBrush(QColor("#FFF176"))) + painter.setPen(QPen(QColor("#F9A825"), 1.5)) + painter.drawRoundedRect(3, 3, size - 6, size - 6, 4, 4) + + # "S" harfi + font = QFont("Segoe UI", int(size * 0.45), QFont.Bold) + painter.setFont(font) + painter.setPen(QColor("#E65100")) + painter.drawText(pixmap.rect(), Qt.AlignCenter, "S") + + painter.end() + return QIcon(pixmap) + + +class TrayManager(QObject): + """ + System Tray ikonunu ve menüsünü yönetir. + Sol tıklama → toggle, sağ tıklama → context menü. + """ + + # ─── Sinyaller ────────────────────────────────────────────────── + toggle_requested = Signal() + new_note_requested = Signal() + settings_requested = Signal() + quit_requested = Signal() + undo_requested = Signal() + clear_all_requested = Signal() + + def __init__( + self, + icon_path: Optional[str] = None, + parent: Optional[QObject] = None, + ) -> None: + super().__init__(parent) + self._note_count: int = 0 + self._canvas_visible: bool = False + + # İkon + if icon_path and os.path.exists(icon_path): + icon = QIcon(icon_path) + else: + icon = _make_default_icon() + + self._tray = QSystemTrayIcon(icon, self) + self._tray.setToolTip(f"{APP_NAME} — 0 Not") + + self._build_menu() + + self._tray.activated.connect(self._on_activated) + self._tray.show() + logger.info("System Tray başlatıldı.") + + # ─── Menü ─────────────────────────────────────────────────────── + + def _build_menu(self) -> None: + menu = QMenu() + menu.setStyleSheet(self._menu_style()) + + self._toggle_action = menu.addAction("👁 Notları Göster") + self._toggle_action.triggered.connect(self.toggle_requested) + + self._new_note_action = menu.addAction("✚ Yeni Not Ekle") + self._new_note_action.triggered.connect(self.new_note_requested) + + menu.addSeparator() + + self._clear_action = menu.addAction("🗑️ Tüm Notları Sil") + self._clear_action.triggered.connect(self._on_clear_all) + + self._undo_action = menu.addAction("↩ Geri Al (Ctrl+Z)") + self._undo_action.triggered.connect(self.undo_requested) + + menu.addSeparator() + + settings_action = menu.addAction("⚙️ Ayarlar...") + settings_action.triggered.connect(self.settings_requested) + + menu.addSeparator() + + quit_action = menu.addAction("✕ Çıkış") + quit_action.triggered.connect(self.quit_requested) + + self._tray.setContextMenu(menu) + + # ─── Slot'lar ─────────────────────────────────────────────────── + + def _on_activated(self, reason: QSystemTrayIcon.ActivationReason) -> None: + if reason == QSystemTrayIcon.Trigger: # Sol tıklama + self.toggle_requested.emit() + # Sağ tıklama → Qt otomatik context menüyü gösterir + + def _on_clear_all(self) -> None: + if self._note_count == 0: + return + reply = QMessageBox.question( + None, + "Tüm Notları Sil", + f"Tüm {self._note_count} not kalıcı olarak silinecek.\nEmin misiniz?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply == QMessageBox.Yes: + self.clear_all_requested.emit() + + # ─── Public API ───────────────────────────────────────────────── + + def update_note_count(self, count: int) -> None: + self._note_count = count + self._tray.setToolTip(f"{APP_NAME} — {count} Not") + self._toggle_action.setText( + "🙈 Notlari Gizle" if self._canvas_visible else "👁 Notlari Goster" + ) + self._clear_action.setEnabled(count > 0) + + def update_toggle_label(self, canvas_visible: bool) -> None: + self._canvas_visible = canvas_visible + if canvas_visible: + self._toggle_action.setText("🙈 Notlari Gizle") + else: + self._toggle_action.setText("👁 Notlari Goster") + + def show_message(self, title: str, message: str) -> None: + self._tray.showMessage(title, message, QSystemTrayIcon.Information, 3000) + + # ─── Stil ─────────────────────────────────────────────────────── + + def _menu_style(self) -> str: + return """ + QMenu { + background-color: #1E1E2E; + color: #CDD6F4; + border: 1px solid #45475A; + border-radius: 10px; + padding: 6px; + font-family: 'Segoe UI'; + font-size: 13px; + min-width: 200px; + } + QMenu::item { + padding: 8px 22px; + border-radius: 6px; + } + QMenu::item:selected { + background-color: #313244; + } + QMenu::item:disabled { + color: #585B70; + } + QMenu::separator { + height: 1px; + background: #45475A; + margin: 4px 2px; + } + """ diff --git a/src/undo_manager.py b/src/undo_manager.py new file mode 100644 index 0000000..74f1829 --- /dev/null +++ b/src/undo_manager.py @@ -0,0 +1,59 @@ +""" +undo_manager.py — Geri alma kuyruğu (RAM tabanlı, 10 işlem kapasiteli) +""" + +import logging +from collections import deque +from typing import Any, Callable, Dict, Optional + +from .constants import UNDO_CAPACITY + +logger = logging.getLogger(__name__) + + +class UndoManager: + """ + Silinen notları 10 işlemlik bir kuyrukta tutar. + Ctrl+Z ile sırayla geri yüklemeyi sağlar. + Uygulama kapatıldığında kuyruk temizlenir (RAM tabanlı). + """ + + def __init__(self) -> None: + self._queue: deque[Dict[str, Any]] = deque(maxlen=UNDO_CAPACITY) + self._on_restore: Optional[Callable[[Dict[str, Any]], None]] = None + + def set_restore_callback(self, callback: Callable[[Dict[str, Any]], None]) -> None: + """Not geri yüklendiğinde çağrılacak fonksiyonu ayarlar.""" + self._on_restore = callback + + def push(self, note_data: Dict[str, Any]) -> None: + """Silinen not verisini kuyruğa ekler.""" + self._queue.append(dict(note_data)) + logger.debug("Undo kuyruğuna eklendi: %s (toplam: %d)", note_data.get("id"), len(self._queue)) + + def pop(self) -> Optional[Dict[str, Any]]: + """ + Kuyruktaki en son silinen notu çıkarır ve geri yükler. + Kuyruk boşsa None döner. + """ + if not self._queue: + logger.debug("Undo kuyruğu boş.") + return None + note_data = self._queue.pop() + logger.debug("Geri alınıyor: %s (kalan: %d)", note_data.get("id"), len(self._queue)) + if self._on_restore: + self._on_restore(note_data) + return note_data + + def can_undo(self) -> bool: + """Geri alınabilir işlem var mı?""" + return len(self._queue) > 0 + + def clear(self) -> None: + """Kuyruğu temizler (uygulama kapatıldığında çağrılır).""" + self._queue.clear() + logger.debug("Undo kuyruğu temizlendi.") + + @property + def count(self) -> int: + return len(self._queue)