mirror of
https://git.srhtnin.garden/sarah/SaraTag.git
synced 2026-10-15 02:43:17 +02:00
SaraTag, a Flet GUI for tagging files.
This commit is contained in:
commit
845d6c6ee6
27 files changed
+6035
No files matched your search
+236
@@ -0,0 +1,236 @@
|
||||
"""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))]
|
||||
Reference in new issue
Block a user