Files
aza/AzA march 2026 - Kopie (4)/aza_notizen_mixin.py

1047 lines
39 KiB
Python
Raw Normal View History

2026-03-25 13:42:48 +01:00
# -*- coding: utf-8 -*-
"""
AzaNotizenMixin Projekt-Notizen-Fenster mit Tabs, Auto-Save und Diktat.
Eigene Notizen: dynamische Sub-Tabs mit + / (Titel, Diktat je Tab).
"""
import json
import os
import re
import html
import threading
import wave
import tkinter as tk
import tkinter.font as tkfont
from tkinter import ttk, simpledialog, messagebox, filedialog
from tkinter.scrolledtext import ScrolledText
from aza_ui_helpers import (
add_resize_grip,
add_font_scale_control,
add_text_font_size_control,
setup_window_geometry_saving,
save_toplevel_geometry,
RoundedButton,
)
from aza_audio import AudioRecorder
from aza_persistence import _win_clipboard_set, sanitize_markdown_for_plain_text
from aza_config import get_writable_data_dir
try:
from PIL import Image, ImageTk
_HAS_PIL = True
except ImportError:
_HAS_PIL = False
_NOTIZEN_DIR = os.path.join(get_writable_data_dir(), "notizen")
_EIGENE_DIR = os.path.join(_NOTIZEN_DIR, "eigene")
_IMAGES_DIR = os.path.join(_EIGENE_DIR, "images")
_TABS_CONFIG = os.path.join(_NOTIZEN_DIR, "eigene_notizen_tabs.json")
_MAX_IMG_WIDTH = 600
_FIXED_TABS = [
("Projektstatus", "projekt_status.md"),
("Changelog", "changelog.md"),
("Sonnet Prompt", "sonnet_prompt.txt"),
]
def _load_file(filepath: str) -> str:
try:
if os.path.isfile(filepath):
with open(filepath, "r", encoding="utf-8") as f:
return f.read()
except Exception:
pass
return ""
def _save_file(filepath: str, content: str):
os.makedirs(os.path.dirname(filepath), exist_ok=True)
with open(filepath, "w", encoding="utf-8") as f:
f.write(content)
def _load_eigene_tabs() -> list:
try:
if os.path.isfile(_TABS_CONFIG):
with open(_TABS_CONFIG, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, list) and data:
return data
except Exception:
pass
old_file = os.path.join(_NOTIZEN_DIR, "eigene_notizen.md")
if os.path.isfile(old_file):
new_path = os.path.join(_EIGENE_DIR, "eigene_notizen.md")
os.makedirs(_EIGENE_DIR, exist_ok=True)
if not os.path.isfile(new_path):
try:
import shutil
shutil.move(old_file, new_path)
except Exception:
pass
return [{"title": "Allgemein", "filename": "eigene_notizen.md"}]
return [{"title": "Allgemein", "filename": "eigene_notizen.md"}]
def _save_eigene_tabs(tabs: list):
os.makedirs(_NOTIZEN_DIR, exist_ok=True)
with open(_TABS_CONFIG, "w", encoding="utf-8") as f:
json.dump(tabs, f, ensure_ascii=False, indent=2)
def _copy_image_to_store(src_path: str) -> str:
"""Kopiert ein Bild nach eigene/images/ und gibt den neuen Dateinamen zurueck."""
import shutil
os.makedirs(_IMAGES_DIR, exist_ok=True)
basename = os.path.basename(src_path)
name, ext = os.path.splitext(basename)
dest = os.path.join(_IMAGES_DIR, basename)
counter = 2
while os.path.exists(dest):
dest = os.path.join(_IMAGES_DIR, f"{name}_{counter}{ext}")
counter += 1
shutil.copy2(src_path, dest)
return os.path.basename(dest)
def _load_and_insert_image(txt_widget, rel_filename: str):
"""Laedt ein Bild aus images/ und bettet es an der INSERT-Position ein."""
if not _HAS_PIL:
txt_widget.insert(tk.INSERT, f"[Bild: {rel_filename}]")
return
fpath = os.path.join(_IMAGES_DIR, rel_filename)
if not os.path.isfile(fpath):
txt_widget.insert(tk.INSERT, f"[Bild nicht gefunden: {rel_filename}]")
return
try:
img = Image.open(fpath)
img.load()
w, h = img.size
if w > _MAX_IMG_WIDTH:
ratio = _MAX_IMG_WIDTH / w
img = img.resize((int(w * ratio), int(h * ratio)), Image.LANCZOS)
photo = ImageTk.PhotoImage(img)
except Exception:
txt_widget.insert(tk.INSERT, f"[Bild-Fehler: {rel_filename}]")
return
if not hasattr(txt_widget, "_aza_images"):
txt_widget._aza_images = []
txt_widget._aza_images.append(photo)
if not hasattr(txt_widget, "_aza_image_map"):
txt_widget._aza_image_map = {}
img_name = txt_widget.image_create(tk.INSERT, image=photo)
txt_widget._aza_image_map[img_name] = rel_filename
def _insert_image_at_cursor(txt_widget, rel_filename: str):
"""Fuegt ein Bild an der Cursor-Position ein und sorgt fuer eigene Zeile."""
cur = txt_widget.index(tk.INSERT)
col = int(cur.split(".")[1])
if col > 0:
txt_widget.insert(tk.INSERT, "\n")
_load_and_insert_image(txt_widget, rel_filename)
txt_widget.insert(tk.INSERT, "\n")
def _image_dialog_insert(txt_widget):
"""Oeffnet Dateidialog und fuegt das gewaehlte Bild ein."""
if not _HAS_PIL:
messagebox.showinfo("Hinweis", "Pillow (PIL) wird für Bilder benötigt.", parent=txt_widget.winfo_toplevel())
return
path = filedialog.askopenfilename(
title="Bild einfügen",
filetypes=[("Bilder", "*.png *.jpg *.jpeg *.gif *.bmp *.webp"), ("Alle Dateien", "*.*")],
parent=txt_widget.winfo_toplevel(),
)
if not path:
return
stored_fn = _copy_image_to_store(path)
if stored_fn:
_insert_image_at_cursor(txt_widget, stored_fn)
def _ensure_rich_tags(txt_widget):
if getattr(txt_widget, "_aza_rich_tags_ready", False):
return
try:
base = tkfont.Font(font=txt_widget.cget("font"))
except Exception:
base = tkfont.nametofont("TkTextFont").copy()
base_size = int(base.cget("size")) if str(base.cget("size")).lstrip("-").isdigit() else 10
f_bold = base.copy()
f_bold.configure(weight="bold")
f_h1 = base.copy()
f_h1.configure(weight="bold", size=max(base_size + 6, 16))
f_h2 = base.copy()
f_h2.configure(weight="bold", size=max(base_size + 4, 14))
f_h3 = base.copy()
f_h3.configure(weight="bold", size=max(base_size + 2, 12))
txt_widget.tag_configure("rt_bold", font=f_bold)
txt_widget.tag_configure("rt_h1", font=f_h1, spacing1=6, spacing3=4)
txt_widget.tag_configure("rt_h2", font=f_h2, spacing1=4, spacing3=3)
txt_widget.tag_configure("rt_h3", font=f_h3, spacing1=3, spacing3=2)
txt_widget.tag_configure("rt_li", lmargin1=22, lmargin2=36)
txt_widget._aza_rich_fonts = (f_bold, f_h1, f_h2, f_h3)
txt_widget._aza_rich_tags_ready = True
def _insert_markdown_inline(txt_widget, text: str):
pos = 0
for m in re.finditer(r"\*\*(.+?)\*\*|__(.+?)__", text or ""):
plain = text[pos:m.start()]
if plain:
txt_widget.insert(tk.INSERT, plain)
chunk = m.group(1) if m.group(1) is not None else (m.group(2) or "")
if chunk:
s = txt_widget.index(tk.INSERT)
txt_widget.insert(tk.INSERT, chunk)
e = txt_widget.index(tk.INSERT)
txt_widget.tag_add("rt_bold", s, e)
pos = m.end()
tail = (text or "")[pos:]
if tail:
txt_widget.insert(tk.INSERT, tail)
def _insert_markdown_as_rich_text(txt_widget, raw_text: str):
_ensure_rich_tags(txt_widget)
lines = (raw_text or "").replace("\r\n", "\n").replace("\r", "\n").split("\n")
for i, raw_line in enumerate(lines):
stripped = raw_line.strip()
m_img = re.match(r'^!\[.*?\]\(images/(.+?)\)$', stripped)
if m_img and _HAS_PIL:
_load_and_insert_image(txt_widget, m_img.group(1))
if i < len(lines) - 1:
txt_widget.insert(tk.INSERT, "\n")
continue
tag_name = None
text_to_insert = raw_line
is_list = False
if stripped:
m_head = re.match(r"^(#{1,3})\s+(.*)$", stripped)
m_list = re.match(r"^[-*•]\s+(.*)$", stripped)
if m_head:
level = len(m_head.group(1))
text_to_insert = m_head.group(2)
tag_name = {1: "rt_h1", 2: "rt_h2", 3: "rt_h3"}.get(level, "rt_h3")
elif m_list:
text_to_insert = m_list.group(1)
is_list = True
line_start = txt_widget.index(tk.INSERT)
if is_list:
txt_widget.insert(tk.INSERT, "")
_insert_markdown_inline(txt_widget, text_to_insert)
line_end = txt_widget.index(tk.INSERT)
if tag_name and txt_widget.compare(line_end, ">", line_start):
txt_widget.tag_add(tag_name, line_start, line_end)
if is_list and txt_widget.compare(line_end, ">", line_start):
txt_widget.tag_add("rt_li", line_start, line_end)
if i < len(lines) - 1:
txt_widget.insert(tk.INSERT, "\n")
def _set_widget_from_markdown(txt_widget, raw_text: str):
txt_widget.delete("1.0", "end")
if raw_text:
_insert_markdown_as_rich_text(txt_widget, raw_text)
def _range_to_markdown_inline(txt_widget, start: str, end: str) -> str:
idx = start
out = []
bold_on = False
while txt_widget.compare(idx, "<", end):
ch = txt_widget.get(idx, f"{idx}+1c")
is_bold = "rt_bold" in txt_widget.tag_names(idx)
if is_bold and not bold_on:
out.append("**")
bold_on = True
elif not is_bold and bold_on:
out.append("**")
bold_on = False
out.append(ch)
idx = f"{idx}+1c"
if bold_on:
out.append("**")
return "".join(out)
def _widget_to_markdown(txt_widget) -> str:
end_idx = txt_widget.index("end-1c")
try:
total_lines = int(end_idx.split(".")[0])
except Exception:
return ""
img_map = getattr(txt_widget, "_aza_image_map", {})
out_lines = []
for line_no in range(1, total_lines + 1):
line_start = f"{line_no}.0"
line_end = f"{line_no}.end"
if img_map:
try:
line_dump = txt_widget.dump(line_start, line_end, image=True)
if line_dump:
imgs = [img_map[e[1]] for e in line_dump
if len(e) >= 2 and e[0] == "image" and e[1] in img_map]
if imgs:
for ifn in imgs:
out_lines.append(f"![](images/{ifn})")
continue
except Exception:
pass
line_text = txt_widget.get(line_start, line_end)
line_md = _range_to_markdown_inline(txt_widget, line_start, line_end)
tags = txt_widget.tag_names(line_start)
prefix = ""
if "rt_h1" in tags:
prefix = "# "
elif "rt_h2" in tags:
prefix = "## "
elif "rt_h3" in tags:
prefix = "### "
elif "rt_li" in tags:
stripped = line_text.lstrip()
if stripped.startswith(""):
stripped = stripped[1:].lstrip()
line_md = stripped
prefix = "- "
out_lines.append((prefix + line_md) if (prefix or line_md) else "")
return "\n".join(out_lines)
def _range_to_html_inline(txt_widget, start: str, end: str) -> str:
idx = start
out = []
bold_on = False
while txt_widget.compare(idx, "<", end):
ch = txt_widget.get(idx, f"{idx}+1c")
is_bold = "rt_bold" in txt_widget.tag_names(idx)
if is_bold and not bold_on:
out.append("<strong>")
bold_on = True
elif not is_bold and bold_on:
out.append("</strong>")
bold_on = False
out.append(html.escape(ch))
idx = f"{idx}+1c"
if bold_on:
out.append("</strong>")
return "".join(out)
def _selection_to_html_fragment(txt_widget) -> str:
try:
sel_start = txt_widget.index("sel.first")
sel_end = txt_widget.index("sel.last")
except Exception:
return ""
sel_text = txt_widget.get(sel_start, sel_end)
if not sel_text:
return ""
lines = sel_text.split("\n")
cursor = sel_start
html_parts = []
in_ul = False
for i, line in enumerate(lines):
line_start = cursor
line_end = f"{line_start}+{len(line)}c"
line_html = _range_to_html_inline(txt_widget, line_start, line_end)
tags = txt_widget.tag_names(line_start)
is_h1 = "rt_h1" in tags
is_h2 = "rt_h2" in tags
is_h3 = "rt_h3" in tags
is_li = "rt_li" in tags
if not line:
if in_ul:
html_parts.append("</ul>")
in_ul = False
html_parts.append("<br>")
elif is_li:
if not in_ul:
html_parts.append("<ul>")
in_ul = True
clean = line_html.strip()
if clean.startswith(""):
clean = clean[1:].lstrip()
html_parts.append(f"<li>{clean}</li>")
else:
if in_ul:
html_parts.append("</ul>")
in_ul = False
if is_h1:
html_parts.append(f"<h1>{line_html}</h1>")
elif is_h2:
html_parts.append(f"<h2>{line_html}</h2>")
elif is_h3:
html_parts.append(f"<h3>{line_html}</h3>")
else:
html_parts.append(f"<p>{line_html}</p>")
if i < len(lines) - 1:
cursor = f"{line_end}+1c"
if in_ul:
html_parts.append("</ul>")
return "".join(html_parts)
class AzaNotizenMixin:
"""Mixin: Notizen-Fenster mit Reitern für Projektdokumentation."""
_notizen_window = None
def open_notizen_window(self):
if self._notizen_window is not None:
try:
if self._notizen_window.winfo_exists():
self._notizen_window.lift()
self._notizen_window.focus_force()
return
except Exception:
pass
self._notizen_window = None
win = tk.Toplevel(self)
win.title("Projekt-Notizen")
win.minsize(700, 520)
win.configure(bg="#B9ECFA")
win.attributes("-topmost", True)
self._notizen_window = win
self._register_window(win)
setup_window_geometry_saving(win, "notizen", 900, 700)
add_resize_grip(win, 700, 520)
add_font_scale_control(win)
header = tk.Frame(win, bg="#B9ECFA")
header.pack(fill="x")
tk.Label(
header, text="📋 Projekt-Notizen", font=("Segoe UI", 12, "bold"),
bg="#B9ECFA", fg="#1a4d6d",
).pack(side="left", padx=10, pady=6)
notebook = ttk.Notebook(win)
notebook.pack(fill="both", expand=True, padx=8, pady=(0, 4))
os.makedirs(_NOTIZEN_DIR, exist_ok=True)
editors = {}
auto_save_ids = {}
# ────────────────────────────────────────────
# Feste Tabs (Projektstatus, Changelog, Prompt)
# ────────────────────────────────────────────
for tab_label, filename in _FIXED_TABS:
frame = ttk.Frame(notebook, padding=6)
notebook.add(frame, text=tab_label)
filepath = os.path.join(_NOTIZEN_DIR, filename)
top_bar = ttk.Frame(frame)
top_bar.pack(fill="x", pady=(0, 4))
ttk.Label(top_bar, text=filename, font=("Segoe UI", 9, "italic")).pack(side="left")
txt = ScrolledText(frame, wrap="word", font=("Segoe UI", 10), bg="#F5FCFF", undo=True)
txt.pack(fill="both", expand=True)
_ensure_rich_tags(txt)
_enable_direct_paste(txt)
add_text_font_size_control(top_bar, txt, initial_size=10, bg_color="#B9ECFA", save_key=f"notizen_{filename}")
content = _load_file(filepath)
if content:
_set_widget_from_markdown(txt, content)
txt.edit_reset()
save_label = tk.Label(top_bar, text="", fg="#388E3C", bg="#B9ECFA", font=("Segoe UI", 8))
save_label.pack(side="right", padx=6)
editors[filename] = txt
auto_save_ids[filename] = None
_bind_auto_save(win, txt, filename, editors, auto_save_ids,
lambda fn, sl: lambda: _auto_save(win, fn, sl, editors, _NOTIZEN_DIR),
save_label)
# ────────────────────────────────────────────
# "Eigene Notizen" Container-Tab mit Sub-Notebook
# ────────────────────────────────────────────
eigene_outer = ttk.Frame(notebook, padding=4)
notebook.add(eigene_outer, text="Eigene Notizen")
eigene_btn_bar = tk.Frame(eigene_outer, bg="#B9ECFA")
eigene_btn_bar.pack(fill="x", pady=(0, 4))
sub_notebook = ttk.Notebook(eigene_outer)
sub_notebook.pack(fill="both", expand=True)
eigene_tabs_data = _load_eigene_tabs()
os.makedirs(_EIGENE_DIR, exist_ok=True)
sub_editors = {}
sub_auto_save_ids = {}
sub_recorders = {}
sub_recording = {}
sub_rec_buttons = {}
sub_status_vars = {}
def _build_eigene_tab(tab_info: dict, select=False):
title = tab_info["title"]
filename = tab_info["filename"]
filepath = os.path.join(_EIGENE_DIR, filename)
frame = ttk.Frame(sub_notebook, padding=6)
sub_notebook.add(frame, text=title)
top_bar = ttk.Frame(frame)
top_bar.pack(fill="x", pady=(0, 4))
ttk.Label(top_bar, text=filename, font=("Segoe UI", 9, "italic")).pack(side="left")
txt = ScrolledText(frame, wrap="word", font=("Segoe UI", 10), bg="#F5FCFF", undo=True)
txt.pack(fill="both", expand=True)
_ensure_rich_tags(txt)
txt._aza_images_enabled = True
_enable_direct_paste(txt)
add_text_font_size_control(top_bar, txt, initial_size=10, bg_color="#B9ECFA", save_key=f"notizen_eigene_{filename}")
content = _load_file(filepath)
if content:
_set_widget_from_markdown(txt, content)
txt.edit_reset()
save_label = tk.Label(top_bar, text="", fg="#388E3C", bg="#B9ECFA", font=("Segoe UI", 8))
save_label.pack(side="right", padx=6)
sub_editors[filename] = txt
sub_auto_save_ids[filename] = None
_bind_auto_save(win, txt, filename, sub_editors, sub_auto_save_ids,
lambda fn, sl: lambda: _auto_save(win, fn, sl, sub_editors, _EIGENE_DIR),
save_label)
bottom = ttk.Frame(frame, padding=(0, 4, 0, 0))
bottom.pack(fill="x")
status_var = tk.StringVar(value="")
sub_status_vars[filename] = status_var
sub_recorders[filename] = None
sub_recording[filename] = False
def _toggle_diktat(fn=filename):
if not hasattr(self, 'ensure_ready') or not self.ensure_ready():
return
if sub_recorders[fn] is None:
sub_recorders[fn] = AudioRecorder()
rec = sub_recorders[fn]
if not sub_recording[fn]:
try:
rec.start()
sub_recording[fn] = True
sub_rec_buttons[fn].configure(text="⏹ Stopp")
sub_status_vars[fn].set("Aufnahme läuft…")
except Exception as e:
sub_status_vars[fn].set(f"Fehler: {e}")
else:
sub_recording[fn] = False
sub_rec_buttons[fn].configure(text="⏺ Diktieren")
sub_status_vars[fn].set("Transkribiere…")
def worker(fn_w=fn):
def _safe(callback):
try:
if self.winfo_exists():
self.after(0, callback)
except Exception:
pass
try:
wav_path = sub_recorders[fn_w].stop_and_save_wav()
try:
with wave.open(wav_path, 'rb') as wf:
if wf.getnframes() / float(wf.getframerate()) < 0.3:
try:
os.remove(wav_path)
except Exception:
pass
sub_recorders[fn_w] = None
_safe(lambda: sub_status_vars[fn_w].set("Kein Audio erkannt."))
return
except Exception:
pass
result = self.transcribe_wav(wav_path)
text = result.text if hasattr(result, 'text') else (result if isinstance(result, str) else "")
try:
os.remove(wav_path)
except Exception:
pass
if not text or not text.strip():
sub_recorders[fn_w] = None
_safe(lambda: sub_status_vars[fn_w].set("Kein Text erkannt."))
return
if hasattr(self, '_diktat_apply_punctuation'):
text = self._diktat_apply_punctuation(text)
def _insert(t=text):
sub_recorders[fn_w] = None
tw = sub_editors.get(fn_w)
try:
if tw and tw.winfo_exists():
tw.configure(state="normal")
cur = tw.get("1.0", "end-1c")
if cur and not cur.endswith("\n") and not cur.endswith(" "):
tw.insert(tk.INSERT, " ")
tw.insert(tk.INSERT, t)
sub_status_vars[fn_w].set("Diktat eingefügt.")
except Exception:
pass
_safe(_insert)
except Exception as e:
def _err(err=e):
sub_recorders[fn_w] = None
sub_status_vars[fn_w].set(f"Fehler: {err}")
_safe(_err)
threading.Thread(target=worker, daemon=True).start()
btn_r = RoundedButton(bottom, "⏺ Diktieren", command=_toggle_diktat, width=140, height=26, canvas_bg="#B9ECFA")
btn_r.pack(side="left")
sub_rec_buttons[filename] = btn_r
if _HAS_PIL:
RoundedButton(
bottom, "Bild einfügen", command=lambda t=txt: _image_dialog_insert(t),
width=120, height=26, canvas_bg="#B9ECFA",
).pack(side="left", padx=(6, 0))
status_bar = tk.Frame(bottom, bg="#FFE4CC", height=20, padx=6, pady=2)
status_bar.pack(side="left", fill="x", expand=True, padx=(8, 0))
status_bar.pack_propagate(False)
tk.Label(status_bar, textvariable=status_var, fg="#BD4500", bg="#FFE4CC",
font=("Segoe UI", 8), anchor="w").pack(side="left", fill="x", expand=True)
if select:
sub_notebook.select(frame)
for tab_info in eigene_tabs_data:
_build_eigene_tab(tab_info)
def _add_eigene_tab():
title = simpledialog.askstring("Neue Notiz", "Titel für die neue Notiz:", parent=win)
if not title or not title.strip():
return
title = title.strip()
safe_name = "".join(c if c.isalnum() or c in " _-" else "_" for c in title).strip().replace(" ", "_").lower()
if not safe_name:
safe_name = "notiz"
existing_fns = {t["filename"] for t in eigene_tabs_data}
fn = f"{safe_name}.md"
counter = 2
while fn in existing_fns:
fn = f"{safe_name}_{counter}.md"
counter += 1
new_tab = {"title": title, "filename": fn}
eigene_tabs_data.append(new_tab)
_save_eigene_tabs(eigene_tabs_data)
_build_eigene_tab(new_tab, select=True)
def _remove_eigene_tab():
if not sub_notebook.tabs():
return
idx = sub_notebook.index(sub_notebook.select())
if idx < 0 or idx >= len(eigene_tabs_data):
return
tab_info = eigene_tabs_data[idx]
if len(eigene_tabs_data) <= 1:
messagebox.showinfo("Hinweis", "Die letzte Notiz kann nicht gelöscht werden.", parent=win)
return
if not messagebox.askyesno(
"Notiz löschen",
f"Notiz «{tab_info['title']}» wirklich löschen?\n\nDie Datei wird nicht gelöscht, nur der Reiter entfernt.",
parent=win,
):
return
fn = tab_info["filename"]
sub_notebook.forget(idx)
eigene_tabs_data.pop(idx)
sub_editors.pop(fn, None)
sub_auto_save_ids.pop(fn, None)
sub_recorders.pop(fn, None)
sub_recording.pop(fn, None)
sub_rec_buttons.pop(fn, None)
sub_status_vars.pop(fn, None)
_save_eigene_tabs(eigene_tabs_data)
def _rename_eigene_tab():
if not sub_notebook.tabs():
return
idx = sub_notebook.index(sub_notebook.select())
if idx < 0 or idx >= len(eigene_tabs_data):
return
tab_info = eigene_tabs_data[idx]
new_title = simpledialog.askstring(
"Notiz umbenennen", f"Neuer Titel für «{tab_info['title']}»:",
parent=win, initialvalue=tab_info["title"],
)
if not new_title or not new_title.strip():
return
tab_info["title"] = new_title.strip()
sub_notebook.tab(idx, text=new_title.strip())
_save_eigene_tabs(eigene_tabs_data)
RoundedButton(
eigene_btn_bar, "+ Neue Notiz", command=_add_eigene_tab,
width=110, height=26, canvas_bg="#B9ECFA", bg="#A8D8B9", fg="#1a4d3d", active_bg="#8CC8A5",
).pack(side="left", padx=(0, 4))
RoundedButton(
eigene_btn_bar, " Entfernen", command=_remove_eigene_tab,
width=100, height=26, canvas_bg="#B9ECFA", bg="#E8B4B4", fg="#5A1A1A", active_bg="#D4A0A0",
).pack(side="left", padx=(0, 4))
RoundedButton(
eigene_btn_bar, "✏ Umbenennen", command=_rename_eigene_tab,
width=110, height=26, canvas_bg="#B9ECFA",
).pack(side="left", padx=(0, 4))
# ────────────────────────────────────────────
# Globale Buttons (unter dem Hauptnotebook)
# ────────────────────────────────────────────
global_btn_frame = tk.Frame(win, bg="#B9ECFA")
global_btn_frame.pack(fill="x", padx=8, pady=(0, 6))
def _reload_current():
main_idx = notebook.index(notebook.select())
if main_idx < len(_FIXED_TABS):
fn = _FIXED_TABS[main_idx][1]
tw = editors.get(fn)
if tw is None:
return
fpath = os.path.join(_NOTIZEN_DIR, fn)
content = _load_file(fpath)
try:
_set_widget_from_markdown(tw, content)
tw.edit_reset()
tw.edit_modified(False)
except Exception:
pass
else:
if not sub_notebook.tabs():
return
sub_idx = sub_notebook.index(sub_notebook.select())
if sub_idx < 0 or sub_idx >= len(eigene_tabs_data):
return
fn = eigene_tabs_data[sub_idx]["filename"]
tw = sub_editors.get(fn)
if tw is None:
return
fpath = os.path.join(_EIGENE_DIR, fn)
content = _load_file(fpath)
try:
_set_widget_from_markdown(tw, content)
tw.edit_reset()
tw.edit_modified(False)
except Exception:
pass
RoundedButton(
global_btn_frame, "↻ Neu laden", command=_reload_current,
width=100, height=28, canvas_bg="#B9ECFA",
).pack(side="left", padx=(0, 8))
def _save_all():
for fn, tw in editors.items():
try:
if not tw.winfo_exists():
continue
_save_file(os.path.join(_NOTIZEN_DIR, fn), _widget_to_markdown(tw))
except Exception:
pass
for fn, tw in sub_editors.items():
try:
if not tw.winfo_exists():
continue
_save_file(os.path.join(_EIGENE_DIR, fn), _widget_to_markdown(tw))
except Exception:
pass
RoundedButton(
global_btn_frame, "💾 Alle speichern", command=_save_all,
width=130, height=28, canvas_bg="#B9ECFA",
).pack(side="left")
def _on_notizen_close():
_save_all()
self._notizen_window = None
if hasattr(self, "_aza_windows"):
self._aza_windows.discard(win)
try:
save_toplevel_geometry("notizen", win.geometry())
except Exception:
pass
win.destroy()
win.protocol("WM_DELETE_WINDOW", _on_notizen_close)
def update_notizen_file(self, filename: str, append_text: str):
"""Von aussen aufrufbar: Text an eine Notiz-Datei anhängen."""
fpath = os.path.join(_NOTIZEN_DIR, filename)
os.makedirs(_NOTIZEN_DIR, exist_ok=True)
try:
existing = _load_file(fpath) or ""
with open(fpath, "w", encoding="utf-8") as f:
f.write(existing.rstrip("\n") + "\n" + append_text + "\n")
except Exception:
pass
def _bind_auto_save(win, txt_widget, filename, editors_dict, save_ids_dict, save_factory, save_label):
def _on_change(event=None):
aid = save_ids_dict.get(filename)
if aid is not None:
try:
win.after_cancel(aid)
except Exception:
pass
save_ids_dict[filename] = win.after(1500, save_factory(filename, save_label))
def _on_modified(event=None):
if txt_widget.edit_modified():
_on_change()
txt_widget.edit_modified(False)
txt_widget.bind("<<Modified>>", _on_modified)
txt_widget.bind("<KeyRelease>", _on_change)
def _try_paste_image_from_clipboard(txt_widget) -> bool:
"""Versucht ein Bild aus der Windows-Zwischenablage einzufuegen. Gibt True bei Erfolg."""
if not _HAS_PIL:
return False
import uuid
# --- Methode 1: Win32 CF_DIB direkt lesen (robustester Weg) ---
try:
import ctypes
from ctypes import wintypes
import io
import struct
CF_DIB = 8
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
user32.OpenClipboard.argtypes = [wintypes.HWND]
user32.OpenClipboard.restype = wintypes.BOOL
user32.GetClipboardData.argtypes = [wintypes.UINT]
user32.GetClipboardData.restype = wintypes.HANDLE
user32.CloseClipboard.restype = wintypes.BOOL
kernel32.GlobalLock.argtypes = [wintypes.HGLOBAL]
kernel32.GlobalLock.restype = ctypes.c_void_p
kernel32.GlobalUnlock.argtypes = [wintypes.HGLOBAL]
kernel32.GlobalSize.argtypes = [wintypes.HGLOBAL]
kernel32.GlobalSize.restype = ctypes.c_size_t
if user32.IsClipboardFormatAvailable(CF_DIB):
if user32.OpenClipboard(None):
try:
h = user32.GetClipboardData(CF_DIB)
if h:
ptr = kernel32.GlobalLock(h)
if ptr:
try:
size = kernel32.GlobalSize(h)
if size >= 40:
raw = ctypes.string_at(ptr, size)
hdr_size = struct.unpack_from("<I", raw, 0)[0]
bmp_file_hdr = struct.pack(
"<2sIHHI", b"BM",
14 + len(raw), 0, 0, 14 + hdr_size
)
img = Image.open(io.BytesIO(bmp_file_hdr + raw))
img = img.convert("RGBA")
fn = f"paste_{uuid.uuid4().hex[:8]}.png"
os.makedirs(_IMAGES_DIR, exist_ok=True)
img.save(os.path.join(_IMAGES_DIR, fn), "PNG")
_insert_image_at_cursor(txt_widget, fn)
return True
finally:
kernel32.GlobalUnlock(h)
finally:
user32.CloseClipboard()
except Exception:
pass
# --- Methode 2: PIL ImageGrab (Fallback) ---
try:
from PIL import ImageGrab
clip = ImageGrab.grabclipboard()
if clip is not None:
if isinstance(clip, Image.Image):
fn = f"paste_{uuid.uuid4().hex[:8]}.png"
os.makedirs(_IMAGES_DIR, exist_ok=True)
clip.save(os.path.join(_IMAGES_DIR, fn), "PNG")
_insert_image_at_cursor(txt_widget, fn)
return True
if isinstance(clip, list):
_IMG_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tiff"}
for fpath in clip:
if isinstance(fpath, str) and os.path.isfile(fpath):
if os.path.splitext(fpath)[1].lower() in _IMG_EXTS:
stored_fn = _copy_image_to_store(fpath)
if stored_fn:
_insert_image_at_cursor(txt_widget, stored_fn)
return True
except Exception:
pass
return False
def _enable_direct_paste(txt_widget):
"""Ermöglicht direktes Kopieren/Einfügen in Notizfelder (Tastatur + Rechtsklick)."""
_ensure_rich_tags(txt_widget)
def _toggle_bold(event=None):
try:
start = txt_widget.index("sel.first")
end = txt_widget.index("sel.last")
except Exception:
return "break"
idx = start
fully_bold = True
while txt_widget.compare(idx, "<", end):
if "rt_bold" not in txt_widget.tag_names(idx):
fully_bold = False
break
idx = f"{idx}+1c"
if fully_bold:
txt_widget.tag_remove("rt_bold", start, end)
else:
txt_widget.tag_add("rt_bold", start, end)
return "break"
def _copy_selection(event=None):
try:
sel = txt_widget.get("sel.first", "sel.last")
html_fragment = _selection_to_html_fragment(txt_widget)
except Exception:
return "break"
if sel:
if not _win_clipboard_set(sel, html_fragment=html_fragment):
try:
txt_widget.clipboard_clear()
txt_widget.clipboard_append(sanitize_markdown_for_plain_text(sel))
except Exception:
pass
return "break"
def _paste_from_clipboard(event=None):
if getattr(txt_widget, "_aza_images_enabled", False) and _HAS_PIL:
txt_widget.after(15, _deferred_smart_paste)
return "break"
_do_text_paste()
return "break"
def _do_text_paste():
try:
txt = txt_widget.clipboard_get()
try:
if txt_widget.tag_ranges("sel"):
txt_widget.delete("sel.first", "sel.last")
except Exception:
pass
_insert_markdown_as_rich_text(txt_widget, txt)
except Exception:
pass
def _deferred_smart_paste():
try:
if not txt_widget.winfo_exists():
return
except Exception:
return
pasted = _try_paste_image_from_clipboard(txt_widget)
if not pasted:
_do_text_paste()
def _show_context_menu(event):
try:
menu = tk.Menu(txt_widget, tearoff=0)
menu.add_command(label="Kopieren", command=lambda: _copy_selection())
menu.add_command(label="Einfügen", command=lambda: _paste_from_clipboard())
menu.add_command(label="Fett umschalten", command=lambda: _toggle_bold())
if getattr(txt_widget, "_aza_images_enabled", False) and _HAS_PIL:
menu.add_separator()
menu.add_command(label="Bild einfügen…", command=lambda: _image_dialog_insert(txt_widget))
menu.tk_popup(event.x_root, event.y_root)
finally:
try:
menu.grab_release()
except Exception:
pass
return "break"
txt_widget.bind("<Control-c>", _copy_selection)
txt_widget.bind("<Control-C>", _copy_selection)
txt_widget.bind("<Control-Insert>", _copy_selection)
txt_widget.bind("<Control-b>", _toggle_bold)
txt_widget.bind("<Control-B>", _toggle_bold)
txt_widget.bind("<Control-v>", _paste_from_clipboard)
txt_widget.bind("<Control-V>", _paste_from_clipboard)
txt_widget.bind("<Shift-Insert>", _paste_from_clipboard)
txt_widget.bind("<Button-3>", _show_context_menu)
def _auto_save(win, filename, label_widget, editors_dict, base_dir):
tw = editors_dict.get(filename)
if tw is None:
return
try:
if not tw.winfo_exists():
return
except Exception:
return
content = _widget_to_markdown(tw)
fpath = os.path.join(base_dir, filename)
try:
_save_file(fpath, content)
try:
if label_widget.winfo_exists():
label_widget.configure(text="gespeichert ✓", fg="#388E3C")
win.after(3000, lambda: _clear_label(label_widget))
except Exception:
pass
except Exception:
try:
if label_widget.winfo_exists():
label_widget.configure(text="Fehler beim Speichern", fg="#D32F2F")
except Exception:
pass
def _clear_label(lbl):
try:
if lbl.winfo_exists():
lbl.configure(text="")
except Exception:
pass