From 8eb9943a30c940fa7c6fe6ab2ca22548efcfa9d9 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Fri, 9 Oct 2026 14:21:42 +0200 Subject: [PATCH] Windows support --- .saratag/Core.py | 5 ++- Books.py | 2 +- Core.py | 18 +++++++-- Lexicons.py | 2 +- Main.py | 6 ++- Packaging/SaraSuite/Icon.png | Bin 0 -> 1042 bytes Packaging/SaraSuite/Manifest.toml | 17 +++++++++ Packaging/SaraSuite/Templates/Launch.sh | 4 ++ Packaging/SaraSuite/Templates/Location.toml | 3 +- Packaging/SaraSuite/Templates/SaraTag.desktop | 3 +- Records.py | 2 +- Settings.py | 36 +++++++++++++++++- Widgets.py | 11 ++++-- assets/Icon.ico | Bin 0 -> 7209 bytes 14 files changed, 92 insertions(+), 17 deletions(-) create mode 100644 Packaging/SaraSuite/Icon.png create mode 100644 assets/Icon.ico diff --git a/.saratag/Core.py b/.saratag/Core.py index d74aadf..8d2229e 100755 --- a/.saratag/Core.py +++ b/.saratag/Core.py @@ -59,6 +59,9 @@ SaraTagRecordsPath = ApplicationDirectory / ".SaraTagRecords.toml" SaraTagHistoryPath = ApplicationDirectory / ".SaraTagHistory.toml" +# Empty means the default app; Windows rarely has mpv. +DefaultVideoPlayer = "" if sys.platform == "win32" else "mpv" + DefaultSaraTagConf = f'''# SaraTag's main portable configuration file. # Paths in this file are interpreted relative to the SaraTag directory # unless they are absolute. @@ -83,7 +86,7 @@ ShowInvalidEntries = true ShowImagePreviews = true [Media] -VideoPlayer = "mpv" +VideoPlayer = "{DefaultVideoPlayer}" [History] Enabled = true diff --git a/Books.py b/Books.py index 83d4759..1ac4d02 100644 --- a/Books.py +++ b/Books.py @@ -207,7 +207,7 @@ def RenameFile(SourcePath: Path, NewName: str) -> Path: """Renames a file and its Book together; undoes the file rename if the Book can't follow. Returns the new path.""" NewName = NewName.strip() - if not NewName or "/" in NewName or NewName in (".", ".."): + if not NewName or "/" in NewName or "\\" in NewName or NewName in (".", ".."): raise DataError("That isn't a valid file name.") if NewName.startswith("."): raise DataError("Names starting with a dot are hidden, and SaraTag skips hidden files.") diff --git a/Core.py b/Core.py index 6f091c7..25d2c10 100644 --- a/Core.py +++ b/Core.py @@ -23,9 +23,17 @@ AppVersion = "0.2.0-beta" BundledFontFamily = "Fixedsys Excelsior" ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme +IsWindows = sys.platform == "win32" + +# Each system's own per-user folders, like SaraSuite's: ~/.config and +# ~/.local/share on Linux, %APPDATA% and %LOCALAPPDATA% on Windows. Home = Path.home() -ConfigHome = Home / ".config" -DataHome = Home / ".local" / "share" +if IsWindows: + ConfigHome = Path(os.environ.get("APPDATA") or Home / "AppData" / "Roaming") + DataHome = Path(os.environ.get("LOCALAPPDATA") or Home / "AppData" / "Local") +else: + ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Home / ".config") + DataHome = Path(os.environ.get("XDG_DATA_HOME") or Home / ".local" / "share") SharedConfigDir = ConfigHome / "SaraSuite" ThemesDir = SharedConfigDir / "Themes" @@ -222,6 +230,8 @@ def ChildEnvironment() -> dict[str, str]: Environment.pop("LD_LIBRARY_PATH", None) for Key in ("PYTHONHOME", "PYTHONPATH"): Environment.pop(Key, None) + if IsWindows: + Environment["PYTHONUTF8"] = "1" # every Sara program reads and writes UTF-8 return Environment @@ -301,8 +311,8 @@ def Heading(Text: str) -> ft.Text: def DisplayPath(PathText: str) -> str: - """/home/you/Pictures -> ~/Pictures, for labels.""" + """/home/you/Pictures -> ~/Pictures, for labels (C:\\Users\\you\\Pictures -> ~\\Pictures).""" HomeText = str(Home) - if PathText == HomeText or PathText.startswith(HomeText + "/"): + if PathText == HomeText or PathText.startswith(HomeText + os.sep): return "~" + PathText[len(HomeText):] return PathText diff --git a/Lexicons.py b/Lexicons.py index 163c650..5f4aff1 100644 --- a/Lexicons.py +++ b/Lexicons.py @@ -363,7 +363,7 @@ def KnownLexicons(Directories: list[Path]) -> list[Path]: def NewSharedLexicon(Name: str) -> Path: """An empty Lexicon in Lexicons/ for other Lexicons to inherit from.""" Name = Name.strip().removesuffix(".toml") - if not Name or "/" in Name or Name.startswith("."): + if not Name or "/" in Name or "\\" in Name or Name.startswith("."): raise DataError("That isn't a valid file name.") LexiconsDir.mkdir(parents=True, exist_ok=True) FilePath = LexiconsDir / f"{Name}.toml" diff --git a/Main.py b/Main.py index 8953b37..1f5ead9 100644 --- a/Main.py +++ b/Main.py @@ -4,7 +4,9 @@ The window and navigation; each page lives in its own *Page.py.""" import flet as ft from BrowsePage import BrowsePage -from Core import AppName, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, EffectiveSettings, LoadConfig, Palette +from Core import ( + AppName, ApplyAppearance, AssetsDir, BuildTheme, BundledFontFamily, EffectiveSettings, IsWindows, LoadConfig, Palette, +) from LexiconsPage import LexiconsPage from LibrariesPage import LibrariesPage from Library import TagCore @@ -40,6 +42,8 @@ def Main(Page: ft.Page) -> None: Page.bgcolor = Palette.Base Page.window.width = 1280 Page.window.height = 820 + if IsWindows: + Page.window.icon = str(AssetsDir / "Icon.ico") # taskbar/title bar (Linux: the desktop entry) Page.window.min_width = 900 Page.window.min_height = 560 diff --git a/Packaging/SaraSuite/Icon.png b/Packaging/SaraSuite/Icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1c39a3389c403299235860115333e18aad4f18ed GIT binary patch literal 1042 zcmeAS@N?(olHy`uVBq!ia0y~yU~~Xs4rZXpFTcZ5`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/Packaging/SaraSuite/Manifest.toml b/Packaging/SaraSuite/Manifest.toml index 0b1e2dc..6b8fb54 100644 --- a/Packaging/SaraSuite/Manifest.toml +++ b/Packaging/SaraSuite/Manifest.toml @@ -1,5 +1,6 @@ # Kept in sync between SaraSuite/Programs/SaraTag/ and SaraTag/Packaging/SaraSuite/. Name = "SaraTag" +Platforms = ["Linux", "Windows"] Description = "Tag files with Books (sidecars) defined by Lexicons: browse, filter and sort by tag, edit tags on many files at once." [Version] @@ -20,18 +21,34 @@ RequirementsFile = "requirements.txt" Source = "Location.toml" Target = "{AppDir}/.saratag/Location.toml" # tells .saratag/Core.py where the data lives +# Linux: a launcher and a desktop entry. [[Templates]] +Platform = "Linux" Source = "Launch.sh" Target = "{AppDir}/Packaging/Launch.sh" Executable = true [[Templates]] +Platform = "Linux" Source = "SaraTag.desktop" Target = "{ApplicationsDir}/SaraTag.desktop" +# Windows: a Start Menu shortcut (pythonw.exe Main.py, from the app folder). +[[Shortcuts]] +Platform = "Windows" +Name = "SaraTag" +Script = "Main.py" +Icon = "assets/Icon.png" + # Made by SaraTag itself. The Lexicons are your tag definitions: only tick # "remove configs" if you don't need them any more. [[Leftovers]] Path = "{ConfigDir}" Kind = "Config" Note = "SaraTag's settings, Records, Libraries, history and Lexicons (Books next to your files are never touched)" + +# Shared with the other Flet programs; see Programs/FletIdentity.py. +[[Templates]] +Platform = "Linux" +Source = "../../FletIdentity.py" +Target = "{ProgramDir}/FletIdentity.py" diff --git a/Packaging/SaraSuite/Templates/Launch.sh b/Packaging/SaraSuite/Templates/Launch.sh index c606b43..70e1413 100644 --- a/Packaging/SaraSuite/Templates/Launch.sh +++ b/Packaging/SaraSuite/Templates/Launch.sh @@ -1,4 +1,8 @@ #!/bin/sh # Written by SaraSuite. Runs from the app folder so Flet finds assets/. +# A Flet client copy with the window app ID srhtnin.{Name}, so the desktop +# matches the window to {Name}.desktop (StartupWMClass) instead of showing it +# as "com.appveyor.flet". Empty if that fails: Flet then uses its own client. +export FLET_VIEW_PATH="$("{Python}" "{ProgramDir}/FletIdentity.py" "srhtnin.{Name}")" cd "{AppDir}" || exit 1 exec "{Python}" Main.py "$@" diff --git a/Packaging/SaraSuite/Templates/Location.toml b/Packaging/SaraSuite/Templates/Location.toml index 7d04cd3..ad2a9f1 100644 --- a/Packaging/SaraSuite/Templates/Location.toml +++ b/Packaging/SaraSuite/Templates/Location.toml @@ -1,3 +1,4 @@ # Written by SaraSuite. Where SaraTag keeps SaraTagConf.toml, its Records, # history and Lexicons (see .saratag/Core.py). -DataDir = "{ConfigDir}" +# A literal ('...') string: Windows paths are full of backslashes. +DataDir = '{ConfigDir}' diff --git a/Packaging/SaraSuite/Templates/SaraTag.desktop b/Packaging/SaraSuite/Templates/SaraTag.desktop index afcc970..a843cd0 100644 --- a/Packaging/SaraSuite/Templates/SaraTag.desktop +++ b/Packaging/SaraSuite/Templates/SaraTag.desktop @@ -5,5 +5,6 @@ Comment=Tag files with Books defined by Lexicons Exec={AppDir}/Packaging/Launch.sh Icon={AppDir}/assets/Icon.png Terminal=false -Categories=Utility;Graphics; +Categories=Utility; StartupNotify=true +StartupWMClass=srhtnin.{Name} diff --git a/Records.py b/Records.py index cddecb2..02cf692 100644 --- a/Records.py +++ b/Records.py @@ -253,7 +253,7 @@ def RenameRecordDirectory(Index: int, NewDirectoryName: str) -> Path: if TheRecord is None: raise DataError("That Record isn't in .SaraTagRecords.toml any more; reload.") NewDirectoryName = NewDirectoryName.strip() - if not NewDirectoryName or "/" in NewDirectoryName or NewDirectoryName.startswith("."): + if not NewDirectoryName or "/" in NewDirectoryName or "\\" in NewDirectoryName or NewDirectoryName.startswith("."): raise DataError("That isn't a valid directory name.") OldPath = TheRecord.Directory NewPath = OldPath.with_name(NewDirectoryName) diff --git a/Settings.py b/Settings.py index 3f96634..597a55f 100644 --- a/Settings.py +++ b/Settings.py @@ -12,7 +12,7 @@ import tomlkit from Core import ( AppConfig, AvailableThemes, BundledFontFamily, ChildEnvironment, ChipButtonStyle, ExportSettings, Home, - ImportSettings, LoadSharedSettings, Palette, SharedSettings, + ImportSettings, IsWindows, LoadSharedSettings, Palette, SharedSettings, ) from Library import ConfPath, ConfValue, DataDir, DataError, SaveConfValues from Widgets import App @@ -28,8 +28,40 @@ def _Note(Text: str) -> ft.Text: return ft.Text(Text, color=Palette.Overlay0, size=12) +def _WindowsFonts() -> set[str]: + """Font families from the system and per-user Fonts registry keys + ("Arial Bold (TrueType)" -> "Arial").""" + import re + import winreg + + Families: set[str] = set() + Key = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" + for Root in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER): + try: + with winreg.OpenKey(Root, Key) as Fonts: + Index = 0 + while True: + try: + Name = winreg.EnumValue(Fonts, Index)[0] + except OSError: + break + Index += 1 + Name = re.sub(r"\s*\(.*\)$", "", Name) + Name = re.sub(r"\s+(Bold|Italic|Light|Semibold|SemiBold|Black|Regular|Medium|Thin)\b.*$", "", Name) + if Name and "&" not in Name: + Families.add(Name.strip()) + except OSError: + continue + return Families + + def _Fonts() -> list[str]: - """Installed font families (fontconfig), with the bundled font first.""" + """Installed font families (fontconfig, or the registry on Windows), with + the bundled font first.""" + if IsWindows: + Families = _WindowsFonts() + Families.discard(BundledFontFamily) + return [BundledFontFamily] + sorted(Families, key=str.lower) try: Result = subprocess.run( ["fc-list", ":", "family"], capture_output=True, text=True, timeout=5, env=ChildEnvironment(), diff --git a/Widgets.py b/Widgets.py index ba9dc23..22c1b2b 100644 --- a/Widgets.py +++ b/Widgets.py @@ -12,7 +12,7 @@ from typing import Callable, Optional import flet as ft -from Core import AppConfig, ChildEnvironment, ChipButton, Note, NotificationStyle, Palette +from Core import AppConfig, ChildEnvironment, ChipButton, IsWindows, Note, NotificationStyle, Palette from Library import ConfValue @@ -72,7 +72,7 @@ class App: else to the default app.""" from Query import VideoExtensions - Command = ["xdg-open", str(TargetPath)] + Command = None if IsWindows else ["xdg-open", str(TargetPath)] # Windows: os.startfile below if TargetPath.suffix.lower() in VideoExtensions: Player = str(ConfValue("Media", "VideoPlayer", "") or "").strip() if Player and shutil.which(Player.split()[0]): @@ -80,8 +80,11 @@ class App: elif Player: self.Notify(f"Video player '{Player}' isn't installed; using the default app.", "warning") try: - subprocess.Popen(Command, env=ChildEnvironment(), start_new_session=True, - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if Command is None: + os.startfile(str(TargetPath)) # the default app + else: + subprocess.Popen(Command, env=ChildEnvironment(), start_new_session=True, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except OSError as Error: self.Notify(f"Couldn't open {TargetPath.name}: {Error}", "error") diff --git a/assets/Icon.ico b/assets/Icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..ec5faed8c449c73fe22879b6ece32f7864c7120a GIT binary patch literal 7209 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�US4b zm?ZD zOyH|w`u2lH+l9QTV9{D{ch=CiNLXU4WTYGLsE zW3xZR$f6f?*$k^7PTU*cK%P8Pp<6~=+Jm2Ou+H3Ml5dE6^CQ{!)n(wj4H_(L?KJ(Kco##xUw;xq{MRY7 zqq7q!Ado|}N|=wO6<`i-@b=F@#C?-&H=u=_U)p|XmtNkcU=mc*n)C<{O_353NjgKtv@fl|_BT380IC(2xeC`S z) z`3YcFokQ~Kq#c^<^UG|!1aKLQWUT4WPDcev%|k*ttYeU5E7-;!L?RcUTamqd1aKzF zy+=P`q!&q^R{N0LTU_H770Le~lK)dA|NqcrB0vC#r0mdqR~watl9Yi-3gRl8%&T{3 ztl4bDqyBXbeU`?5lHwM4at)L%-gN7Cye(^s2WS1I>Ld+UnTvv`YakqUMYTr+l```D zM-P^2buKlBHfRMN4&K?tT-8o=Ny~IOoN2wS5K!tfSWqXymE^uVP(KDf=-nOCjrfBy zoAv=7-XM&8hlUwEcky5EyT2J{p-;M{0R@8r&a(55MyfY3G?N$QwnV}U&J`gHxf*qi zO{eB^H`;Yfr-mEgt(#KW;y5g|WI?-DfxU&7oM~WkSs%Cz-7VIy!m^kz+|lD-qeMSw zhGP*~&r^h4o-F*5a$-(ZLl9L(&mdmSPDbbCAmW|$cPa7wz29G`;hz=5Ld3yM z%7b=(LF0f06%3+Qh|E};$B69zuH8T+HVF8$cC!ntas>c#vA=7#UwkPnhV01MrL&>X z5>^mcFV;yglX-?A4@jLSgIu@-jkTkt@+F5@@+qCk-7G{tPb*n22ruj%4yk-R)PjHf zV*UNr$s!6yleROBrU4oR00yCdhmfEhN;k{{;=;<@;S6R&z5Q_80yOaE^1u^HNqV^Y z5kU?F%?ADxzSC`-K}s+Kaa8{Rx@k(Ohyuh;3vOFs|0K$ygQN?0IFWU)5CzvT{CDI3 z;md!Z@=-h;&m*IV%joMfanVgt&AUwWGN*d;x$q48+V*rSVYBen{ju%cqsmuVQr%>L z6ZjvXhoE;1DI^ZR(nEw_&3o*pq3+unXORQ&-uMS#f1^OvYxvwqvv2M2J(rck`t4xi zRV%h{X5UiLje7*Qp_+~Fr%HP7V*oUK3K3F>aK1|%QHmK+-$fs7+Sb;#rD_bE>Q}P{ zWk=g9Ld!|@I~3mql~b2?Es{CZCi85TpH(aIclhxu1e@cAD`~mCHZTl(!>6j1H>KQb zOKsoV#@eI`P-!S>nVkMKxGE;ztCP+38M@X5hMy-}o7^ zlV#NR`(pneZd@3e8MT36;^$Bm-{FAY@NZsgG_YPELg z)YbnZ+}HNWJ48-5JVHvG03!g7I#u`*Nw`Z1)PJf6|Blj#zUo9X_Da%(RX0wB!)fNgaEa{3gAXVD)U)3t+0 LANT&}yUTw8o<*Uk literal 0 HcmV?d00001