mirror of
https://git.srhtnin.garden/sarah/SaraFem.git
synced 2026-10-13 18:18:11 +02:00
196 lines
7.5 KiB
Python
196 lines
7.5 KiB
Python
"""File-picker mode. The picker is the normal SaraFem window (sidebar,
|
|
toolbar, list/grid, preview, the same menus and shortcuts) with this bar
|
|
along the bottom: a file name field when saving, a file type filter when
|
|
the app asked for one, and Cancel/confirm. Used two ways:
|
|
|
|
- `python Main.py --pick-open/--pick-save/--pick-folder ...` directly, for
|
|
scripts.
|
|
- Launched as a subprocess by PortalService.py, which is what actually
|
|
makes SaraFem show up as the system file picker (the
|
|
org.freedesktop.impl.portal.FileChooser backend); see that file for the
|
|
D-Bus side of this.
|
|
|
|
Either way, the result goes to the OnComplete callback (Main.py writes it to
|
|
--result-file as JSON); nothing here talks D-Bus directly."""
|
|
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Callable, Optional
|
|
|
|
import flet as ft
|
|
|
|
from Core import ChipButtonStyle, Palette
|
|
|
|
if TYPE_CHECKING:
|
|
from Browser import Browser
|
|
|
|
|
|
@dataclass
|
|
class PickRequest:
|
|
Kind: str # "open" or "save"
|
|
Multiple: bool = False
|
|
Directory: bool = False
|
|
Title: str = "Select a File"
|
|
SuggestedName: str = ""
|
|
StartPath: Path = field(default_factory=Path.home)
|
|
# (filter label, [glob patterns]), offered in a dropdown with the first
|
|
# one selected, like other pickers. Empty list means "show everything".
|
|
Filters: list[tuple[str, list[str]]] = field(default_factory=list)
|
|
|
|
|
|
class PickerBar(ft.Container):
|
|
def __init__(self, Request: PickRequest, OnComplete: Callable[[Optional[list[Path]]], None]):
|
|
self.Request = Request
|
|
self.OnComplete = OnComplete
|
|
self.BrowserView: Optional["Browser"] = None
|
|
self.FilterIndex = 0 if Request.Filters else -1
|
|
self.Completed = False
|
|
|
|
Controls: list[ft.Control] = []
|
|
self.NameField: Optional[ft.TextField] = None
|
|
if Request.Kind == "save":
|
|
self.NameField = ft.TextField(
|
|
value=Request.SuggestedName, label="File name", expand=True, autofocus=True,
|
|
on_submit=lambda e: self.Confirm(),
|
|
on_focus=lambda e: self._PauseShortcuts(True),
|
|
on_blur=lambda e: self._PauseShortcuts(False),
|
|
)
|
|
Controls.append(self.NameField)
|
|
else:
|
|
Controls.append(ft.Container(expand=True))
|
|
|
|
if Request.Filters and not Request.Directory:
|
|
Controls.append(ft.Dropdown(
|
|
width=260,
|
|
value="0",
|
|
options=[
|
|
ft.DropdownOption(key=str(Index), text=f"{Label} ({', '.join(Patterns)})")
|
|
for Index, (Label, Patterns) in enumerate(Request.Filters)
|
|
] + [ft.DropdownOption(key="-1", text="All files")],
|
|
on_select=self._OnFilterSelected,
|
|
))
|
|
|
|
Controls.append(ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: self.Cancel()))
|
|
Controls.append(ft.OutlinedButton(content=self._ConfirmLabel(), style=ChipButtonStyle(), on_click=lambda e: self.Confirm()))
|
|
|
|
super().__init__(
|
|
content=ft.Row(Controls, spacing=8, vertical_alignment=ft.CrossAxisAlignment.CENTER),
|
|
padding=8,
|
|
bgcolor=Palette.Mantle,
|
|
border=ft.Border.only(top=ft.BorderSide(1, Palette.Surface1)),
|
|
)
|
|
|
|
def Attach(self, BrowserView: "Browser") -> None:
|
|
self.BrowserView = BrowserView
|
|
self._PauseShortcuts(self.NameField is not None) # the name field starts focused
|
|
|
|
def _PauseShortcuts(self, Paused: bool) -> None:
|
|
if self.BrowserView is not None:
|
|
self.BrowserView.ShortcutsPaused = Paused
|
|
|
|
def _ConfirmLabel(self) -> str:
|
|
if self.Request.Kind == "save":
|
|
return "Save"
|
|
if self.Request.Directory:
|
|
return "Select Folder"
|
|
return "Open"
|
|
|
|
# --- Hooks the Browser calls ------------------------------------------
|
|
|
|
def Filter(self, EntryPath: Path) -> bool:
|
|
if EntryPath.is_dir():
|
|
return True
|
|
if self.Request.Directory:
|
|
return False
|
|
if self.FilterIndex < 0:
|
|
return True
|
|
_Label, Patterns = self.Request.Filters[self.FilterIndex]
|
|
NameLower = EntryPath.name.lower()
|
|
return any(Path(NameLower).match(Pattern.lower()) for Pattern in Patterns)
|
|
|
|
def Activate(self, EntryPath: Path) -> None:
|
|
"""Double-click/Enter on a file."""
|
|
if self.Request.Kind == "save" and self.NameField is not None:
|
|
self.NameField.value = EntryPath.name
|
|
self.Confirm()
|
|
elif not self.Request.Directory:
|
|
self._Finish([EntryPath])
|
|
|
|
def SelectionChanged(self) -> None:
|
|
# Picking an existing file while saving fills in its name, like other pickers.
|
|
if self.Request.Kind != "save" or self.NameField is None or self.BrowserView is None:
|
|
return
|
|
Selected = self.BrowserView.SelectedPath
|
|
if Selected is not None and not Selected.is_dir() and self.NameField.value != Selected.name:
|
|
self.NameField.value = Selected.name
|
|
self.NameField.update()
|
|
|
|
def _OnFilterSelected(self, Event) -> None:
|
|
self.FilterIndex = int(Event.control.value)
|
|
if self.BrowserView is not None:
|
|
self.BrowserView.Refresh()
|
|
|
|
# --- Confirm / cancel ---------------------------------------------------
|
|
|
|
def Confirm(self) -> None:
|
|
View = self.BrowserView
|
|
if View is None:
|
|
return
|
|
Selected = [Entry for Entry in View.CurrentEntries if Entry in View.SelectedPaths]
|
|
|
|
if self.Request.Kind == "save":
|
|
Name = (self.NameField.value or "").strip()
|
|
if not Name:
|
|
return
|
|
Target = View.CurrentPath / Name
|
|
if Target.is_dir():
|
|
View.NavigateTo(Target)
|
|
elif Target.exists():
|
|
self._ConfirmOverwrite(Target)
|
|
else:
|
|
self._Finish([Target])
|
|
return
|
|
|
|
if self.Request.Directory:
|
|
Folders = [Entry for Entry in Selected if Entry.is_dir()]
|
|
if not self.Request.Multiple:
|
|
Folders = Folders[:1]
|
|
self._Finish(Folders or [View.CurrentPath])
|
|
return
|
|
|
|
if not self.Request.Multiple:
|
|
Selected = [View.SelectedPath] if View.SelectedPath is not None else []
|
|
if len(Selected) == 1 and Selected[0].is_dir():
|
|
View.NavigateTo(Selected[0]) # "Open" on a folder goes into it
|
|
return
|
|
Files = [Entry for Entry in Selected if not Entry.is_dir()]
|
|
if Files:
|
|
self._Finish(Files)
|
|
|
|
def _ConfirmOverwrite(self, Target: Path) -> None:
|
|
def Replace(e) -> None:
|
|
self.page.pop_dialog()
|
|
self._Finish([Target])
|
|
|
|
self.page.show_dialog(ft.AlertDialog(
|
|
title="Replace file?",
|
|
content=ft.Text(f'"{Target.name}" already exists in {Target.parent}. Replace it?'),
|
|
actions=[
|
|
ft.OutlinedButton(content="Cancel", style=ChipButtonStyle(), on_click=lambda e: self.page.pop_dialog()),
|
|
ft.OutlinedButton(content="Replace", style=ChipButtonStyle(), on_click=Replace),
|
|
],
|
|
))
|
|
|
|
def _Finish(self, Paths: list[Path]) -> None:
|
|
# Enter in the name field fires both its on_submit and the page's
|
|
# keyboard handler, so this can be reached twice for one confirm.
|
|
if self.Completed:
|
|
return
|
|
self.Completed = True
|
|
self.OnComplete(Paths)
|
|
|
|
def Cancel(self) -> None:
|
|
if not self.Completed:
|
|
self.Completed = True
|
|
self.OnComplete(None)
|