Files
SaraTag/LexiconsPage.py
T

477 lines
23 KiB
Python

"""Lexicons: edit tag definitions. Left, every Lexicon (Lexicons/ and the
ones inside Record directories); middle, its options and tags; right, the
selected tag's properties. Every change is saved straight away.
Inherited tags show their Source's values; changing one writes an override
with only that property. "Revert" drops the override again.
"""
import os
from pathlib import Path
from typing import Any, Callable, Optional
import flet as ft
from Books import ParseNumber
from Core import ChipButton, DisplayPath, Heading, Note, Palette
from Library import DataError, FormatValue, LexiconsDir, TagType, TagTypes
from Lexicons import (
CommonProperties, KnownLexicons, Lexicon, NewSharedLexicon, PropertyHelp, TagView, TypeProperties,
)
from Records import ListRecords
from Widgets import App, Badge, EmptyState, Panel, SectionTitle, TextInput
OriginColors = {"Local": Palette.Mauve, "Inherited": Palette.Overlay0, "Overridden": Palette.Peach, "Excluded": Palette.Red}
OriginHelp = {
"Local": "Defined in this Lexicon.",
"Inherited": "Comes unchanged from the Source Lexicon.",
"Overridden": "Comes from the Source, with some properties changed here.",
"Excluded": "In the Source, but left out here (ExcludeTags).",
}
class LexiconsPage(ft.Container):
def __init__(self, TheApp: App):
super().__init__(expand=True)
self.App = TheApp
self.Paths: list[Path] = []
self.UsedBy: dict[Path, list[str]] = {}
self.Current: Optional[Lexicon] = None
self.SelectedTag: Optional[str] = None
self.Search = TextInput(TheApp, Hint="Find a Lexicon", Prefix=ft.Icons.SEARCH, OnChange=lambda e: self._BuildList())
self.List = ft.ListView(spacing=2, expand=True)
Left = ft.Container(
ft.Column([
SectionTitle("Lexicons", [ChipButton("New", lambda e: self.NewDialog(), ft.Icons.ADD)]),
self.Search, self.List,
], spacing=8, expand=True),
width=270, bgcolor=Palette.Mantle, padding=ft.Padding.only(left=12, right=8, top=8),
border=ft.Border.only(right=ft.BorderSide(1, Palette.Surface1)),
)
self.Middle = ft.Container(width=330, padding=12, border=ft.Border.only(right=ft.BorderSide(1, Palette.Surface1)))
self.Right = ft.Container(expand=True, padding=16)
self.content = ft.Row([Left, self.Middle, self.Right], spacing=0, expand=True,
vertical_alignment=ft.CrossAxisAlignment.STRETCH)
def Refresh(self, Open: str = "") -> None:
self.page.on_keyboard_event = None
try:
Records = ListRecords()[0]
except DataError as Error:
self.App.Notify(str(Error), "error")
Records = []
self.UsedBy = {}
for Record in Records:
self.UsedBy.setdefault(Record.LexiconPath.resolve(), []).append(Record.Name)
self.Paths = KnownLexicons([Record.Directory for Record in Records if Record.Directory.is_dir()])
Target = Path(Open).resolve() if Open else (self.Current.Path if self.Current else None)
if Target is not None and Target not in self.Paths and Target.is_file():
self.Paths.append(Target)
if Target is None and self.Paths:
Target = self.Paths[0]
self._Load(Target, Keep=not Open)
def Leave(self) -> None:
pass
def _Load(self, FilePath: Optional[Path], Keep: bool = False) -> None:
Previous = self.SelectedTag if Keep else None
self.Current = None
self.LoadError = ""
if FilePath is not None:
try:
self.Current = Lexicon(FilePath)
except DataError as Error:
self.LoadError = str(Error)
self.CurrentPath = FilePath
self.SelectedTag = Previous if self.Current and Previous and self.Current.View(Previous) else (
self.Current.Views[0].Name if self.Current and self.Current.Views else None)
self.CurrentPath = FilePath
self._BuildAll()
def _BuildAll(self) -> None:
self._BuildList(Update=False)
self._BuildMiddle()
self._BuildRight()
self.update()
def _Label(self, FilePath: Path) -> str:
try:
return str(FilePath.relative_to(LexiconsDir.resolve()))
except ValueError:
return DisplayPath(str(FilePath))
def _BuildList(self, Update: bool = True) -> None:
Query = (self.Search.value or "").casefold()
Shared, Inside = [], []
for FilePath in self.Paths:
if Query and Query not in str(FilePath).casefold():
continue
Selected = FilePath == getattr(self, "CurrentPath", None)
Users = self.UsedBy.get(FilePath, [])
Tile = ft.Container(
ft.Column([
ft.Text(FilePath.name, size=13, color=Palette.Mauve if Selected else Palette.Text, max_lines=1,
overflow=ft.TextOverflow.ELLIPSIS),
*([Note("Used by " + ", ".join(Users), Size=11)] if Users else []),
*([Note(DisplayPath(str(FilePath.parent)), Size=11)] if FilePath.parent != LexiconsDir.resolve() else []),
], spacing=0, tight=True),
padding=ft.Padding.symmetric(horizontal=8, vertical=6), border_radius=4, ink=True,
bgcolor=Palette.Surface0 if Selected else None, on_click=lambda e, P=FilePath: self._Load(P),
)
(Shared if FilePath.parent == LexiconsDir.resolve() else Inside).append(Tile)
Controls: list[ft.Control] = []
if Shared:
Controls += [Note("In Lexicons/", Size=11)] + Shared
if Inside:
Controls += [Note("Inside directories", Size=11)] + Inside
self.List.controls = Controls or [Note("No Lexicons found.")]
if Update:
self.List.update()
# Middle: options and tags
def _BuildMiddle(self) -> None:
Lex = self.Current
if Lex is None:
self.Middle.content = EmptyState(self.LoadError or "No Lexicon selected.", Icon=ft.Icons.MENU_BOOK_OUTLINED)
return
Others = [FilePath for FilePath in self.Paths if FilePath != Lex.Path.resolve()]
SourceText = str(Lex.Options.get("Source", "") or "")
SourceText = "" if SourceText == "None" else SourceText
Choices = {self._Relative(Lex.Path, FilePath): FilePath for FilePath in Others}
if SourceText and SourceText not in Choices:
Choices = {SourceText: None} | Choices
SourcePicker = ft.Dropdown(
label="Source", value=SourceText or None, editable=True, enable_filter=True, dense=True, menu_height=320,
options=[ft.DropdownOption(key=Key, text=Key) for Key in Choices], expand=True,
)
InheritSwitch = ft.Switch(label="Inherit tags from a Source", value=bool(Lex.Options.get("Inherit")))
def ApplyOptions(e=None) -> None:
Source = SourcePicker.value or ""
try:
Lex.SetOptions(bool(InheritSwitch.value), Source)
except DataError as Error:
self.App.Notify(str(Error), "error")
self._BuildAll()
return
self.App.Notify("Saved. Run Update to bring Books in line.", "success")
self._BuildAll()
InheritSwitch.on_change = ApplyOptions
SourcePicker.on_select = ApplyOptions
Header: list[ft.Control] = [
Heading(Lex.Path.name),
Note(DisplayPath(str(Lex.Path.parent)), Size=11),
ft.Row([InheritSwitch]),
ft.Row([SourcePicker, ft.IconButton(ft.Icons.OPEN_IN_NEW, tooltip="Open the Source",
on_click=lambda e: self._Load(Lex.SourcePath) if Lex.SourcePath else None,
disabled=Lex.SourcePath is None)]),
]
if Lex.SourceError:
Header.append(ft.Text(Lex.SourceError, color=Palette.Red, size=12))
Tags = []
for View in Lex.Views:
Selected = View.Name == self.SelectedTag
Tags.append(ft.Container(
ft.Row([
ft.Text(View.Name, size=13, expand=True, color=Palette.Overlay0 if View.Origin == "Excluded" else Palette.Text,
style=ft.TextStyle(decoration=ft.TextDecoration.LINE_THROUGH) if View.Origin == "Excluded" else None),
Note(TagType(View.Definition) or "?", Size=11),
ft.Container(Badge(View.Origin, OriginColors[View.Origin]), tooltip=OriginHelp[View.Origin]),
], spacing=6),
padding=ft.Padding.symmetric(horizontal=8, vertical=6), border_radius=4, ink=True,
bgcolor=Palette.Surface0 if Selected else None,
on_click=lambda e, Name=View.Name: self._SelectTag(Name),
))
self.Middle.content = ft.Column([
*Header,
ft.Divider(height=1, color=Palette.Surface1),
SectionTitle("Tags", [ChipButton("Add", lambda e: self.AddTagDialog(), ft.Icons.ADD)]),
ft.Column(Tags, spacing=2, scroll=ft.ScrollMode.AUTO, expand=True) if Tags else Note("No tags yet."),
], spacing=8, expand=True)
@staticmethod
def _Relative(LexiconPath: Path, Target: Path) -> str:
Relative = os.path.relpath(Target, LexiconPath.resolve().parent)
return Relative if Relative.startswith(".") else "./" + Relative
def _SelectTag(self, Name: str) -> None:
self.SelectedTag = Name
self._BuildMiddle()
self._BuildRight()
self.update()
# Right: one tag's properties
def _BuildRight(self) -> None:
Lex = self.Current
View = Lex.View(self.SelectedTag) if Lex and self.SelectedTag else None
if View is None:
self.Right.content = EmptyState("Pick a tag." if Lex else "", Icon=ft.Icons.LABEL_OUTLINE if Lex else None)
return
Kind = TagType(View.Definition)
Local = Lex.Local.get(View.Name, {})
Actions: list[ft.Control] = []
if View.Origin == "Local":
Actions += [
ChipButton("Rename", lambda e: self.RenameDialog(View), ft.Icons.DRIVE_FILE_RENAME_OUTLINE),
ft.IconButton(ft.Icons.ARROW_UPWARD, tooltip="Move up", on_click=lambda e: self._Do(lambda: Lex.MoveTag(View.Name, -1))),
ft.IconButton(ft.Icons.ARROW_DOWNWARD, tooltip="Move down", on_click=lambda e: self._Do(lambda: Lex.MoveTag(View.Name, 1))),
ChipButton("Delete", lambda e: self.DeleteTag(View), ft.Icons.DELETE_OUTLINE, Accent=Palette.Red),
]
elif View.Origin == "Excluded":
Actions.append(ChipButton("Restore (stop excluding)", lambda e: self._Do(lambda: Lex.RestoreTag(View.Name), "Restored."),
ft.Icons.RESTORE))
else:
Actions.append(ChipButton("Exclude from this Lexicon", lambda e: self._Do(lambda: Lex.DeleteTag(View.Name), "Excluded."),
ft.Icons.REMOVE_CIRCLE_OUTLINE, Accent=Palette.Red))
if View.Origin == "Overridden":
Actions.append(ChipButton("Revert all overrides", lambda e: self.RevertAll(View), ft.Icons.UNDO))
TypePicker = ft.Dropdown(
label="Type", value=Kind or None, dense=True, width=180, disabled=View.Origin != "Local",
options=[ft.DropdownOption(key=Name, text=Name) for Name in TagTypes],
on_select=lambda e: self._ChangeType(View, e.control.value),
tooltip=None if View.Origin == "Local" else "Change the type in the Source Lexicon",
)
Rows: list[ft.Control] = []
if View.Origin != "Excluded":
for Property in CommonProperties + TypeProperties.get(Kind, []):
Rows.append(self._PropertyRow(Lex, View, Property, Local))
return_note = {
"Local": "Changes here apply to every Book using this Lexicon (and Lexicons inheriting from it).",
"Inherited": "Changing a property here overrides it for this Lexicon only.",
"Overridden": "Orange properties are overridden here; Revert brings back the Source's value.",
"Excluded": "Excluded tags aren't part of this Lexicon. Clean unused tags removes them from Books.",
}[View.Origin]
self.Right.content = ft.Column([
ft.Row([Heading(View.Name), Badge(View.Origin, OriginColors[View.Origin])], spacing=10),
Note(return_note),
ft.Row(Actions, wrap=True, spacing=8),
TypePicker if View.Origin != "Excluded" else ft.Container(),
ft.Divider(height=1, color=Palette.Surface1),
*Rows,
], spacing=12, scroll=ft.ScrollMode.AUTO, expand=True)
def _Do(self, Action: Callable[[], None], Success: str = "") -> None:
try:
Action()
except DataError as Error:
self.App.Notify(str(Error), "error")
else:
if Success:
self.App.Notify(Success, "success")
if self.Current and self.SelectedTag and not self.Current.View(self.SelectedTag):
self.SelectedTag = self.Current.Views[0].Name if self.Current.Views else None
self._BuildMiddle()
self._BuildRight()
self.update()
def _Set(self, View: TagView, Property: str, Value: Any) -> None:
self._Do(lambda: self.Current.SetProperty(View.Name, Property, Value))
def _PropertyRow(self, Lex: Lexicon, View: TagView, Property: str, Local: dict[str, Any]) -> ft.Control:
Definition = View.Definition
Kind = TagType(Definition)
Origin = View.PropertyOrigin(Property, Local)
Present = Property in Definition
Value = Definition.get(Property)
Label = ft.Text(Property, size=13, width=120,
color=Palette.Peach if Origin == "Overridden" else Palette.Text, tooltip=PropertyHelp.get(Property))
Trailing: list[ft.Control] = []
if Origin == "Overridden":
Trailing.append(ft.IconButton(ft.Icons.UNDO, icon_size=16, tooltip="Revert to the Source's value",
on_click=lambda e: self._Set(View, Property, None)))
elif Property in ("Default", "Minimum", "Maximum", "EmptySymbol") and Present and View.Origin == "Local":
Trailing.append(ft.IconButton(ft.Icons.CLEAR, icon_size=16, tooltip=f"Remove {Property}",
on_click=lambda e: self._Set(View, Property, None)))
if Property in ("Required", "ForceDefault"):
Editor: ft.Control = ft.Switch(value=bool(Value), on_change=lambda e: self._Set(View, Property, bool(e.control.value)))
elif Property == "Values":
Editor = self._ValuesEditor(View, list(Value or []))
elif Property == "Default" and Kind == "List":
Options = [ft.DropdownOption(key=str(Item), text=str(Item)) for Item in Definition.get("Values", [])]
ByKey = {str(Item): Item for Item in Definition.get("Values", [])}
Editor = ft.Dropdown(value=str(Value) if Present else None, options=Options, dense=True, expand=True,
hint_text="(none)", on_select=lambda e: self._Set(View, "Default", ByKey.get(e.control.value)))
elif Property == "Default" and Kind == "Boolean":
Editor = ft.Dropdown(value=("Yes" if Value else "No") if Present else None, dense=True, width=140, hint_text="(none)",
options=[ft.DropdownOption(key="Yes", text="Yes"), ft.DropdownOption(key="No", text="No")],
on_select=lambda e: self._Set(View, "Default", e.control.value == "Yes"))
else:
Numeric = Property in ("Minimum", "Maximum") or (Property == "Default" and Kind in ("Number", "Rating"))
Editor = self._TextEditor(View, Property, "" if not Present else FormatValue(Value) if not Numeric else str(Value),
Numeric, Whole=Kind == "Rating")
return ft.Row([Label, ft.Container(Editor, expand=True), *Trailing], vertical_alignment=ft.CrossAxisAlignment.CENTER)
def _TextEditor(self, View: TagView, Property: str, Shown: str, Numeric: bool, Whole: bool) -> ft.Control:
Field = TextInput(self.App, Value=Shown, Expand=True, Hint="(none)" if Numeric or Property == "Default" else None)
State = {"Saved": Shown}
def Commit(e=None) -> None:
Text = Field.value or ""
if Text == State["Saved"]:
return
State["Saved"] = Text
if Numeric:
if not Text.strip():
self._Set(View, Property, None)
return
try:
Value = ParseNumber(Text)
except ValueError:
self.App.Notify(f"{Property} must be a number.", "error")
return
if Whole and not isinstance(Value, int):
self.App.Notify(f"{Property} must be a whole number for a Rating.", "error")
return
self._Set(View, Property, Value)
else:
self._Set(View, Property, Text)
Field.on_submit = Commit
OldBlur = Field.on_blur
def Blur(e) -> None:
OldBlur(e)
Commit()
Field.on_blur = Blur
return Field
def _ValuesEditor(self, View: TagView, Values: list[Any]) -> ft.Control:
def Save(NewValues: list[Any]) -> None:
self._Set(View, "Values", NewValues)
def Move(Index: int, Offset: int) -> None:
Target = Index + Offset
if 0 <= Target < len(Values):
NewValues = list(Values)
NewValues.insert(Target, NewValues.pop(Index))
Save(NewValues)
def Rename(Index: int) -> None:
def Done(Text: str) -> None:
Text = Text.strip()
if not Text or Text == str(Values[Index]):
return
NewValues = list(Values)
NewValues[Index] = Text
Save(NewValues)
Default = self.Current.View(View.Name).Definition.get("Default") if self.Current.View(View.Name) else None
if Default == Values[Index]:
self._Set(View, "Default", Text)
self.App.Ask("Rename value", "Value", str(Values[Index]), Done,
Help="Books holding the old value keep it (and show as invalid) until you change them.")
def Remove(Index: int) -> None:
Save([Item for Number, Item in enumerate(Values) if Number != Index])
Rows: list[ft.Control] = []
for Index, Item in enumerate(Values):
Rows.append(ft.Row([
ft.Text(str(Item), size=13, expand=True),
ft.IconButton(ft.Icons.ARROW_UPWARD, icon_size=14, on_click=lambda e, I=Index: Move(I, -1), disabled=Index == 0),
ft.IconButton(ft.Icons.ARROW_DOWNWARD, icon_size=14, on_click=lambda e, I=Index: Move(I, 1),
disabled=Index == len(Values) - 1),
ft.IconButton(ft.Icons.EDIT_OUTLINED, icon_size=14, on_click=lambda e, I=Index: Rename(I)),
ft.IconButton(ft.Icons.DELETE_OUTLINE, icon_size=14, icon_color=Palette.Red, on_click=lambda e, I=Index: Remove(I)),
], spacing=0, height=32))
NewField = TextInput(self.App, Hint="Add a value…", Expand=True)
def Add(e=None) -> None:
Text = (NewField.value or "").strip()
if Text:
Save(Values + [Text])
NewField.on_submit = Add
Rows.append(ft.Row([NewField, ft.IconButton(ft.Icons.ADD, on_click=Add)]))
return Panel(ft.Column(Rows, spacing=0), padding=6)
# Dialogs and actions
def _ChangeType(self, View: TagView, NewKind: str) -> None:
if NewKind == TagType(View.Definition):
return
def Yes() -> None:
self._Do(lambda: self.Current.ChangeType(View.Name, NewKind), f"{View.Name} is now {NewKind}.")
self.App.Confirm("Change type", f"Make {View.Name} a {NewKind}? Its type-specific properties are replaced; the "
"Default is converted when possible. Existing Book values aren't changed (Set all to defaults "
"on the Update page resets them).", Yes, "Change", Accent=Palette.Yellow)
def AddTagDialog(self) -> None:
if self.Current is None:
return
NameField = TextInput(self.App, Label="Tag name", Autofocus=True)
KindPicker = ft.Dropdown(label="Type", value="Text", options=[ft.DropdownOption(key=Name, text=Name) for Name in TagTypes])
def Add(e=None) -> None:
Name = (NameField.value or "").strip()
try:
self.Current.AddTag(Name, KindPicker.value or "Text")
except DataError as Error:
self.App.Notify(str(Error), "error")
return
self.App.Close()
self.SelectedTag = Name
self.App.Notify(f"Added {Name}. Run Update to add it to Books.", "success")
self._BuildMiddle()
self._BuildRight()
self.update()
NameField.on_submit = Add
self.App.ShowDialog("Add a tag", ft.Column([NameField, KindPicker, Note(
"Text: anything. Number: any number, optional Minimum/Maximum. Rating: whole points shown as symbols. "
"List: one of fixed Values. Boolean: yes or no.")], tight=True, spacing=10), [
ChipButton("Cancel", lambda e: self.App.Close()), ChipButton("Add", Add, ft.Icons.ADD)], Width=460)
def RenameDialog(self, View: TagView) -> None:
def Done(NewName: str) -> None:
try:
self.Current.RenameTag(View.Name, NewName)
except DataError as Error:
self.App.Notify(str(Error), "error")
return
self.SelectedTag = NewName.strip()
self.App.Notify(f"Renamed to {NewName.strip()}. Books still hold the old key; Clean unused tags drops it, "
"and Update adds the new one.", "success")
self._BuildMiddle()
self._BuildRight()
self.update()
self.App.Ask("Rename tag", "New name", View.Name, Done, "Rename")
def DeleteTag(self, View: TagView) -> None:
self.App.Confirm("Delete tag", f"Delete {View.Name} from this Lexicon? Books keep their value until you run "
"Clean unused tags.", lambda: self._Do(lambda: self.Current.DeleteTag(View.Name), f"Deleted {View.Name}."),
"Delete")
def RevertAll(self, View: TagView) -> None:
def Revert() -> None:
self.Current.Local.pop(View.Name, None)
self.Current.Save()
self._Do(Revert, "Overrides removed.")
def NewDialog(self) -> None:
def Done(Name: str) -> None:
try:
FilePath = NewSharedLexicon(Name)
except (DataError, OSError) as Error:
self.App.Notify(str(Error), "error")
return
self.Paths.append(FilePath.resolve())
self._Load(FilePath.resolve())
self.App.Ask("New shared Lexicon", "File name (in Lexicons/)", "", Done, "Create",
"For tags several Records share: point their Lexicons' Source at it. A Record's own Lexicon is "
"made when you add the Record.")