Refine member and asset detail layouts

This commit is contained in:
Marcel Peterkau
2026-06-27 10:36:03 +02:00
parent 87e972bb43
commit 04c23fbdf9
5 changed files with 452 additions and 52 deletions
+127 -22
View File
@@ -7,7 +7,9 @@ from tkinter import messagebox, ttk
from ccma.domain.models import ASSET_STATUS_LABELS, Asset, Event
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.dialogs import AssetClaimDialog
from ccma.ui.dialogs import AssetClaimDialog, IntegrityWarningDialog
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
from ccma.ui.scrolling import ScrollableFrame
class AssetTab(ttk.Frame):
@@ -40,18 +42,35 @@ class AssetTab(ttk.Frame):
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(1, weight=1)
self.rowconfigure(2, weight=1)
header = ttk.Frame(self)
header.grid(row=0, column=0, sticky="ew", pady=(0, 10))
header.columnconfigure(0, weight=1)
header.columnconfigure(1, weight=0)
self.title_var = tk.StringVar()
self.subtitle_var = tk.StringVar()
ttk.Label(header, textvariable=self.title_var, style="TabTitle.TLabel").grid(row=0, column=0, sticky="w")
ttk.Label(header, textvariable=self.subtitle_var, style="Mono.TLabel").grid(row=1, column=0, sticky="w")
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(row=0, column=1, rowspan=2, sticky="e")
self.id_var = tk.StringVar()
title_column = ttk.Frame(header)
title_column.grid(row=0, column=0, sticky="ew")
title_column.columnconfigure(0, weight=1)
ttk.Label(title_column, textvariable=self.title_var, style="TabTitle.TLabel").grid(
row=0, column=0, sticky="w"
)
ttk.Label(title_column, textvariable=self.subtitle_var, style="Mono.TLabel").grid(
row=1, column=0, sticky="w"
)
ttk.Label(title_column, textvariable=self.id_var, style="Mono.TLabel").grid(
row=2, column=0, sticky="w", pady=(3, 0)
)
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(
row=0, column=1, sticky="ne", padx=(12, 0)
)
self.messages = MessageBannerList(self)
self.messages.grid(row=1, column=0, sticky="ew", pady=(0, 10))
self.messages.grid_remove()
self.pane = ttk.Panedwindow(self, orient="horizontal")
self.pane.grid(row=1, column=0, sticky="nsew")
self.pane.grid(row=2, column=0, sticky="nsew")
self.details_pane = ttk.Frame(self.pane, padding=(0, 0, 10, 0))
self.timeline_pane = ttk.Frame(self.pane, padding=(10, 0, 0, 0))
self.pane.add(self.details_pane, weight=2)
@@ -74,15 +93,23 @@ class AssetTab(ttk.Frame):
def _build_details(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
parent.rowconfigure(0, weight=1)
parent.rowconfigure(1, weight=1)
ttk.Label(parent, text="// ASSET", style="TimelineHeader.TLabel").grid(
row=0, column=0, sticky="w", pady=(0, 8)
)
notebook = ttk.Notebook(parent)
notebook.grid(row=0, column=0, sticky="nsew")
data_tab = ttk.Frame(notebook, padding=16)
notebook.grid(row=1, column=0, sticky="nsew")
data_tab = self._create_form_tab(notebook, "Stammdaten")
actions = ttk.Frame(parent)
actions.grid(row=2, column=0, sticky="ew", pady=(10, 0))
actions.columnconfigure(0, weight=1)
action_buttons = ttk.Frame(actions)
action_buttons.grid(row=0, column=0, sticky="e")
finance_tab = ttk.Frame(notebook, padding=16)
notebook.add(data_tab, text="Stammdaten")
notebook.add(finance_tab, text="Forderungen")
fields = [
("UUID / Ordner-ID", "asset_id"),
("Bezeichnung", "label"),
("Kategorie", "category"),
("Inventarnummer", "inventory_number"),
@@ -94,6 +121,8 @@ class AssetTab(ttk.Frame):
ttk.Label(data_tab, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12))
entry = ttk.Entry(data_tab, textvariable=self.variables[key], width=42)
entry.grid(row=row, column=1, sticky="ew", pady=5)
if key == "asset_id":
entry.configure(state="readonly")
if key == "deposit_amount_default":
self.deposit_entry = entry
self.variables["status"] = tk.StringVar()
@@ -108,23 +137,48 @@ class AssetTab(ttk.Frame):
)
self.status_box.grid(row=holder_row, column=1, sticky="ew", pady=5)
self.holder_var = tk.StringVar()
ttk.Label(data_tab, text="Aktueller Halter").grid(row=holder_row + 1, column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Label(data_tab, textvariable=self.holder_var, style="TimelineHeader.TLabel").grid(
row=holder_row + 1, column=1, sticky="w", pady=5
ttk.Label(data_tab, text="Aktueller Halter").grid(
row=holder_row + 1,
column=0,
sticky="w",
pady=5,
padx=(0, 12),
)
self.holder_label = ttk.Label(
data_tab,
textvariable=self.holder_var,
style="TimelineHeader.TLabel",
)
self.holder_label.grid(row=holder_row + 1, column=1, sticky="w", pady=5)
self.holder_label.bind("<Button-1>", lambda _event: self._open_holder_member(), add="+")
ttk.Label(data_tab, text="Interne Notiz").grid(
row=holder_row + 2,
column=0,
sticky="nw",
pady=5,
padx=(0, 12),
)
ttk.Label(data_tab, text="Interne Notiz").grid(row=holder_row + 2, column=0, sticky="nw", pady=5, padx=(0, 12))
self.notes_text = tk.Text(data_tab, width=42, height=6, wrap="word")
self.notes_text.grid(row=holder_row + 2, column=1, sticky="ew", pady=5)
data_tab.columnconfigure(1, weight=1)
actions = ttk.Frame(data_tab)
actions.grid(row=holder_row + 3, column=1, sticky="e", pady=(16, 0))
self.open_member_button = ttk.Button(actions, text="Mitglied öffnen", command=self._open_holder_member)
self.open_member_button.pack(side="left", padx=(0, 8))
self.issue_button = ttk.Button(actions, text="Ausgeben", command=lambda: self.on_issue_asset(self.asset_id))
self.issue_button = ttk.Button(
action_buttons,
text="Ausgeben",
command=lambda: self.on_issue_asset(self.asset_id),
)
self.issue_button.pack(side="left", padx=(0, 8))
self.return_button = ttk.Button(actions, text="Zurücknehmen", command=lambda: self.on_return_asset(self.asset_id))
self.return_button = ttk.Button(
action_buttons,
text="Zurücknehmen",
command=lambda: self.on_return_asset(self.asset_id),
)
self.return_button.pack(side="left", padx=(0, 8))
ttk.Button(actions, text="Speichern", style="Accent.TButton", command=self._save).pack(side="left")
ttk.Button(
action_buttons,
text="Speichern",
style="Accent.TButton",
command=self._save,
).pack(side="left")
finance_tab.columnconfigure(0, weight=1)
self.finance_summary_var = tk.StringVar()
@@ -157,6 +211,15 @@ class AssetTab(ttk.Frame):
self.asset_claims.bind("<Double-1>", lambda _event: self._open_selected_asset_claim())
self.asset_claims.bind("<Return>", lambda _event: self._open_selected_asset_claim())
def _create_form_tab(self, notebook: ttk.Notebook, title: str) -> ttk.Frame:
tab = ttk.Frame(notebook)
tab.columnconfigure(0, weight=1)
tab.rowconfigure(0, weight=1)
scroller = ScrollableFrame(tab, padding=16)
scroller.grid(row=0, column=0, sticky="nsew")
notebook.add(tab, text=title)
return scroller.content
def _build_timeline(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
parent.rowconfigure(1, weight=1)
@@ -186,6 +249,20 @@ class AssetTab(ttk.Frame):
holder = self._holder_label()
status = ASSET_STATUS_LABELS.get(self.asset.status, self.asset.status.upper())
self.subtitle_var.set(f"{self.asset.inventory_number or ''} · {status} · {holder}")
self.id_var.set(f"UUID: {self.asset.asset_id}")
warnings = self.repository.asset_hash_warnings(self.asset_id)
self.messages.set_messages(
[
TabMessage(
"warning",
"WARNUNG: " + " | ".join(warnings),
MessageAction("Überprüft, bestätigen", self._confirm_integrity_banner),
)
]
if warnings
else []
)
self.variables["asset_id"].set(self.asset.asset_id)
self.variables["label"].set(self.asset.label)
self.variables["category"].set(self.asset.category)
self.variables["inventory_number"].set(self.asset.inventory_number)
@@ -197,7 +274,7 @@ class AssetTab(ttk.Frame):
self.notes_text.insert("1.0", self.asset.notes)
self.holder_var.set(holder)
issued = bool(self.asset.current_holder_member_id)
self.open_member_button.configure(state="normal" if issued else "disabled")
self.holder_label.configure(cursor="hand2" if issued else "")
self.issue_button.configure(state="normal" if self.asset.status == "available" else "disabled")
self.return_button.configure(state="normal" if issued else "disabled")
self.status_box.configure(state="disabled" if issued else "readonly")
@@ -255,6 +332,13 @@ class AssetTab(ttk.Frame):
)
def _save(self) -> None:
warnings = self.repository.asset_hash_warnings(self.asset_id)
if warnings:
self._confirm_integrity_and_then(self._save_confirmed)
return
self._save_confirmed()
def _save_confirmed(self) -> None:
self.asset.label = self.variables["label"].get().strip()
self.asset.category = self.variables["category"].get().strip()
self.asset.inventory_number = self.variables["inventory_number"].get().strip()
@@ -265,12 +349,33 @@ class AssetTab(ttk.Frame):
self.asset.status = _asset_status_key(self.variables["status"].get())
try:
self.repository.save_asset(self.asset)
self.repository.refresh_asset_record_hashes(self.asset_id)
except RepositoryError as exc:
messagebox.showerror("Asset konnte nicht gespeichert werden", str(exc), parent=self)
return
self.refresh()
self.on_changed()
def _confirm_integrity_banner(self) -> None:
self._confirm_integrity_and_then(self._refresh_hashes_only)
def _refresh_hashes_only(self) -> None:
self.repository.refresh_asset_record_hashes(self.asset_id)
self.refresh()
self.on_changed()
def _confirm_integrity_and_then(self, callback: Callable[[], None]) -> None:
warnings = self.repository.asset_hash_warnings(self.asset_id)
if not warnings:
callback()
return
IntegrityWarningDialog(
self,
title="Externe Änderungen bestätigen",
warnings=warnings,
on_confirm=callback,
)
def _add_comment(self) -> None:
text = self.comment_var.get().strip()
if not text:
+143 -30
View File
@@ -12,8 +12,11 @@ from ccma.domain.models import ASSET_STATUS_LABELS, MEMBERSHIP_STATUS_LABELS as
from ccma.domain.models import Event
from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.document_dialog import DocumentTemplateDialog
from ccma.ui.dialogs import IntegrityWarningDialog
from ccma.ui.file_open import open_path
from ccma.ui.labels import display_label, storage_key
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
from ccma.ui.scrolling import ScrollableFrame
CLAIM_TABLE_COLUMNS = (
@@ -61,30 +64,46 @@ class MemberTab(ttk.Frame):
self.on_return_asset = on_return_asset
self.member = repository.get_member(member_id)
self.variables: dict[str, tk.Variable] = {}
self._field_sections: dict[str, str] = {}
self._dirty_sections: set[str] = set()
self._form_tabs: dict[str, str] = {}
self._form_tab_titles: dict[str, str] = {}
self._loading = False
self.notes_text: tk.Text | None = None
self._build_ui()
self.refresh()
def _build_ui(self) -> None:
self.columnconfigure(0, weight=1)
self.rowconfigure(1, weight=1)
self.rowconfigure(2, weight=1)
header = ttk.Frame(self)
header.grid(row=0, column=0, sticky="ew", pady=(0, 10))
header.columnconfigure(0, weight=1)
header.columnconfigure(1, weight=0)
self.title_var = tk.StringVar()
self.status_var = tk.StringVar()
ttk.Label(header, textvariable=self.title_var, style="TabTitle.TLabel").grid(
self.id_var = tk.StringVar()
title_column = ttk.Frame(header)
title_column.grid(row=0, column=0, sticky="ew")
title_column.columnconfigure(0, weight=1)
ttk.Label(title_column, textvariable=self.title_var, style="TabTitle.TLabel").grid(
row=0, column=0, sticky="w"
)
ttk.Label(header, textvariable=self.status_var, style="Mono.TLabel").grid(
ttk.Label(title_column, textvariable=self.status_var, style="Mono.TLabel").grid(
row=1, column=0, sticky="w", pady=(3, 0)
)
ttk.Button(header, text="Tab schließen", command=self.on_close).grid(
row=0, column=1, rowspan=2, sticky="e"
ttk.Label(title_column, textvariable=self.id_var, style="Mono.TLabel").grid(
row=2, column=0, sticky="w", pady=(3, 0)
)
ttk.Button(header, text="Tab schließen", command=self._close).grid(
row=0, column=1, sticky="ne", padx=(12, 0)
)
self.messages = MessageBannerList(self)
self.messages.grid(row=1, column=0, sticky="ew", pady=(0, 10))
self.messages.grid_remove()
self.pane = ttk.Panedwindow(self, orient="horizontal")
self.pane.grid(row=1, column=0, sticky="nsew")
self.pane.grid(row=2, column=0, sticky="nsew")
self.details_pane = ttk.Frame(self.pane, padding=(0, 0, 10, 0))
self.timeline_pane = ttk.Frame(self.pane, padding=(10, 0, 0, 0))
self.pane.add(self.details_pane, weight=2)
@@ -107,23 +126,34 @@ class MemberTab(ttk.Frame):
def _build_details(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
parent.rowconfigure(0, weight=1)
parent.rowconfigure(1, weight=1)
ttk.Label(parent, text="// MITGLIED", style="TimelineHeader.TLabel").grid(
row=0, column=0, sticky="w", pady=(0, 8)
)
notebook = ttk.Notebook(parent)
notebook.grid(row=0, column=0, sticky="nsew")
data_tab = ttk.Frame(notebook, padding=16)
address_tab = ttk.Frame(notebook, padding=16)
banking_tab = ttk.Frame(notebook, padding=16)
self.details_notebook = notebook
notebook.grid(row=1, column=0, sticky="nsew")
data_tab = self._create_form_tab(notebook, "data", "Stammdaten")
address_tab = self._create_form_tab(notebook, "address", "Anschrift")
banking_tab = self._create_form_tab(notebook, "banking", "Bank / SEPA")
actions = ttk.Frame(parent)
actions.grid(row=2, column=0, sticky="ew", pady=(10, 0))
actions.columnconfigure(0, weight=1)
ttk.Button(
actions,
text="Mitgliedsdaten speichern",
style="Accent.TButton",
command=self._save,
).grid(row=0, column=0, sticky="e")
contribution_tab = ttk.Frame(notebook, padding=16)
assets_tab = ttk.Frame(notebook, padding=16)
documents_tab = ttk.Frame(notebook, padding=16)
notebook.add(data_tab, text="Stammdaten")
notebook.add(address_tab, text="Anschrift")
notebook.add(banking_tab, text="Bank / SEPA")
notebook.add(contribution_tab, text="Forderungen")
notebook.add(assets_tab, text="Assets")
notebook.add(documents_tab, text="Dokumente")
fields = [
("UUID / Ordner-ID", "member_id"),
("Mitgliedsnummer", "member_number"),
("Vorname", "first_name"),
("Nachname", "last_name"),
@@ -136,7 +166,7 @@ class MemberTab(ttk.Frame):
]
for row, (label, key) in enumerate(fields):
variable = tk.StringVar()
self.variables[key] = variable
self._add_variable(key, variable, "data")
ttk.Label(data_tab, text=label).grid(row=row, column=0, sticky="w", pady=5, padx=(0, 12))
if key == "birth_date":
birth_row = ttk.Frame(data_tab)
@@ -151,11 +181,11 @@ class MemberTab(ttk.Frame):
"write", lambda *_args, source=variable: self.age_var.set(age_label(source.get()))
)
else:
entry_state = "readonly" if key == "member_number" else "normal"
entry_state = "readonly" if key in {"member_id", "member_number"} else "normal"
ttk.Entry(data_tab, textvariable=variable, width=42, state=entry_state).grid(
row=row, column=1, sticky="ew", pady=5
)
self.variables["status"] = tk.StringVar()
self._add_variable("status", tk.StringVar(), "data")
ttk.Label(data_tab, text="Status").grid(row=len(fields), column=0, sticky="w", pady=5, padx=(0, 12))
ttk.Combobox(
data_tab,
@@ -169,10 +199,8 @@ class MemberTab(ttk.Frame):
)
self.notes_text = tk.Text(data_tab, width=42, height=6, wrap="word")
self.notes_text.grid(row=len(fields) + 1, column=1, sticky="ew", pady=5)
self.notes_text.bind("<<Modified>>", lambda _event: self._mark_dirty_from_text("data"), add="+")
data_tab.columnconfigure(1, weight=1)
ttk.Button(data_tab, text="Änderungen speichern", style="Accent.TButton", command=self._save).grid(
row=len(fields) + 2, column=1, sticky="e", pady=(18, 0)
)
address_fields = (
("Straße und Hausnummer", "street"),
@@ -183,14 +211,11 @@ class MemberTab(ttk.Frame):
)
address_tab.columnconfigure(1, weight=1)
for row, (label, key) in enumerate(address_fields):
self.variables[key] = tk.StringVar()
self._add_variable(key, tk.StringVar(), "address")
ttk.Label(address_tab, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5)
ttk.Entry(address_tab, textvariable=self.variables[key], width=42).grid(
row=row, column=1, sticky="ew", pady=5
)
ttk.Button(
address_tab, text="Anschrift speichern", style="Accent.TButton", command=self._save
).grid(row=len(address_fields), column=1, sticky="e", pady=(18, 0))
banking_fields = (
("Kontoinhaber", "account_holder"),
@@ -202,22 +227,18 @@ class MemberTab(ttk.Frame):
)
banking_tab.columnconfigure(1, weight=1)
for row, (label, key) in enumerate(banking_fields):
self.variables[key] = tk.StringVar()
self._add_variable(key, tk.StringVar(), "banking")
ttk.Label(banking_tab, text=label).grid(row=row, column=0, sticky="w", padx=(0, 12), pady=5)
ttk.Entry(banking_tab, textvariable=self.variables[key], width=42).grid(
row=row, column=1, sticky="ew", pady=5
)
self.variables["mandate_active"] = tk.BooleanVar()
self._add_variable("mandate_active", tk.BooleanVar(), "banking")
ttk.Checkbutton(
banking_tab,
text="SEPA-Lastschriftmandat ist aktiv",
variable=self.variables["mandate_active"],
style="Switch",
).grid(row=len(banking_fields), column=0, columnspan=2, sticky="w", pady=(12, 5))
ttk.Button(
banking_tab, text="Bankdaten speichern", style="Accent.TButton", command=self._save
).grid(row=len(banking_fields) + 1, column=1, sticky="e", pady=(18, 0))
contribution_tab.columnconfigure(0, weight=1)
contribution_tab.rowconfigure(1, weight=1)
self.contribution_summary = tk.StringVar()
@@ -308,6 +329,53 @@ class MemberTab(ttk.Frame):
self.documents.bind("<Return>", lambda _event: self._open_selected_document())
self.document_paths: dict[str, Path] = {}
def _create_form_tab(self, notebook: ttk.Notebook, section: str, title: str) -> ttk.Frame:
tab = ttk.Frame(notebook)
tab.columnconfigure(0, weight=1)
tab.rowconfigure(0, weight=1)
scroller = ScrollableFrame(tab, padding=16)
scroller.grid(row=0, column=0, sticky="nsew")
notebook.add(tab, text=title)
self._form_tabs[section] = str(tab)
self._form_tab_titles[section] = title
return scroller.content
def _add_variable(self, key: str, variable: tk.Variable, section: str) -> None:
self.variables[key] = variable
self._field_sections[key] = section
variable.trace_add("write", lambda *_args, target=section: self._mark_dirty(target))
def _mark_dirty(self, section: str) -> None:
if self._loading:
return
self._dirty_sections.add(section)
self._refresh_dirty_tabs()
def _mark_dirty_from_text(self, section: str) -> None:
if self.notes_text is None or not self.notes_text.edit_modified():
return
self.notes_text.edit_modified(False)
self._mark_dirty(section)
def _refresh_dirty_tabs(self) -> None:
for section, tab_id in self._form_tabs.items():
title = self._form_tab_titles[section]
suffix = " *" if section in self._dirty_sections else ""
self.details_notebook.tab(tab_id, text=f"{title}{suffix}")
def _clear_dirty(self) -> None:
self._dirty_sections.clear()
self._refresh_dirty_tabs()
def _close(self) -> None:
if self._dirty_sections and not messagebox.askokcancel(
"Ungespeicherte Änderungen",
"Es gibt ungespeicherte Änderungen an den Mitgliedsdaten. Tab trotzdem schließen?",
parent=self,
):
return
self.on_close()
def _build_timeline(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
parent.rowconfigure(1, weight=1)
@@ -332,9 +400,23 @@ class MemberTab(ttk.Frame):
ttk.Button(compose, text="Kommentar", command=self._add_comment).grid(row=0, column=1)
def refresh(self) -> None:
self._loading = True
self.member = self.repository.get_member(self.member_id)
self.title_var.set(f"{self.member.member_number or ''} · {self.member.display_name}")
self.status_var.set(STATUS_LABELS.get(self.member.status, self.member.status.upper()))
self.id_var.set(f"UUID: {self.member.member_id}")
warnings = self.repository.member_hash_warnings(self.member_id)
self.messages.set_messages(
[
TabMessage(
"warning",
"WARNUNG: " + " | ".join(warnings),
MessageAction("Überprüft, bestätigen", self._confirm_integrity_banner),
)
]
if warnings
else []
)
date_fields = {
"birth_date",
"accepted_at",
@@ -351,6 +433,9 @@ class MemberTab(ttk.Frame):
if self.notes_text is not None:
self.notes_text.delete("1.0", "end")
self.notes_text.insert("1.0", self.member.notes)
self.notes_text.edit_modified(False)
self._loading = False
self._clear_dirty()
self._refresh_events()
self._refresh_contributions()
self._refresh_assets()
@@ -460,6 +545,13 @@ class MemberTab(ttk.Frame):
)
def _save(self) -> None:
warnings = self.repository.member_hash_warnings(self.member_id)
if warnings:
self._confirm_integrity_and_then(self._save_confirmed)
return
self._save_confirmed()
def _save_confirmed(self) -> None:
for key, variable in self.variables.items():
raw_value = variable.get()
value = raw_value.strip() if isinstance(raw_value, str) else raw_value
@@ -470,12 +562,33 @@ class MemberTab(ttk.Frame):
self.member.notes = self.notes_text.get("1.0", "end-1c").strip()
try:
self.repository.save_member(self.member)
self.repository.refresh_member_record_hashes(self.member_id)
except RepositoryError as exc:
messagebox.showerror("Speichern fehlgeschlagen", str(exc), parent=self)
return
self.refresh()
self.on_changed()
def _confirm_integrity_banner(self) -> None:
self._confirm_integrity_and_then(self._refresh_hashes_only)
def _refresh_hashes_only(self) -> None:
self.repository.refresh_member_record_hashes(self.member_id)
self.refresh()
self.on_changed()
def _confirm_integrity_and_then(self, callback: Callable[[], None]) -> None:
warnings = self.repository.member_hash_warnings(self.member_id)
if not warnings:
callback()
return
IntegrityWarningDialog(
self,
title="Externe Änderungen bestätigen",
warnings=warnings,
on_confirm=callback,
)
def _add_comment(self) -> None:
text = self.comment_var.get().strip()
if not text:
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
import tkinter as tk
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from tkinter import ttk
MessageType = str
@dataclass(frozen=True)
class MessageAction:
label: str
callback: Callable[[], None]
@dataclass(frozen=True)
class TabMessage:
message_type: MessageType
text: str
action: MessageAction | None = None
_TYPE_STYLES = {
"error": "Error",
"notification": "Notification",
"warning": "Warning",
"info": "Info",
}
class MessageBannerList(ttk.Frame):
def __init__(self, master: tk.Misc):
super().__init__(master)
self.columnconfigure(0, weight=1)
self._messages: list[TabMessage] = []
def set_messages(self, messages: Iterable[TabMessage]) -> None:
self._messages = [message for message in messages if message.text.strip()]
self._render()
if self._messages:
self.grid()
else:
self.grid_remove()
def _render(self) -> None:
for child in self.winfo_children():
child.destroy()
for row_index, message in enumerate(self._messages):
style_name = _message_style_name(message.message_type)
banner = ttk.Frame(self, style=f"Message{style_name}Border.TFrame", padding=1)
banner.grid(row=row_index, column=0, sticky="ew", pady=(0, 6))
banner.columnconfigure(0, weight=1)
body = ttk.Frame(banner, style=f"Message{style_name}.TFrame", padding=(10, 8))
body.grid(row=0, column=0, sticky="ew")
body.columnconfigure(0, weight=1)
label = ttk.Label(
body,
text=message.text,
style=f"Message{style_name}.TLabel",
wraplength=900,
justify="left",
)
label.grid(row=0, column=0, sticky="ew")
body.bind(
"<Configure>",
lambda event, target=label: target.configure(wraplength=max(240, event.width - 180)),
add="+",
)
if message.action is not None:
ttk.Button(
body,
text=message.action.label,
command=message.action.callback,
).grid(row=0, column=1, sticky="e", padx=(12, 0))
def _message_style_name(message_type: MessageType) -> str:
return _TYPE_STYLES.get(message_type.casefold(), "Info")
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import tkinter as tk
from tkinter import ttk
class ScrollableFrame(ttk.Frame):
def __init__(self, master: tk.Misc, *, padding: int | tuple[int, ...] = 0):
super().__init__(master)
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
self.canvas = tk.Canvas(self, highlightthickness=0, borderwidth=0)
self.scrollbar = ttk.Scrollbar(self, orient="vertical", command=self.canvas.yview)
self.content = ttk.Frame(self.canvas, padding=padding)
self._content_window = self.canvas.create_window((0, 0), window=self.content, anchor="nw")
self.canvas.configure(yscrollcommand=self.scrollbar.set)
self.canvas.grid(row=0, column=0, sticky="nsew")
self.content.bind("<Configure>", self._update_scrollregion, add="+")
self.canvas.bind("<Configure>", self._fit_content_width, add="+")
self.bind("<Enter>", self._bind_mousewheel, add="+")
self.bind("<Leave>", self._unbind_mousewheel, add="+")
self.content.bind("<Enter>", self._bind_mousewheel, add="+")
self.content.bind("<Leave>", self._unbind_mousewheel, add="+")
self.after_idle(self._apply_canvas_background)
def _apply_canvas_background(self) -> None:
background = ttk.Style(self).lookup("TFrame", "background")
if background:
self.canvas.configure(background=background)
def _update_scrollregion(self, _event: tk.Event | None = None) -> None:
self.canvas.configure(scrollregion=self.canvas.bbox("all"))
self._sync_scrollbar()
def _fit_content_width(self, event: tk.Event) -> None:
self.canvas.itemconfigure(self._content_window, width=event.width)
self._sync_scrollbar()
def _sync_scrollbar(self) -> None:
bounds = self.canvas.bbox("all")
if not bounds:
self.scrollbar.grid_remove()
return
content_height = bounds[3] - bounds[1]
canvas_height = self.canvas.winfo_height()
if canvas_height > 1 and content_height > canvas_height:
self.scrollbar.grid(row=0, column=1, sticky="ns")
else:
self.scrollbar.grid_remove()
def _bind_mousewheel(self, _event: tk.Event | None = None) -> None:
self.canvas.bind_all("<MouseWheel>", self._on_mousewheel, add="+")
self.canvas.bind_all("<Button-4>", self._on_mousewheel, add="+")
self.canvas.bind_all("<Button-5>", self._on_mousewheel, add="+")
def _unbind_mousewheel(self, _event: tk.Event | None = None) -> None:
self.canvas.unbind_all("<MouseWheel>")
self.canvas.unbind_all("<Button-4>")
self.canvas.unbind_all("<Button-5>")
def _on_mousewheel(self, event: tk.Event) -> None:
if getattr(event, "num", None) == 4:
delta = -1
elif getattr(event, "num", None) == 5:
delta = 1
else:
delta = -1 * int(getattr(event, "delta", 0) / 120)
self.canvas.yview_scroll(delta, "units")
+34
View File
@@ -47,6 +47,28 @@ def _configure_ccma_styles(style: ttk.Style, variant: str) -> None:
accent = "#00d084" if dark else "#087f5b"
warning = "#ffb454"
danger = "#ff6b6b"
message_styles = {
"Error": (
"#5c2528" if dark else "#d04242",
"#3a1719" if dark else "#fde8e8",
"#ffd7d7" if dark else "#8a1f1f",
),
"Warning": (
"#6c4b19" if dark else "#d69b19",
"#3a2a12" if dark else "#fff4cc",
"#ffe2a8" if dark else "#7a4f00",
),
"Info": (
"#204f79" if dark else "#5b9bd8",
"#132c45" if dark else "#e5f1ff",
"#cde6ff" if dark else "#174a7c",
),
"Notification": (
"#1f5a3a" if dark else "#57ad75",
"#123222" if dark else "#e4f7ec",
"#c9f2dc" if dark else "#1f6b3d",
),
}
style.configure("Ribbon.TFrame", padding=(12, 9))
style.configure("AppTitle.TLabel", font=("TkDefaultFont", 14, "bold"))
style.configure("TabTitle.TLabel", font=("TkDefaultFont", 15, "bold"))
@@ -75,3 +97,15 @@ def _configure_ccma_styles(style: ttk.Style, variant: str) -> None:
fieldbackground=background,
foreground=foreground,
)
for name, (message_border, message_background, message_foreground) in message_styles.items():
style.configure(f"Message{name}Border.TFrame", background=message_border)
style.configure(
f"Message{name}.TFrame",
background=message_background,
)
style.configure(
f"Message{name}.TLabel",
background=message_background,
foreground=message_foreground,
font=("TkDefaultFont", 10),
)