"""SaraFem entry point.""" import argparse import json import os import subprocess import sys from pathlib import Path from typing import Optional import flet as ft from Browser import Browser from Core import AppName, ApplyAppearance, BuildTheme, BundledFontFamily, EffectiveSettings, LoadConfig, ResizeHandle 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"): return try: Result = subprocess.run( ["gsettings", "get", "org.gnome.desktop.interface", "cursor-theme"], capture_output=True, text=True, timeout=2, ) Theme = Result.stdout.strip().strip("'") except (OSError, subprocess.SubprocessError): Theme = "" os.environ["XCURSOR_THEME"] = Theme or "Adwaita" def _ResolveStartPath() -> Path: """A directory (or file — its parent is used) passed on argv, so SaraFem can be launched already pointed at a location — e.g. a desktop entry's "Open with SaraFem" / "set as default file manager" handoff. Falls back to home if there's no argument or it doesn't resolve to anything real.""" if len(sys.argv) > 1: Candidate = Path(sys.argv[1]).expanduser() try: Candidate = Candidate.resolve() except OSError: Candidate = None if Candidate is not None: if Candidate.is_dir(): return Candidate if Candidate.exists(): return Candidate.parent return Path.home() def _ParsePickerArgs() -> argparse.Namespace: """Only meaningful for the file-picker path (see Picker.py) — a plain launch never sets any of these, so _ResolveStartPath's own positional-arg handling is untouched. parse_known_args so unrecognized args never crash a normal launch.""" Parser = argparse.ArgumentParser(add_help=False) PickGroup = Parser.add_mutually_exclusive_group() PickGroup.add_argument("--pick-open", action="store_true") PickGroup.add_argument("--pick-save", action="store_true") PickGroup.add_argument("--pick-folder", action="store_true") Parser.add_argument("--multiple", action="store_true") Parser.add_argument("--title", default=None) Parser.add_argument("--suggested-name", default="") Parser.add_argument("--result-file", default=None) Parser.add_argument("--filter", action="append", default=[]) Parser.add_argument("start_path", nargs="?", default=None) Args, _Unknown = Parser.parse_known_args(sys.argv[1:]) return Args def _BuildWindow(Config, StartPath: Path, **BrowserHooks) -> tuple[ft.Row, Browser]: """The normal SaraFem window (sidebar + browser). The file picker is this same window with a PickerBar under it, so the two always look alike; BrowserHooks are the picker's Browser hooks (see Picker.py).""" # STRETCH so the sidebar-width divider (a bare GestureDetector, no # layout control of its own) gets a real height to be draggable across — # confirmed here, not assumed; Flet doesn't stretch Row children by # default. Sidebar/BrowserView already fill full height regardless # (their own content drives that), so this doesn't change how they look. OuterRow = ft.Row(expand=True, spacing=0, vertical_alignment=ft.CrossAxisAlignment.STRETCH) SidebarView: Optional[Sidebar] = None SidebarDivider: Optional[ft.Control] = None def OnPathChanged(NewPath: Path) -> None: if SidebarView is not None: SidebarView.UpdateSelection(NewPath) def OnBookmarksChanged() -> None: if SidebarView is not None: SidebarView.RefreshBookmarks() def _BuildSidebarWidthDivider() -> ft.Control: """Resizes the sidebar, never BrowserView on its other side — same "only touch your own side" convention as the preview panel's divider in Browser.py, and the two are independent.""" def OnDrag(Delta: float) -> None: # SidebarSide "right" puts the divider on the sidebar's *left*, # so dragging right (growing the file list) has to shrink the # sidebar — sign flips relative to the "left" case. Signed = -Delta if Config.SidebarSide == "right" else Delta NewWidth = max(120, (SidebarView.width or Config.SidebarWidth) + Signed) SidebarView.width = NewWidth SidebarView.update() def OnDragEnd() -> None: Config.SidebarWidth = int(SidebarView.width) Config.Save() return ResizeHandle("horizontal", None, OnDrag, OnDragEnd) def BuildOuterLayout() -> list[ft.Control]: nonlocal SidebarView, SidebarDivider Layout: list[ft.Control] = [BrowserView] if Config.ShowSidebar and (Config.ShowBookmarks or Config.ShowDevices or Config.ShowFiletree): SidebarView = Sidebar( OnNavigate=BrowserView.NavigateTo, Config=Config, OnFilesMoved=BrowserView.Refresh, OnSortChanged=BrowserView.Refresh, ) SidebarDivider = _BuildSidebarWidthDivider() if Config.SidebarSide == "left": Layout[0:0] = [SidebarView, SidebarDivider] else: Layout += [SidebarDivider, SidebarView] else: SidebarView = None SidebarDivider = None return Layout def OnToggleSidebar() -> None: # Ctrl+B. Hides the existing sidebar instead of rebuilding it, so its # Devices panel keeps its udisks2 connection instead of opening a new one. Config.ShowSidebar = not Config.ShowSidebar Config.Save() if SidebarView is None: OnSidebarSettingsChanged() # it didn't exist yet (started hidden) return SidebarView.visible = Config.ShowSidebar SidebarView.update() if SidebarDivider is not None: SidebarDivider.visible = Config.ShowSidebar SidebarDivider.update() if Config.ShowSidebar: SidebarView.UpdateSelection(BrowserView.CurrentPath) def OnSidebarSettingsChanged() -> None: # Sidebar's presence/side/width and its sections aren't owned by # Browser, so rebuilding it lives here rather than needing a restart. OuterRow.controls = BuildOuterLayout() OuterRow.update() if SidebarView is not None: SidebarView.UpdateSelection(BrowserView.CurrentPath) BrowserView = Browser( StartPath=StartPath, Config=Config, OnPathChanged=OnPathChanged, OnBookmarksChanged=OnBookmarksChanged, OnSidebarSettingsChanged=OnSidebarSettingsChanged, OnToggleSidebar=OnToggleSidebar, **BrowserHooks, ) OuterRow.controls = BuildOuterLayout() return OuterRow, BrowserView def _RunPickerMode(Page: ft.Page, Config, Args: argparse.Namespace) -> None: from Picker import PickerBar, PickRequest StartPath = Path.home() if Args.start_path: Candidate = Path(Args.start_path).expanduser() if Candidate.exists(): StartPath = Candidate if Candidate.is_dir() else Candidate.parent Filters: list[tuple[str, list[str]]] = [] for RawFilter in Args.filter: Label, _, PatternsRaw = RawFilter.partition(":") Patterns = [P.strip() for P in PatternsRaw.split(",") if P.strip()] if Patterns: Filters.append((Label or "Files", Patterns)) Kind = "save" if Args.pick_save else "open" DefaultTitle = "Save File" if Args.pick_save else "Select Folder" if Args.pick_folder else "Open File" Request = PickRequest( Kind=Kind, Multiple=Args.multiple, Directory=Args.pick_folder, Title=Args.title or DefaultTitle, SuggestedName=Args.suggested_name, StartPath=StartPath, Filters=Filters, ) ResultPath = Path(Args.result_file) if Args.result_file else None Page.title = Request.Title def OnComplete(Paths: Optional[list[Path]]) -> None: if ResultPath is not None: if Paths is None: ResultPath.write_text(json.dumps({"cancelled": True})) else: ResultPath.write_text(json.dumps({"paths": [str(P) for P in Paths]})) Page.run_task(Page.window.destroy) Bar = PickerBar(Request=Request, OnComplete=OnComplete) OuterRow, BrowserView = _BuildWindow( Config, Request.StartPath, EntryFilter=Bar.Filter, OnActivateFile=Bar.Activate, OnSelectionChanged=Bar.SelectionChanged, OnEscape=Bar.Cancel, ) Bar.Attach(BrowserView) Page.add(ft.Column([OuterRow, Bar], expand=True, spacing=0)) def Main(Page: ft.Page) -> None: Config = LoadConfig() Appearance = EffectiveSettings(Config) # shared SaraSuite settings, or SaraFem's own ApplyAppearance(Appearance) # must run before anything reads Palette.X Page.theme_mode = ft.ThemeMode.DARK Page.theme = BuildTheme() Page.dark_theme = BuildTheme() Page.fonts = {BundledFontFamily: "fonts/Fixedsys_Excelsior_Regular.ttf"} Page.padding = 0 Page.window.width = 1100 Page.window.height = 700 Page.window.title_bar_hidden = Appearance.HideWindowTitleBar PickerArgs = _ParsePickerArgs() if PickerArgs.pick_open or PickerArgs.pick_save or PickerArgs.pick_folder: _RunPickerMode(Page, Config, PickerArgs) return Page.title = AppName OuterRow, _BrowserView = _BuildWindow(Config, _ResolveStartPath()) Page.add(OuterRow) if __name__ == "__main__": if "--portal-service" in sys.argv: # 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 # actual picker UI. Packaged installs ship one executable that # covers all three modes (GUI / --pick-* / --portal-service) rather # than a separate binary per role. import asyncio from PortalService import Run as RunPortalService asyncio.run(RunPortalService()) else: _SetCursorThemeFallback() ft.run(Main)