mirror of
https://git.srhtnin.garden/sarah/SaraFem.git
synced 2026-10-13 18:18:11 +02:00
- New optional list-view columns: Description, Size (now includes directories), Modified, Owner, Created (via statx birth time), Content (item/line count or image dimensions), Access (rwx) - Drag-to-resize: list view column headers, sidebar sections (Bookmarks/ Devices/Filetree), Preview/Tags split, and the sidebar/preview side panels themselves — all commit to Config on release, not per drag frame - Column resizing mutates existing row cells live instead of rebuilding, with metadata cached per (file, mtime) — fixes lag in large folders - Fix list view background click (rows were full ListTile width with no empty space to click); bookmark selected-background now covers the triple-dot menu too; batch rename/new-file dialogs pre-select their default text instead of just pre-filling it - Toolbar scrolls instead of clipping when buttons don't fit; toolbar and breadcrumbs are independently toggleable - Add a "Server" bookmark icon Co-Authored-By: Claude Sonnet 5 <[email protected]>
261 lines
10 KiB
Python
261 lines
10 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, 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
|
|
)
|
|
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)
|