Files
SaraFem/Main.py
T

229 lines
8.4 KiB
Python

"""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, ApplyTheme, BuildTheme, FontFamily, LoadConfig
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)."""
OuterRow = ft.Row(expand=True, spacing=0)
SidebarView: Optional[Sidebar] = 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 BuildOuterLayout() -> list[ft.Control]:
nonlocal SidebarView
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
)
if Config.SidebarSide == "left":
Layout.insert(0, SidebarView)
else:
Layout.append(SidebarView)
else:
SidebarView = 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 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()
ApplyTheme(Config.Theme) # must run before anything reads Palette.X
Page.theme_mode = ft.ThemeMode.DARK
Page.theme = BuildTheme()
Page.dark_theme = BuildTheme()
Page.fonts = {FontFamily: "fonts/Fixedsys_Excelsior_Regular.ttf"}
Page.padding = 0
Page.window.width = 1100
Page.window.height = 700
Page.window.title_bar_hidden = Config.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)