Files
SaraSuite/ProgramCard.py
T

169 lines
7.4 KiB
Python

"""One card per program: its icon on the left; name, description, install
info and install status in the middle; and three fixed buttons stacked on
the right (Install/Update, Options, Remove). A button that doesn't apply
right now stays in place, greyed out, rather than disappearing."""
from typing import Callable, Optional
import flet as ft
from Core import ChipButtonStyle, Palette
from Installer import RemovalPlan, Status
from Manifests import Program
ChildIndent = 40
# The icons are 32x32 pixel art (stored 10x, as 320x320). 128 keeps every art
# pixel a whole number of screen pixels: 4 at 100% scale and 3 at 75%, the
# scale of Sarah's screens. Sizes that aren't (like 80) make pixels uneven.
IconSize = 128
ButtonWidth = 120
class ProgramCard(ft.Container):
def __init__(
self,
Program: Program,
OnInstall: Callable[["ProgramCard"], None],
OnUpdate: Callable[["ProgramCard"], None],
OnRemove: Callable[["ProgramCard"], None],
OnOptions: Callable[["ProgramCard"], None],
) -> None:
self.Program = Program
self.CurrentStatus: Optional[Status] = None
self.OnInstall = OnInstall
self.OnUpdate = OnUpdate
# Which buttons apply right now (set by ShowStatus); while busy they're
# all disabled, and afterwards only these come back.
self.Allowed = {"Primary": False, "Options": False, "Remove": False}
# No frame of its own: the icons are drawn with their own border.
self.IconBox = ft.Container(
width=IconSize, height=IconSize, alignment=ft.Alignment.CENTER, content=self._IconContent(),
)
self.InfoText = ft.Text("", size=12, color=Palette.Overlay0, selectable=True)
self.StatusText = ft.Text("Checking...", size=12, color=Palette.Overlay0)
self.NoteText = ft.Text("", size=12, color=Palette.Overlay0, selectable=True)
self.Spinner = ft.ProgressRing(width=14, height=14, stroke_width=2, visible=False)
# Install, Update or Repair, depending on the state.
self.PrimaryButton = ft.OutlinedButton(
content="Install", style=ChipButtonStyle(), width=ButtonWidth, disabled=True,
on_click=lambda e: self._OnPrimary(),
)
self.OptionsButton = ft.OutlinedButton(
content="Options", style=ChipButtonStyle(), width=ButtonWidth, disabled=True,
on_click=lambda e: OnOptions(self),
)
self.RemoveButton = ft.OutlinedButton(
content="Remove", style=ChipButtonStyle(Palette.Red), width=ButtonWidth, disabled=True,
on_click=lambda e: OnRemove(self),
)
super().__init__(
content=ft.Row(
spacing=16,
vertical_alignment=ft.CrossAxisAlignment.CENTER,
controls=[
self.IconBox,
ft.Column(
expand=True,
spacing=6,
controls=[
ft.Text(Program.Name, size=22, color=Palette.Mauve),
ft.Text(Program.Description, size=14, color=Palette.Text),
self.InfoText,
ft.Row([self.Spinner, self.StatusText, self.NoteText], spacing=8, wrap=True),
],
),
ft.Column(
spacing=8,
controls=[self.PrimaryButton, self.OptionsButton, self.RemoveButton],
),
],
),
bgcolor=Palette.Mantle,
border=ft.Border.all(1, Palette.Surface1),
border_radius=8,
padding=16,
# A program that belongs to another (Parent) sits indented under it.
margin=ft.Margin.only(left=ChildIndent) if Program.Parent else None,
)
def _IconContent(self) -> ft.Control:
Path = self.Program.IconPath
if Path is None:
# No icon (e.g. SaraSortd, a background service).
return ft.Icon(ft.Icons.APPS, size=IconSize // 2, color=Palette.Overlay0)
return ft.Image(
src=str(Path),
width=IconSize,
height=IconSize,
fit=ft.BoxFit.CONTAIN,
filter_quality=ft.FilterQuality.NONE, # pixel art stays crisp when scaled
)
def _OnPrimary(self) -> None:
if self.CurrentStatus is not None and self.CurrentStatus.Installed:
self.OnUpdate(self)
else:
self.OnInstall(self)
def ShowStatus(self, Current: Status, Leftovers: RemovalPlan, Busy: bool) -> None:
self.CurrentStatus = Current
Receipt = Current.Receipt
if Current.Broken:
Text, Color = "Incomplete install", Palette.Peach
elif Current.UpdateAvailable:
Text, Color = "Update available", Palette.Yellow
elif Current.Installed:
Text, Color = "Installed, up to date", Palette.Green
elif Current.Error:
Text, Color = "Unreachable", Palette.Red
else:
Text, Color = "Not installed", Palette.Overlay0
self.StatusText.value = Text
self.StatusText.color = Color
Info = []
if Receipt is not None:
Installed = f"Installed {Receipt.Version} ({Receipt.Commit[:7]})".replace(" ", " ")
Info.append(f"{Installed} on {Receipt.UpdatedAt.replace('T', ' ')}")
if Current.LatestCommit and (Receipt is None or Current.LatestCommit != Receipt.Commit):
Info.append(f"latest commit {Current.LatestCommit[:7]}")
Info.append(f"from {self.Program.GitUrl}")
self.InfoText.value = " · ".join(Info)
Notes = []
if Current.Error:
Notes.append(Current.Error)
if Receipt is None and Leftovers.Items:
Notes.append(f"{len(Leftovers.Items)} file(s)/folder(s) it made are still around")
self.NoteText.value = " · ".join(Notes)
# Install when not installed; Update (Repair if broken) when it is,
# greyed out while there's nothing to update.
if not Current.Installed:
self.PrimaryButton.content = "Install"
self.Allowed["Primary"] = bool(Current.LatestCommit)
else:
self.PrimaryButton.content = "Repair" if Current.Broken else "Update"
self.Allowed["Primary"] = Current.UpdateAvailable and bool(Current.LatestCommit)
# Options reapplies the installed commit, so it needs the git host too.
self.Allowed["Options"] = Current.Installed and bool(self.Program.Options) and bool(Current.LatestCommit)
# Not installed but configs/runtime files were kept: still removable.
self.RemoveButton.content = "Remove" if Current.Installed or not Leftovers.Items else "Clean up"
self.Allowed["Remove"] = Current.Installed or bool(Leftovers.Items)
self.IconBox.content = self._IconContent() # installing/removing switches the icon's source
self.SetBusy(Busy)
def SetBusy(self, Busy: bool, Working: bool = False) -> None:
"""Busy greys out every button (only one operation runs at a time);
Working also shows this card's spinner."""
self.PrimaryButton.disabled = Busy or not self.Allowed["Primary"]
self.OptionsButton.disabled = Busy or not self.Allowed["Options"]
self.RemoveButton.disabled = Busy or not self.Allowed["Remove"]
self.Spinner.visible = Working