mirror of
https://git.srhtnin.garden/sarah/SaraTag.git
synced 2026-10-13 18:28:16 +02:00
237 lines
11 KiB
Python
237 lines
11 KiB
Python
"""The tag panel on the Browse page: every tag of the selected file(s), each
|
|
with a control that fits its Type. Changes are written to the Books straight
|
|
away. With several files selected, only tags their Lexicons share are shown,
|
|
and tags whose values differ show as mixed until set."""
|
|
|
|
from typing import Any, Callable
|
|
|
|
import flet as ft
|
|
|
|
from Books import BuildSelection, Book, InitialValue, Mixed, ParseNumber, SetTagValue, ValidateValue
|
|
from Core import Note, Palette
|
|
from Library import Bound, DataError, FormatValue, TagType, Unset
|
|
from Widgets import App, Plural, TextInput
|
|
|
|
MixedText = "(mixed)"
|
|
|
|
|
|
class TagEditor(ft.Column):
|
|
def __init__(self, TheApp: App, OnChanged: Callable[[list[Book]], None]):
|
|
super().__init__(spacing=10, scroll=ft.ScrollMode.AUTO, expand=True)
|
|
self.App = TheApp
|
|
self.OnChanged = OnChanged
|
|
self.Books: list[Book] = []
|
|
self.ShowBooks([], Update=False)
|
|
|
|
def ShowBooks(self, Books: list[Book], Header: str = "", Update: bool = True) -> None:
|
|
self.Books = Books
|
|
self.Selection = BuildSelection(Books)
|
|
self.controls = self._Build(Header)
|
|
if Update:
|
|
self.update()
|
|
|
|
def _Build(self, Header: str) -> list[ft.Control]:
|
|
if not self.Books:
|
|
return [Note(Header or "Select a file with a Book to edit its tags.")]
|
|
Controls: list[ft.Control] = []
|
|
if Header:
|
|
Controls.append(ft.Text(Header, color=Palette.Mauve, size=14, max_lines=2, overflow=ft.TextOverflow.ELLIPSIS))
|
|
Errors = {TheBook.Error for TheBook in self.Books if TheBook.Error}
|
|
for Error in Errors:
|
|
Controls.append(ft.Row([ft.Icon(ft.Icons.WARNING_AMBER, color=Palette.Red, size=16),
|
|
ft.Text(Error, color=Palette.Red, size=12, expand=True)]))
|
|
if not self.Selection.Definitions:
|
|
if len(self.Books) > 1 and not Errors:
|
|
Controls.append(Note("These files' Lexicons share no tags."))
|
|
for Name, Definition in self.Selection.Definitions.items():
|
|
Controls.append(self._TagRow(Name, Definition, self.Selection.Values.get(Name, Unset)))
|
|
if self.Selection.Extra:
|
|
Controls.append(ft.Divider(height=1, color=Palette.Surface1))
|
|
Controls.append(Note("Not in the Lexicon (Clean unused tags on the Update page removes them):"))
|
|
for Name, Value in self.Selection.Extra.items():
|
|
Controls.append(ft.Row([ft.Text(Name, size=12, color=Palette.Overlay0, width=110),
|
|
ft.Text(FormatValue(Value), size=12, selectable=True, expand=True)]))
|
|
return Controls
|
|
|
|
# One tag
|
|
|
|
def _TagRow(self, Name: str, Definition: dict[str, Any], Value: Any) -> ft.Control:
|
|
Kind = TagType(Definition)
|
|
Required = bool(Definition.get("Required"))
|
|
Label = ft.Text(Name + (" *" if Required else ""), size=12, color=Palette.Mauve if Required else Palette.Text,
|
|
tooltip=(Definition.get("Description") or None))
|
|
Problem = None if Value is Mixed else ValidateValue(Name, Value, Definition)
|
|
|
|
if Definition.get("ForceDefault"):
|
|
Editor: ft.Control = ft.Row([
|
|
ft.Icon(ft.Icons.LOCK_OUTLINE, size=14, color=Palette.Overlay0),
|
|
ft.Text(FormatValue(Definition.get("Default"), Definition), size=13),
|
|
], tooltip="ForceDefault: every update sets this to its Default.")
|
|
elif Kind == "Boolean":
|
|
Editor = self._Boolean(Name, Definition, Value)
|
|
elif Kind == "List":
|
|
Editor = self._List(Name, Definition, Value)
|
|
elif Kind == "Rating":
|
|
Editor = self._Rating(Name, Definition, Value)
|
|
elif Kind in ("Number", "Text"):
|
|
Editor = self._Field(Name, Definition, Value, Kind)
|
|
else:
|
|
Editor = ft.Text(f"Unknown type {Definition.get('Type')!r}: {FormatValue(Value)}", color=Palette.Red, size=12)
|
|
|
|
Reset = ft.IconButton(
|
|
ft.Icons.RESTART_ALT, icon_size=16, icon_color=Palette.Overlay0, tooltip="Back to its default",
|
|
on_click=lambda e: self._Set(Name, InitialValue(Definition)),
|
|
visible=not Definition.get("ForceDefault"),
|
|
)
|
|
Rows: list[ft.Control] = [ft.Row([Label, ft.Container(expand=True), Reset], height=24)]
|
|
Rows.append(Editor)
|
|
if Problem:
|
|
Rows.append(ft.Text(Problem, size=11, color=Palette.Red))
|
|
return ft.Column(Rows, spacing=2)
|
|
|
|
def _Set(self, Name: str, Value: Any) -> None:
|
|
Definition = self.Selection.Definitions[Name]
|
|
Problem = ValidateValue(Name, Value, Definition)
|
|
if Problem and not (Value == Unset and Definition.get("Required")):
|
|
self.App.Notify(Problem, "error")
|
|
self.ShowBooks(self.Books, self._Header())
|
|
return
|
|
try:
|
|
Changed = SetTagValue(self.Books, Name, Value)
|
|
except DataError as Error:
|
|
self.App.Notify(str(Error), "error")
|
|
return
|
|
if Changed and len(self.Books) > 1:
|
|
self.App.Notify(f"Set {Name} on {Plural(Changed, 'file')}.", "success")
|
|
self.ShowBooks(self.Books, self._Header())
|
|
if Changed:
|
|
self.OnChanged(self.Books)
|
|
|
|
def _Header(self) -> str:
|
|
return self.Books[0].FilePath.name if len(self.Books) == 1 else f"{len(self.Books)} files selected"
|
|
|
|
def _Boolean(self, Name: str, Definition: dict[str, Any], Value: Any) -> ft.Control:
|
|
Known = isinstance(Value, bool)
|
|
return ft.Checkbox(
|
|
label="Yes" if Value is True else "No" if Value is False else MixedText if Value is Mixed else "Not set",
|
|
value=Value if Known else None,
|
|
tristate=not Known,
|
|
on_change=lambda e: self._Set(Name, not Value if Known else True),
|
|
)
|
|
|
|
def _List(self, Name: str, Definition: dict[str, Any], Value: Any) -> ft.Control:
|
|
Values = [Item for Item in Definition.get("Values", []) if isinstance(Item, (str, int, float))]
|
|
Options = [ft.DropdownOption(key=str(Item), text=str(Item)) for Item in Values]
|
|
if not Definition.get("Required"):
|
|
Options.insert(0, ft.DropdownOption(key=Unset, text="? (not set)"))
|
|
Current = str(Value) if Value is not Mixed and (Value in Values or Value == Unset) else None
|
|
ByKey = {str(Item): Item for Item in Values} | {Unset: Unset}
|
|
return ft.Dropdown(
|
|
value=Current, options=Options, dense=True, width=260,
|
|
hint_text=MixedText if Value is Mixed else (FormatValue(Value) if Current is None else None),
|
|
on_select=lambda e: self._Set(Name, ByKey.get(e.control.value, Unset)),
|
|
)
|
|
|
|
def _Rating(self, Name: str, Definition: dict[str, Any], Value: Any) -> ft.Control:
|
|
Minimum = Bound(Definition, "Minimum")
|
|
Minimum = Minimum if isinstance(Minimum, int) and Minimum >= 0 else 0
|
|
Maximum = Bound(Definition, "Maximum")
|
|
Filled = Definition.get("FilledSymbol") or "★"
|
|
Empty = Definition.get("EmptySymbol") or ""
|
|
Current = Value if isinstance(Value, int) and not isinstance(Value, bool) else None
|
|
Controls: list[ft.Control] = []
|
|
if isinstance(Maximum, int) and 0 < Maximum <= 20:
|
|
for Point in range(1, Maximum + 1):
|
|
Lit = Current is not None and Point <= Current
|
|
Symbol = Filled if Lit or not Empty else Empty
|
|
Controls.append(ft.Container(
|
|
ft.Text(Symbol, size=20, color=Palette.Mauve if Lit else Palette.Surface1),
|
|
on_click=lambda e, Point=Point: self._Set(Name, max(Minimum, Point - 1) if Current == Point else max(Minimum, Point)),
|
|
tooltip=f"{Point}", padding=ft.Padding.symmetric(horizontal=1),
|
|
))
|
|
else: # no sensible Maximum: a number with - and +
|
|
Controls += [
|
|
ft.IconButton(ft.Icons.REMOVE, icon_size=16,
|
|
on_click=lambda e: self._Set(Name, max(Minimum, (Current or Minimum) - 1))),
|
|
ft.Text(FormatValue(Current, Definition) if Current is not None else "?", size=16),
|
|
ft.IconButton(ft.Icons.ADD, icon_size=16,
|
|
on_click=lambda e: self._Set(Name, (Current if Current is not None else Minimum - 1) + 1)),
|
|
]
|
|
if Value is Mixed:
|
|
Controls.append(Note(MixedText))
|
|
elif Current is None:
|
|
Controls.append(Note("not set" if Value == Unset else FormatValue(Value)))
|
|
if not Definition.get("Required") and Value != Unset:
|
|
Controls.append(ft.IconButton(ft.Icons.CLEAR, icon_size=14, icon_color=Palette.Overlay0,
|
|
tooltip="Not set (?)", on_click=lambda e: self._Set(Name, Unset)))
|
|
return ft.Row(Controls, spacing=0, wrap=True)
|
|
|
|
def _Field(self, Name: str, Definition: dict[str, Any], Value: Any, Kind: str) -> ft.Control:
|
|
Shown = "" if Value is Mixed or Value == Unset else FormatValue(Value)
|
|
Hint = MixedText if Value is Mixed else "? (not set)"
|
|
# No expand here: this sits in a scrolling Column, where a vertical flex breaks the layout.
|
|
Field = TextInput(self.App, Value=Shown, Hint=Hint,
|
|
keyboard_type=ft.KeyboardType.NUMBER if Kind == "Number" else ft.KeyboardType.TEXT)
|
|
Field.multiline = Kind == "Text"
|
|
Field.shift_enter = True # Enter saves, Shift+Enter adds a line
|
|
State = {"Saved": Shown}
|
|
|
|
def Commit(e=None) -> None:
|
|
Text = Field.value or ""
|
|
if Text == State["Saved"]:
|
|
return
|
|
if Kind == "Text":
|
|
NewValue: Any = Text if Text.strip() else Unset
|
|
elif not Text.strip():
|
|
NewValue = Unset
|
|
else:
|
|
try:
|
|
NewValue = ParseNumber(Text)
|
|
except ValueError:
|
|
Field.error = "Not a number"
|
|
Field.update()
|
|
return
|
|
State["Saved"] = Text
|
|
self._Set(Name, NewValue)
|
|
|
|
Field.on_submit = Commit
|
|
OldBlur = Field.on_blur
|
|
|
|
def Blur(e) -> None:
|
|
OldBlur(e)
|
|
Commit()
|
|
|
|
Field.on_blur = Blur
|
|
Hints: list[str] = []
|
|
if Kind == "Number":
|
|
Minimum, Maximum = Bound(Definition, "Minimum"), Bound(Definition, "Maximum")
|
|
if Minimum is not None or Maximum is not None:
|
|
Hints.append(f"{Minimum if Minimum is not None else ''} … {Maximum if Maximum is not None else ''}")
|
|
Controls: list[ft.Control] = [Field]
|
|
if Hints:
|
|
Controls.append(Note(Hints[0], Size=11))
|
|
return ft.Column(Controls, spacing=2)
|
|
|
|
|
|
def TagSummary(TheBook: Book | None, Limit: int = 6) -> str:
|
|
""""Colour: Orange · Rating: ★★☆ · ..." for a list row."""
|
|
if TheBook is None:
|
|
return ""
|
|
Parts = []
|
|
for Name, Value in TheBook.Tags.items():
|
|
if Value == Unset or Value == "":
|
|
continue
|
|
Parts.append(f"{Name}: {FormatValue(Value, TheBook.Definitions.get(Name))}")
|
|
if len(Parts) >= Limit:
|
|
break
|
|
return " · ".join(Parts)
|
|
|
|
|
|
def BookProblems(TheBook: Book | None) -> list[str]:
|
|
if TheBook is None:
|
|
return []
|
|
if TheBook.Error:
|
|
return [TheBook.Error]
|
|
return [Problem for Name, Definition in TheBook.Definitions.items()
|
|
if (Problem := ValidateValue(Name, TheBook.Metadata.get(Name, Unset), Definition))]
|