diff --git a/Bookmarks.py b/Bookmarks.py index 8dcf9e7..c5757d5 100644 --- a/Bookmarks.py +++ b/Bookmarks.py @@ -5,10 +5,13 @@ from pathlib import Path import toml -from Core import BookmarksPath, ConfigDir +from Core import BookmarksPath, ConfigDir, IsWindows DefaultIcon = "BOOKMARK_OUTLINE" -TrashPath = Path.home() / ".local" / "share" / "Trash" / "files" +# The freedesktop trash folder (Linux). Windows' Recycle Bin isn't a folder +# that can be browsed, so there's no Trash bookmark there (deleting still goes +# to the Recycle Bin, through send2trash). +TrashPath = None if IsWindows else Path.home() / ".local" / "share" / "Trash" / "files" # One-time marker: without it, re-adding the Trash bookmark to old files (see # LoadBookmarks) would run on every load and undo the user removing it. @@ -41,7 +44,7 @@ def DefaultBookmarks() -> list[Bookmark]: Bookmark("Documents", Path.home() / "Documents", "DESCRIPTION_OUTLINED"), Bookmark("Downloads", Path.home() / "Downloads", "DOWNLOAD_OUTLINED"), Bookmark("Pictures", Path.home() / "Pictures", "IMAGE_OUTLINED"), - Bookmark("Trash", TrashPath, "DELETE_OUTLINE"), + *([Bookmark("Trash", TrashPath, "DELETE_OUTLINE")] if TrashPath else []), ] @@ -74,7 +77,7 @@ def LoadBookmarks() -> list[Bookmark]: # Trash used to be a hardcoded Sidebar entry, not a real bookmark (so it # couldn't be edited). Migrate existing bookmark files that predate that — # but only once, so deliberately removing Trash afterwards actually sticks. - if not _TrashMigrationMarker.exists(): + if TrashPath is not None and not _TrashMigrationMarker.exists(): if not any(B.Path == TrashPath for B in Bookmarks): Bookmarks.append(Bookmark("Trash", TrashPath, "DELETE_OUTLINE")) SaveBookmarks(Bookmarks) diff --git a/Browser.py b/Browser.py index 26867c4..1ffed47 100644 --- a/Browser.py +++ b/Browser.py @@ -5,7 +5,7 @@ import asyncio import ctypes import fnmatch import os -import pwd +import sys import re import shutil import subprocess @@ -108,8 +108,8 @@ def _EntryModified(Entry: Path) -> str: def _EntryOwner(Entry: Path) -> str: try: - return pwd.getpwuid(Entry.stat().st_uid).pw_name - except (OSError, KeyError): + return Entry.owner() # works on Windows too (Python 3.13+), unlike pwd + except (OSError, KeyError, NotImplementedError): return "" @@ -150,6 +150,13 @@ except OSError: def _EntryCreated(Entry: Path) -> str: + # Windows (and macOS) record it in plain stat() as st_birthtime. + try: + BirthTime = getattr(Entry.stat(), "st_birthtime", None) + except OSError: + BirthTime = None + if BirthTime: + return datetime.fromtimestamp(BirthTime).strftime("%d/%m/%y %H:%M") if _Libc is None: return "" Buf = _StatxStruct() @@ -915,7 +922,10 @@ class Browser(ft.Row): self._OpenInEditor(EntryPath) return try: - subprocess.Popen(["xdg-open", str(EntryPath)]) + if sys.platform == "win32": + os.startfile(str(EntryPath)) # the default app + else: + subprocess.Popen(["xdg-open", str(EntryPath)]) except OSError as Error: self._Notify(f"Couldn't open {EntryPath.name}: {Error}") diff --git a/Core.py b/Core.py index d79e5ab..98e059d 100644 --- a/Core.py +++ b/Core.py @@ -5,8 +5,10 @@ SaraView/Backend/DefaultThemes/CatppuccinMocha.toml and Frontend/src/Styles/Them one accent colour (Purple/Mauve) used everywhere, not a mauve+lavender mix. """ +import os import shutil import subprocess +import sys from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Callable, Optional @@ -45,7 +47,19 @@ AppName = "SaraFem" BundledFontFamily = "Fixedsys Excelsior" ActiveFontFamily = BundledFontFamily # set by ApplyAppearance, read by BuildTheme -CacheDir = Path.home() / ".cache" / "SaraFem" +IsWindows = sys.platform == "win32" + +# Each system's own per-user folders, like SaraSuite's: ~/.config and ~/.cache +# on Linux; %APPDATA% (settings) and %LOCALAPPDATA% (caches) on Windows. +if IsWindows: + ConfigHome = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") + CacheDir = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local") / "SaraFem" / "Cache" + # Where the file tree starts by default: the system drive (C:\), not "/". + DefaultFiletreeRoot = (os.environ.get("SystemDrive") or "C:") + "\\" +else: + ConfigHome = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") + CacheDir = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "SaraFem" + DefaultFiletreeRoot = "/" ThumbnailCacheDir = CacheDir / "Thumbnails" # Copy/Cut/Paste uses the real OS clipboard (Clipboard.set_files/get_files — @@ -72,7 +86,7 @@ def GetClipboardMode() -> str: # Config moved from ~/.config/SaraFem/ into a SaraSuite-wide layout so other # Sara* programs (SaraTag, SaraView) can eventually read from the same place. -SharedConfigDir = Path.home() / ".config" / "SaraSuite" +SharedConfigDir = ConfigHome / "SaraSuite" ThemesDir = SharedConfigDir / "Themes" ConfigDir = SharedConfigDir / "SaraFem" ConfigPath = ConfigDir / "Config.toml" @@ -328,7 +342,7 @@ class AppConfig: # always showing it with a "No devices." placeholder. AutoHideEmptyDevices: bool = False ShowFiletree: bool = True - FiletreeRoot: str = "/" + FiletreeRoot: str = DefaultFiletreeRoot BookmarksMinHeight: int = 0 # 0 = no constraint BookmarksMaxHeight: int = 0 DevicesMinHeight: int = 0 @@ -552,9 +566,41 @@ def ApplyAppearance(Settings: SharedSettings) -> None: ActiveFontFamily = Settings.FontFamily or BundledFontFamily +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 InstalledFontFamilies() -> list[str]: - """Font families fontconfig knows about, for the font picker. The - bundled font is always first, since it isn't installed system-wide.""" + """Font families fontconfig (or, on Windows, the registry) knows about, + for the font picker. The bundled font is always first, since it isn't + installed system-wide.""" + 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) Families = {Line.split(",")[0].strip() for Line in Result.stdout.splitlines() if Line.strip()} diff --git a/Devices.py b/Devices.py index 3d543f7..8a672e6 100644 --- a/Devices.py +++ b/Devices.py @@ -17,15 +17,23 @@ Falls back to polling /run/media/ if udisks2 isn't reachable. Everything runs on the page's own event loop (Page.run_task), so calling .update() from here is safe.""" +from __future__ import annotations # the D-Bus type hints below don't exist on Windows + import asyncio import getpass +import os +import sys from dataclasses import dataclass from pathlib import Path from typing import Callable, Optional import flet as ft -from dbus_next import BusType, Message, MessageType, Variant -from dbus_next.aio import MessageBus + +IsWindows = sys.platform == "win32" +if not IsWindows: + # udisks2 is Linux-only; Windows lists its drives itself (ListWindowsDrives). + from dbus_next import BusType, Message, MessageType, Variant + from dbus_next.aio import MessageBus from Core import AppConfig, NotificationStyle, Palette @@ -35,7 +43,7 @@ BlockInterface = f"{UDisks}.Block" FilesystemInterface = f"{UDisks}.Filesystem" DriveInterface = f"{UDisks}.Drive" -PollInterval = 3.0 # seconds, only for the no-udisks2 fallback +PollInterval = 3.0 # seconds, for Windows and the no-udisks2 fallback MountRoots = [Path("/run/media") / getpass.getuser(), Path("/media") / getpass.getuser()] @@ -71,6 +79,39 @@ def _UserMount(MountPoint: Path) -> bool: return any(Root in MountPoint.parents for Root in MountRoots) +def ListWindowsDrives(ShowInternal: bool, ShowSystemDrive: bool) -> list[Device]: + """Windows: removable drives (USB sticks, memory cards, discs with a disc + in), plus fixed and network drives with ShowInternal, and the system drive + (C:) with ShowSystemDrive. Windows mounts drives itself, so there's nothing + to mount; ejecting is left to Windows' own "Safely remove".""" + import ctypes + + Kernel = ctypes.windll.kernel32 + Removable, Fixed, Remote, CdRom = 2, 3, 4, 5 + SystemDrive = (os.environ.get("SystemDrive") or "C:").upper().rstrip("\\") + "\\" + Devices: list[Device] = [] + Mask = Kernel.GetLogicalDrives() + for Index in range(26): + if not Mask & (1 << Index): + continue + Root = f"{chr(65 + Index)}:\\" + Kind = Kernel.GetDriveTypeW(Root) + IsSystem = Root.upper() == SystemDrive + if IsSystem and not ShowSystemDrive: + continue + if Kind in (Fixed, Remote) and not (ShowInternal or IsSystem): + continue + if Kind not in (Removable, Fixed, Remote, CdRom): + continue + Name = ctypes.create_unicode_buffer(261) + # Fails for an empty card reader or disc drive: nothing to show then. + if not Kernel.GetVolumeInformationW(Root, Name, 261, None, None, None, None, 0): + continue + Fallback = {Removable: "Removable drive", CdRom: "Disc", Remote: "Network drive"}.get(Kind, "Local disk") + Devices.append(Device(Root, f"{Name.value or Fallback} ({Root[:2]})", Path(Root), IsSystemMount=IsSystem)) + return Devices + + def ListFallbackDevices() -> list[Device]: Devices: list[Device] = [] for Root in MountRoots: @@ -210,6 +251,9 @@ class DevicesPanel(ft.Container): self.Client = None async def _Start(self) -> None: + if IsWindows: + await self._PollLoop() # no udisks2: Windows' own drive list, checked every few seconds + return try: self.Client = await UDisksClient.Connect() await self.Client.Watch(self._ScheduleRefresh) @@ -221,7 +265,10 @@ class DevicesPanel(ft.Container): async def _PollLoop(self) -> None: while not self.Unmounted: - self._Show(ListFallbackDevices()) + if IsWindows: + self._Show(ListWindowsDrives(self.Config.ShowInternalDrives, self.Config.ShowSystemDrive)) + else: + self._Show(ListFallbackDevices()) await asyncio.sleep(PollInterval) def _ScheduleRefresh(self) -> None: diff --git a/Filetree.py b/Filetree.py index 33c3373..f16a662 100644 --- a/Filetree.py +++ b/Filetree.py @@ -9,7 +9,7 @@ from typing import Callable, Optional import flet as ft -from Core import AppConfig, DragGroup, MoveIntoDirectory, NotificationStyle, Palette +from Core import AppConfig, DefaultFiletreeRoot, DragGroup, MoveIntoDirectory, NotificationStyle, Palette class _FiletreeNode: @@ -119,7 +119,7 @@ class Filetree(ft.Container): ): self.OnNavigate = OnNavigate self.Config = Config - self.RootPath = RootPath or Path("/") + self.RootPath = RootPath or Path(DefaultFiletreeRoot) self.OnFilesMoved = OnFilesMoved self.SelectedNode: Optional[_FiletreeNode] = None diff --git a/Main.py b/Main.py index 4da51cf..afb52af 100644 --- a/Main.py +++ b/Main.py @@ -18,8 +18,9 @@ from Sidebar import Sidebar def _SetCursorThemeFallback() -> None: """GTK's cursor lookup reads XCURSOR_THEME directly; it isn't always inherited from the desktop session (shows as 'Unable to load ... from the cursor theme'). - Fill it in from gsettings if missing, so the warning has nowhere to come from.""" - if os.environ.get("XCURSOR_THEME"): + Fill it in from gsettings if missing, so the warning has nowhere to come from. + GTK/GNOME only: nothing to do on Windows.""" + if os.environ.get("XCURSOR_THEME") or sys.platform == "win32": return try: Result = subprocess.run( @@ -233,6 +234,9 @@ def Main(Page: ft.Page) -> None: Page.padding = 0 Page.window.width = 1100 Page.window.height = 700 + if sys.platform == "win32": + # The taskbar/title bar icon (on Linux it comes from the desktop entry). + Page.window.icon = str(Path(__file__).resolve().parent / "assets" / "Icon.ico") Page.window.title_bar_hidden = Appearance.HideWindowTitleBar PickerArgs = _ParsePickerArgs() @@ -246,7 +250,7 @@ def Main(Page: ft.Page) -> None: if __name__ == "__main__": - if "--portal-service" in sys.argv: + if "--portal-service" in sys.argv and sys.platform != "win32": # Linux's file-picker portal only # No Flet page at all here — the D-Bus FileChooser backend is a # plain asyncio service (see PortalService.py) that # spawns *this same executable* again with --pick-* flags for the diff --git a/Settings.py b/Settings.py index 18a335d..7a28e93 100644 --- a/Settings.py +++ b/Settings.py @@ -11,7 +11,7 @@ from typing import Callable import flet as ft from Core import ( - AppConfig, AvailableThemes, ChipButtonStyle, FolderIconBand, InstalledFontFamilies, LoadSharedSettings, Palette, + AppConfig, AvailableThemes, ChipButtonStyle, DefaultFiletreeRoot, FolderIconBand, InstalledFontFamilies, LoadSharedSettings, Palette, SharedSettings, ) from FolderIcons import FolderIconChoices, FolderIconLabelOverrides @@ -294,7 +294,7 @@ def BuildSettingsDialog( "Devices height", Config.DevicesMinHeight, Config.DevicesMaxHeight ) FiletreeSwitch = ft.Switch(label="Show filetree section", value=Config.ShowFiletree) - FiletreeRootField = ft.TextField(value=Config.FiletreeRoot, label="Filetree root", hint_text="/") + FiletreeRootField = ft.TextField(value=Config.FiletreeRoot, label="Filetree root", hint_text=DefaultFiletreeRoot) FiletreeHeightRow, FiletreeMinHeightField, FiletreeMaxHeightField = _MinMaxRow( "Filetree height", Config.FiletreeMinHeight, Config.FiletreeMaxHeight ) @@ -630,7 +630,7 @@ def BuildSettingsDialog( Config.AutoHideEmptyDevices = AutoHideEmptyDevicesSwitch.value Config.ShowFiletree = FiletreeSwitch.value Config.SplitBookmarksFiletreeEvenly = SplitSidebarEvenlySwitch.value - Config.FiletreeRoot = FiletreeRootField.value.strip() or "/" + Config.FiletreeRoot = FiletreeRootField.value.strip() or DefaultFiletreeRoot Config.PreviewSide = GetPreviewSide() Config.ShowPreview = PreviewSwitch.value diff --git a/assets/Icon.ico b/assets/Icon.ico new file mode 100644 index 0000000..1d2f3bc Binary files /dev/null and b/assets/Icon.ico differ diff --git a/requirements.txt b/requirements.txt index 132dd54..9918d60 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,4 @@ flet-desktop>=1.0,<2.0 # the desktop client; otherwise Flet fetches it on first pillow send2trash toml -dbus-next +dbus-next; sys_platform == "linux" # udisks2 and the file-picker portal (Linux only)