mirror of
https://github.com/alhan/noteki.git
synced 2026-08-23 05:30:19 +00:00
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
This commit is contained in:
164
.gitignore
vendored
Normal file
164
.gitignore
vendored
Normal file
@@ -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
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -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.
|
||||||
144
README.md
Normal file
144
README.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# Noteki — Sticky Notes for Windows
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/icon.png" alt="Noteki" width="128" height="128">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<strong>Modern, lightweight, customizable sticky notes that stay out of your way.</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/python-3.8+-blue.svg" alt="Python">
|
||||||
|
<img src="https://img.shields.io/badge/framework-PySide6%20(Qt6)-green.svg" alt="PySide6">
|
||||||
|
<img src="https://img.shields.io/badge/platform-Windows-0078D6.svg" alt="Windows">
|
||||||
|
<img src="https://img.shields.io/badge/license-MIT-yellow.svg" alt="MIT">
|
||||||
|
<a href="https://github.com/kullaniciadi/noteki/releases/latest"><img src="https://img.shields.io/github/v/release/kullaniciadi/noteki?color=orange" alt="Release"></a>
|
||||||
|
<img src="https://img.shields.io/badge/downloads-EXE%20available-brightgreen.svg" alt="EXE">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<sub>Built with and PySide6</sub>
|
||||||
|
</p>
|
||||||
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 332 KiB |
51
main.py
Normal file
51
main.py
Normal file
@@ -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()
|
||||||
278
prd.md
Normal file
278
prd.md
Normal file
@@ -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.
|
||||||
64
pyinstaller_guide.md
Normal file
64
pyinstaller_guide.md
Normal file
@@ -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.
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
PySide6>=6.6.0
|
||||||
|
Pillow>=10.0.0
|
||||||
1
src/__init__.py
Normal file
1
src/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# src/__init__.py
|
||||||
77
src/add_button.py
Normal file
77
src/add_button.py
Normal file
@@ -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ı.")
|
||||||
443
src/app.py
Normal file
443
src/app.py
Normal file
@@ -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()
|
||||||
417
src/canvas_window.py
Normal file
417
src/canvas_window.py
Normal file
@@ -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; }
|
||||||
|
"""
|
||||||
154
src/constants.py
Normal file
154
src/constants.py
Normal file
@@ -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"
|
||||||
|
|
||||||
361
src/data_manager.py
Normal file
361
src/data_manager.py
Normal file
@@ -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
|
||||||
168
src/hotkey_manager.py
Normal file
168
src/hotkey_manager.py
Normal file
@@ -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()
|
||||||
647
src/note_widget.py
Normal file
647
src/note_widget.py
Normal file
@@ -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
|
||||||
488
src/settings_dialog.py
Normal file
488
src/settings_dialog.py
Normal file
@@ -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: <b>{self._current_session}</b>")
|
||||||
|
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; }
|
||||||
|
""")
|
||||||
181
src/tray_manager.py
Normal file
181
src/tray_manager.py
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
"""
|
||||||
59
src/undo_manager.py
Normal file
59
src/undo_manager.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user