Files
SaraTag/TagEditor.py
T

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))]