"""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