Files
SaraFem/Main.py
T
sarahandClaude Sonnet 5 31b6c554e6 List view metadata columns and drag-to-resize everywhere
- 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]>
2026-09-26 17:22:55 +02:00

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)