From d9da8438c2a57d301d3ed0f4645dc232cf8b64d4 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Fri, 9 Oct 2026 10:31:30 +0200 Subject: [PATCH] Program icons at 128px, no frame --- Core.py | 12 ++- Installer.py | 4 +- Manifests.py | 16 ++++ ProgramCard.py | 167 ++++++++++++++++++++++--------------- Programs/SaraFem/Icon.png | Bin 0 -> 1024 bytes Programs/SaraSort/Icon.png | Bin 0 -> 1026 bytes Programs/SaraTag/Icon.png | Bin 0 -> 1042 bytes Programs/SaraView/Icon.png | Bin 0 -> 991 bytes 8 files changed, 128 insertions(+), 71 deletions(-) create mode 100644 Programs/SaraFem/Icon.png create mode 100644 Programs/SaraSort/Icon.png create mode 100644 Programs/SaraTag/Icon.png create mode 100644 Programs/SaraView/Icon.png diff --git a/Core.py b/Core.py index 0250a9e..923f820 100644 --- a/Core.py +++ b/Core.py @@ -237,10 +237,14 @@ def BuildTheme() -> ft.Theme: def ChipButtonStyle(Accent: str = "") -> ft.ButtonStyle: """SaraView's button look: SurfaceAlt fill, 1px accent border, small radius. - Accent defaults to Mauve; Remove buttons pass Palette.Red.""" + Accent defaults to Mauve; Remove buttons pass Palette.Red. Disabled buttons + are greyed out (muted text, plain border, no fill) so it's obvious they + can't be pressed.""" + Disabled = ft.ControlState.DISABLED + Default = ft.ControlState.DEFAULT return ft.ButtonStyle( - color=Palette.Text, - bgcolor=Palette.Surface0, - side=ft.BorderSide(1, Accent or Palette.Mauve), + color={Disabled: Palette.Overlay0, Default: Palette.Text}, + bgcolor={Disabled: Palette.Mantle, Default: Palette.Surface0}, + side={Disabled: ft.BorderSide(1, Palette.Surface1), Default: ft.BorderSide(1, Accent or Palette.Mauve)}, shape=ft.RoundedRectangleBorder(radius=4), ) diff --git a/Installer.py b/Installer.py index b9964c4..42024d0 100644 --- a/Installer.py +++ b/Installer.py @@ -152,7 +152,9 @@ def ManifestHash(Program: Program) -> str: AppImage), so that shows up as an update even with no new commit.""" Hasher = hashlib.sha256() for ManifestFile in sorted(Program.Folder.rglob("*")): - if ManifestFile.is_file() and "__pycache__" not in ManifestFile.parts: + # Icon.png is only the card's picture before installing; changing it + # shouldn't make installed programs show an update. + if ManifestFile.is_file() and "__pycache__" not in ManifestFile.parts and ManifestFile.name != "Icon.png": Hasher.update(str(ManifestFile.relative_to(Program.Folder)).encode()) Hasher.update(ManifestFile.read_bytes()) return Hasher.hexdigest() diff --git a/Manifests.py b/Manifests.py index 7a4247c..f2d0edd 100644 --- a/Manifests.py +++ b/Manifests.py @@ -129,6 +129,22 @@ class Program: def AppDir(self) -> Path: return self.ProgramDir / "App" + @property + def IconPath(self) -> Optional[Path]: + """The installed copy's own icon if it's there (always current; the + Icon= line of the program's desktop entry template says where), otherwise + the copy bundled next to the manifest (Programs//Icon.png), so the + card has an icon before installing too.""" + for Template in self.Templates: + if Template.Target.endswith(".desktop"): + for Line in (self.Folder / "Templates" / Template.Source).read_text().splitlines(): + if Line.startswith("Icon="): + Installed = Path(Render(Line.removeprefix("Icon="), self.Context())) + if Installed.is_file(): + return Installed + Bundled = self.Folder / "Icon.png" + return Bundled if Bundled.is_file() else None + @property def ConfigDir(self) -> Path: return SharedConfigDir / self.Name diff --git a/ProgramCard.py b/ProgramCard.py index 69020ee..09695bd 100644 --- a/ProgramCard.py +++ b/ProgramCard.py @@ -1,4 +1,7 @@ -"""One card per program: name, status, versions, and its action buttons.""" +"""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 @@ -9,6 +12,11 @@ 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): @@ -22,50 +30,56 @@ class ProgramCard(ft.Container): ) -> 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.StatusChip = ft.Container( - content=self.StatusText, - bgcolor=Palette.Surface0, - border=ft.Border.all(1, Palette.Overlay0), - border_radius=4, - padding=ft.Padding.symmetric(horizontal=8, vertical=2), - ) - self.DetailText = ft.Text("", size=12, color=Palette.Overlay0, selectable=True) - self.Spinner = ft.ProgressRing(width=16, height=16, stroke_width=2, visible=False) + self.NoteText = ft.Text("", size=12, color=Palette.Overlay0, selectable=True) + self.Spinner = ft.ProgressRing(width=14, height=14, stroke_width=2, visible=False) - self.InstallButton = ft.OutlinedButton( - content="Install", style=ChipButtonStyle(), visible=False, on_click=lambda e: OnInstall(self), - ) - self.UpdateButton = ft.OutlinedButton( - content="Update", style=ChipButtonStyle(), visible=False, on_click=lambda e: OnUpdate(self), + # 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(), visible=False, on_click=lambda e: OnOptions(self), + content="Options", style=ChipButtonStyle(), width=ButtonWidth, disabled=True, + on_click=lambda e: OnOptions(self), ) self.RemoveButton = ft.OutlinedButton( - content="Remove", style=ChipButtonStyle(Palette.Red), visible=False, on_click=lambda e: OnRemove(self), + content="Remove", style=ChipButtonStyle(Palette.Red), width=ButtonWidth, disabled=True, + on_click=lambda e: OnRemove(self), ) super().__init__( - content=ft.Column( - spacing=6, + content=ft.Row( + spacing=16, + vertical_alignment=ft.CrossAxisAlignment.CENTER, controls=[ - ft.Row( - vertical_alignment=ft.CrossAxisAlignment.CENTER, + self.IconBox, + ft.Column( + expand=True, + spacing=6, controls=[ - ft.Text(Program.Name, size=20, color=Palette.Mauve), - self.StatusChip, - ft.Container(expand=True), - self.Spinner, - self.InstallButton, - self.UpdateButton, - self.OptionsButton, - self.RemoveButton, + 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.Text(Program.Description, size=13, color=Palette.Text), - self.DetailText, + ft.Column( + spacing=8, + controls=[self.PrimaryButton, self.OptionsButton, self.RemoveButton], + ), ], ), bgcolor=Palette.Mantle, @@ -76,58 +90,79 @@ class ProgramCard(ft.Container): margin=ft.Margin.only(left=ChildIndent) if Program.Parent else None, ) - def _SetChip(self, Text: str, Color: str) -> None: - self.StatusText.value = Text - self.StatusText.color = Color - self.StatusChip.border = ft.Border.all(1, Color) + 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: - self._SetChip("Incomplete", Palette.Peach) + Text, Color = "Incomplete install", Palette.Peach elif Current.UpdateAvailable: - self._SetChip("Update available", Palette.Yellow) + Text, Color = "Update available", Palette.Yellow elif Current.Installed: - self._SetChip("Installed", Palette.Green) + Text, Color = "Installed, up to date", Palette.Green elif Current.Error: - self._SetChip("Unreachable", Palette.Red) + Text, Color = "Unreachable", Palette.Red else: - self._SetChip("Not installed", Palette.Overlay0) + Text, Color = "Not installed", Palette.Overlay0 + self.StatusText.value = Text + self.StatusText.color = Color - Details = [] + Info = [] if Receipt is not None: Installed = f"Installed {Receipt.Version} ({Receipt.Commit[:7]})".replace(" ", " ") - Details.append(f"{Installed} on {Receipt.UpdatedAt.replace('T', ' ')}") + Info.append(f"{Installed} on {Receipt.UpdatedAt.replace('T', ' ')}") if Current.LatestCommit and (Receipt is None or Current.LatestCommit != Receipt.Commit): - Details.append(f"Latest commit {Current.LatestCommit[:7]}") - Details.append(f"From {self.Program.GitUrl}") - if Current.Error: - Details.append(Current.Error) - if Receipt is None and Leftovers.Items: - Details.append(f"{len(Leftovers.Items)} file(s)/folder(s) it made are still around") - self.DetailText.value = " · ".join(Details) + Info.append(f"latest commit {Current.LatestCommit[:7]}") + Info.append(f"from {self.Program.GitUrl}") + self.InfoText.value = " · ".join(Info) - self.InstallButton.visible = not Current.Installed - self.InstallButton.disabled = Busy or not Current.LatestCommit - # Only there when there's something to update (or repair). - self.UpdateButton.visible = Current.UpdateAvailable - self.UpdateButton.disabled = Busy or not Current.LatestCommit - self.UpdateButton.content = "Repair" if Current.Broken else "Update" - # Change options (e.g. the file picker) after installing. Needs the - # git host, since it reapplies the installed commit. - self.OptionsButton.visible = Current.Installed and bool(self.Program.Options) - self.OptionsButton.disabled = Busy or not Current.LatestCommit + 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.visible = Current.Installed or bool(Leftovers.Items) - self.RemoveButton.content = "Remove" if Current.Installed else "Clean up" - self.RemoveButton.disabled = Busy - self.Spinner.visible = False + 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 the buttons (only one operation runs at a time); + """Busy greys out every button (only one operation runs at a time); Working also shows this card's spinner.""" - for Button in (self.InstallButton, self.UpdateButton, self.OptionsButton, self.RemoveButton): - Button.disabled = Busy + 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 diff --git a/Programs/SaraFem/Icon.png b/Programs/SaraFem/Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c72d144abc8a1379ba6b9c7650b72d4c2379abdd GIT binary patch literal 1024 zcmeAS@N?(olHy`uVBq!ia0y~yU~~Xs4rZXpFTcZ5`FEq4$8-_i7YQuf{qpO*hO|9;y1 zZf)brbidR-kFIi?`~>|FPbJHklj^&GR%(<4`2{olCk|BEZ27>zz|7?7;uumf=j|QG zyu%6%3V?;rsCI_Wc`6 zG9x)2WHBV8LVo5fZ&VQwue_nSefsKkr=1wWnI#CrYUKxCN~&{zO=avshkIoT_?}g7 zEDmPEloBkMnLD$nzGE%ACdM9Ku8JB=XDw&3v1>3ygBu+gm)8p&kU)bs&NUPppFI c;?tx3?)>M^R3%JK0do+8r>mdKI;Vst0DcN?1poj5 literal 0 HcmV?d00001 diff --git a/Programs/SaraSort/Icon.png b/Programs/SaraSort/Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c233ccd4b895d961b1e7ef55152b4a2c69798b21 GIT binary patch literal 1026 zcmeAS@N?(olHy`uVBq!ia0y~yU~~Xs4rZXpFTcZ5`FEq4$8-_i7YQuf{qpO*hO|9;y1 zZf)brbidR-kFIi?`~>|FPbJHklj^&GR%(<4`2{olCk|BEZ27>zz|7+5;uumf=j|Ow zzcmgFu7QuOzHI&4z@xlA|Ux2vJY+@KfOBcxD$gpvjk!I+wnl^B!*+?(2s3T?Dzah z$EIMKQq6SWJ2$E`LA-BE8uIP0%}9P<%D~9P!XcpG&;Vr25&Uq6%hI92w!x49+{npz zd~zQ>&pFa#+j@VPl578OV4Lj@9N5 z4%r6PjMvXGFxiNqg!~P8Mj3yp^LMx-m|#8!nbOmc{>4_GQ{jL+T15QFl}+ExDv-dR zz=H&HoDO_kviEUc`JZzP|GAMQK#Fpt4!mYTNgUu%4{5m0ho+Jz;Z4c`(^J{zahwIl zXraC_p81b7F@eI^^Oo(3GN#JAjO+gH%O)j3vh%Dy9?&lLZ!$0sF?hQAxvXZ5`FEq4$8-_i7YQuf{qpO*hO|9;y1 zZf)brbidR-kFIi?`~>|FPbJHklj^&GR%(<4`2{olCk|BEZ27>zz%1hF;uumf=j~m? zyfp?Q4i`0EpQ!n^|MdM!EM%$g9R%*rd;Pt{Q*RP64*ftoVz>Vq$=HLHz zb6Kt)%ZF5kWK`JB_|*?p1jO@iSlm86zxI49!#7M8I#y8*X1>>_LaIY&+iX{k3#vITNf`vi_Tfvpe5LBfR8tw+q8T-#YyMV0pkIRAG z>=@oDV?JQ{)C%40j6HW53S4$3VI~)IhCh4I;}|=ikNMSHOqqEM?(#2gZ8?i2xxF!1 da1TrT*cXWDh(9W=tOMpL22WQ%mvv4FO#lsoa2o&s literal 0 HcmV?d00001 diff --git a/Programs/SaraView/Icon.png b/Programs/SaraView/Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4f98d41d9650883cac64e9d9f70cbb3d5ce3895f GIT binary patch literal 991 zcmeAS@N?(olHy`uVBq!ia0y~yU~~Xs4rZXpFTcZ5`FEq4$8-_i7YQuf{qpO*hO|9;y1 zZf)brbidR-kFIi?`~>|FPbJHklj^&GR%(<4`2{olCk|BEZ27>z!1U15#WAE}&fD9D z(-s>DI2^1j?wk4a-}XB>ZyorX?GqASw|nghoyk=DSL^H7m#pjL4oIND8^RCH@0UxT zysT2OL7!Oy6-M){C}%!jgV)K>G~>c#U!fW(7?dR#KIu}WISiT!=Ei3f)HR}yvJT}WZRcR`xky)@mdq6 z4y2(%{=lJa^MsJKe`mZiD>v%K#wleNQa~`g=h(sf^pYfsR^|M+wdG$a|9%0L&l^p00i_>zopr0Lt?~Gynhq literal 0 HcmV?d00001