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:
2026-05-21 04:13:45 +03:00
commit 9f3a599b79
19 changed files with 3720 additions and 0 deletions

164
.gitignore vendored Normal file
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

51
main.py Normal file
View 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
View 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
View 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ıı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
View File

@@ -0,0 +1,2 @@
PySide6>=6.6.0
Pillow>=10.0.0

1
src/__init__.py Normal file
View File

@@ -0,0 +1 @@
# src/__init__.py

77
src/add_button.py Normal file
View 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
View 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
View 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
View 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.
ı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
View 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
View 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
View 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
View 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
View 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
View 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)