mirror of
https://git.srhtnin.garden/sarah/SaraFem.git
synced 2026-10-15 10:23:17 +02:00
Initial commit
This commit is contained in:
commit
4c8d272719
31 files changed
+3870
No files matched your search
@@ -0,0 +1,214 @@
|
||||
"""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 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,
|
||||
**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)
|
||||
Reference in new issue
Block a user