mirror of
https://git.srhtnin.garden/sarah/SaraFem.git
synced 2026-10-14 02:18:11 +02:00
Add search (Ctrl+F, wildcards, match case/whole word) and sort, both off by default
This commit is contained in:
1 parent
6c48796dde
commit
1c2acf3f16
5 files changed
+249
-5
No files matched your search
+152
-3
@@ -3,8 +3,10 @@ plus the breadcrumbs/preview/dotfiles toggles from Config.toml."""
|
||||
|
||||
import asyncio
|
||||
import ctypes
|
||||
import fnmatch
|
||||
import os
|
||||
import pwd
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
@@ -328,6 +330,40 @@ class Browser(ft.Row):
|
||||
# None: the file list shows. A Path: EditorView fills the file area
|
||||
# instead (Features/Major: "Different display in middle").
|
||||
self.OpenFilePath: Optional[Path] = None
|
||||
# Live filter below the breadcrumbs/toolbar bar (Ctrl+F). Session-
|
||||
# only — Match Case/Whole Word aren't durable preferences (same idea
|
||||
# as a browser's find bar), and SearchBarVisible only starts from
|
||||
# Config.SearchAlwaysVisible; toggling it never touches Config.
|
||||
self.SearchText = ""
|
||||
self.MatchCase = False
|
||||
self.MatchWholeWord = False
|
||||
self.SearchBarVisible = self.Config.SearchAlwaysVisible
|
||||
self.SearchField = ft.TextField(
|
||||
hint_text="Search this folder... * and ? wildcards work, e.g. *.png",
|
||||
dense=True,
|
||||
expand=True,
|
||||
border=ft.InputBorder.OUTLINE,
|
||||
on_change=self._OnSearchChanged,
|
||||
on_focus=lambda e: self._SetShortcutsPaused(True),
|
||||
on_blur=lambda e: self._SetShortcutsPaused(False),
|
||||
)
|
||||
self.MatchCaseButton = ft.OutlinedButton(
|
||||
content=ft.Text("A", weight=ft.FontWeight.BOLD, size=13),
|
||||
tooltip="Match case",
|
||||
style=self._SearchToggleStyle(False),
|
||||
on_click=self._ToggleMatchCase,
|
||||
)
|
||||
self.MatchWholeWordButton = ft.OutlinedButton(
|
||||
content=ft.Text("Aa", size=13, style=ft.TextStyle(decoration=ft.TextDecoration.UNDERLINE)),
|
||||
tooltip="Match whole word",
|
||||
style=self._SearchToggleStyle(False),
|
||||
on_click=self._ToggleMatchWholeWord,
|
||||
)
|
||||
self.SearchBar = ft.Container(
|
||||
visible=self.SearchBarVisible,
|
||||
padding=ft.Padding.symmetric(horizontal=8, vertical=6),
|
||||
content=ft.Row([self.SearchField, self.MatchCaseButton, self.MatchWholeWordButton], spacing=4),
|
||||
)
|
||||
# SaraSortd integration (Features/Major): "*.png -> ~/Pictures" style
|
||||
# lines for the currently-browsed directory. Mutated in place by
|
||||
# _RefreshSortBanner on every NavigateTo, rather than rebuilt as part
|
||||
@@ -512,6 +548,12 @@ class Browser(ft.Row):
|
||||
if ShowBar and self.Config.BreadcrumbsPosition == "top":
|
||||
Items += [self._BuildToolbarBar(), ft.Divider(height=1, color=Palette.Surface1)]
|
||||
|
||||
# Always directly above the file area (below breadcrumbs in the
|
||||
# default top position; top of the file area either way if
|
||||
# breadcrumbs are set to "bottom" instead).
|
||||
if self.Config.ShowSearch:
|
||||
Items.append(self.SearchBar)
|
||||
|
||||
# KeyboardListener tracks live Shift/Ctrl-held state for click
|
||||
# modifiers (TapEvent carries no modifier info in Flet, unlike
|
||||
# Page.on_keyboard_event, which is keydown-only and can't see releases —
|
||||
@@ -900,6 +942,52 @@ class Browser(ft.Row):
|
||||
if self.Config.ViewMode == "list" and self.CurrentEntries:
|
||||
self.page.run_task(self._PopulateEntriesAsync, self.EntriesGeneration, list(self.CurrentEntries))
|
||||
|
||||
def _SearchToggleStyle(self, Active: bool) -> ft.ButtonStyle:
|
||||
return ft.ButtonStyle(
|
||||
color=Palette.Mauve if Active else Palette.Text,
|
||||
bgcolor=Palette.Surface1 if Active else Palette.Surface0,
|
||||
side=ft.BorderSide(1, Palette.Mauve),
|
||||
shape=ft.RoundedRectangleBorder(radius=4),
|
||||
)
|
||||
|
||||
def _OnSearchChanged(self, Event: ft.ControlEvent) -> None:
|
||||
self.SearchText = self.SearchField.value or ""
|
||||
self._RefreshEntries()
|
||||
|
||||
def _ToggleMatchCase(self, Event: ft.ControlEvent) -> None:
|
||||
self.MatchCase = not self.MatchCase
|
||||
self.MatchCaseButton.style = self._SearchToggleStyle(self.MatchCase)
|
||||
self.MatchCaseButton.update()
|
||||
if self.SearchText:
|
||||
self._RefreshEntries()
|
||||
|
||||
def _ToggleMatchWholeWord(self, Event: ft.ControlEvent) -> None:
|
||||
self.MatchWholeWord = not self.MatchWholeWord
|
||||
self.MatchWholeWordButton.style = self._SearchToggleStyle(self.MatchWholeWord)
|
||||
self.MatchWholeWordButton.update()
|
||||
if self.SearchText:
|
||||
self._RefreshEntries()
|
||||
|
||||
def _ToggleSearchBar(self) -> None:
|
||||
"""Ctrl+F. With SearchAlwaysVisible on, the bar's already there —
|
||||
this just (re)focuses it. Otherwise it opens/closes the bar itself;
|
||||
closing also clears the filter, same as dismissing a find bar."""
|
||||
if not self.Config.ShowSearch:
|
||||
return
|
||||
if self.Config.SearchAlwaysVisible:
|
||||
self.page.run_task(self.SearchField.focus)
|
||||
return
|
||||
self.SearchBarVisible = not self.SearchBarVisible
|
||||
self.SearchBar.visible = self.SearchBarVisible
|
||||
if self.SearchBarVisible:
|
||||
self.SearchBar.update()
|
||||
self.page.run_task(self.SearchField.focus)
|
||||
else:
|
||||
self.SearchText = ""
|
||||
self.SearchField.value = ""
|
||||
self.SearchBar.update()
|
||||
self._RefreshEntries()
|
||||
|
||||
def _BuildBreadcrumbs(self, DirPath: Path) -> list[ft.Control]:
|
||||
Parts = DirPath.parts
|
||||
Controls: list[ft.Control] = []
|
||||
@@ -1002,11 +1090,64 @@ class Browser(ft.Row):
|
||||
if SinceUpdate:
|
||||
self.EntryList.update()
|
||||
|
||||
def _MatchesSearch(self, Name: str) -> bool:
|
||||
"""*.png / *sarah* work as literal fnmatch wildcards; a bare word
|
||||
with neither implicitly becomes a substring search (the common case,
|
||||
typing to filter). Match Whole Word only affects that bare-word case
|
||||
(a wildcard pattern already says exactly what it wants to match) —
|
||||
bounded so "cat" doesn't match "concatenate", same sense as a find
|
||||
bar's whole-word option. Regex \\b treats "_" as a word character
|
||||
(no boundary there), which would wrongly reject "my_cat_photo" — so
|
||||
this uses its own not-alphanumeric lookaround instead, treating "_"/
|
||||
"-"/"." and the rest the same as filenames actually delimit words."""
|
||||
Pattern = self.SearchText
|
||||
if not Pattern:
|
||||
return True
|
||||
Flags = 0 if self.MatchCase else re.IGNORECASE
|
||||
if "*" in Pattern or "?" in Pattern:
|
||||
return re.match(fnmatch.translate(Pattern), Name, Flags) is not None
|
||||
if self.MatchWholeWord:
|
||||
Boundary = rf"(?<![A-Za-z0-9]){re.escape(Pattern)}(?![A-Za-z0-9])"
|
||||
return re.search(Boundary, Name, Flags) is not None
|
||||
return re.search(re.escape(Pattern), Name, Flags) is not None
|
||||
|
||||
def _SortEntries(self, Entries: list[Path]) -> list[Path]:
|
||||
"""Directories always sort before files (matches the previous fixed
|
||||
order) — SortField/SortAscending only reorder within each group, so
|
||||
switching to Size/Modified/Type can't suddenly interleave folders
|
||||
and files. Size never walks a directory recursively (see _EntrySize
|
||||
— that recursion was Bugs/Major's "load times... are horrid"):
|
||||
directories are all tied at 0 for this sort and fall back to name."""
|
||||
Field = self.Config.SortField
|
||||
|
||||
def KeyFn(Entry: Path) -> tuple:
|
||||
NameKey = Entry.name.lower()
|
||||
if Field == "Size":
|
||||
try:
|
||||
Primary = 0 if Entry.is_dir() else Entry.stat().st_size
|
||||
except OSError:
|
||||
Primary = 0
|
||||
elif Field == "Modified":
|
||||
try:
|
||||
Primary = Entry.stat().st_mtime
|
||||
except OSError:
|
||||
Primary = 0.0
|
||||
elif Field == "Type":
|
||||
Primary = "" if Entry.is_dir() else Entry.suffix.lower()
|
||||
else: # "Name"
|
||||
Primary = NameKey
|
||||
return (Primary, NameKey)
|
||||
|
||||
Reverse = not self.Config.SortAscending
|
||||
Dirs = sorted((E for E in Entries if E.is_dir()), key=KeyFn, reverse=Reverse)
|
||||
Files = sorted((E for E in Entries if not E.is_dir()), key=KeyFn, reverse=Reverse)
|
||||
return Dirs + Files
|
||||
|
||||
def _BuildEntries(self, DirPath: Path) -> list[ft.Control]:
|
||||
self.EntryTiles = {}
|
||||
self.ColumnCells = {}
|
||||
try:
|
||||
Entries = sorted(DirPath.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
||||
Entries = list(DirPath.iterdir())
|
||||
except (PermissionError, FileNotFoundError):
|
||||
self.CurrentEntries = []
|
||||
return [ft.Text("Can't read this folder.", italic=True)]
|
||||
@@ -1017,6 +1158,9 @@ class Browser(ft.Row):
|
||||
Entries = [Entry for Entry in Entries if not IsInternalSaraTagFile(Entry, self.Config)]
|
||||
if self.EntryFilter:
|
||||
Entries = [Entry for Entry in Entries if self.EntryFilter(Entry)]
|
||||
if self.Config.ShowSearch and self.SearchText:
|
||||
Entries = [Entry for Entry in Entries if self._MatchesSearch(Entry.name)]
|
||||
Entries = self._SortEntries(Entries)
|
||||
self.CurrentEntries = Entries
|
||||
# A refresh (new/renamed/deleted files) can leave stale paths in the
|
||||
# selection — drop anything that no longer exists in this listing.
|
||||
@@ -1395,8 +1539,11 @@ class Browser(ft.Row):
|
||||
if self.DialogOpen:
|
||||
return
|
||||
if self.ShortcutsPaused:
|
||||
if Event.key == "Escape" and self.OnEscape:
|
||||
self.OnEscape()
|
||||
if Event.key == "Escape":
|
||||
if self.OnEscape:
|
||||
self.OnEscape()
|
||||
elif self.SearchBarVisible and not self.Config.SearchAlwaysVisible:
|
||||
self._ToggleSearchBar()
|
||||
return
|
||||
|
||||
if Event.key == "F2" and self.SelectedPath:
|
||||
@@ -1429,6 +1576,8 @@ class Browser(ft.Row):
|
||||
self._ToggleDotfiles()
|
||||
elif Event.key == "T" and Event.ctrl:
|
||||
self._ToggleTags()
|
||||
elif Event.key == "F" and Event.ctrl:
|
||||
self._ToggleSearchBar()
|
||||
elif Event.key == "C" and Event.ctrl and Event.shift and self.SelectedPaths:
|
||||
self._HandleCopyPath(self.SelectedPath)
|
||||
elif Event.key == "C" and Event.ctrl and self.SelectedPaths:
|
||||
|
||||
@@ -233,6 +233,24 @@ class AppConfig:
|
||||
# opposite of this default, so both are always one click away.
|
||||
OpenFilesInApp: bool = False
|
||||
|
||||
# Live filter for the file list, below the breadcrumbs/toolbar bar.
|
||||
# ShowSearch is the master on/off; SearchAlwaysVisible picks between the
|
||||
# bar always being there or only appearing on Ctrl+F (and dismissing on
|
||||
# Escape). Match Case/Whole Word are session-only (Browser.MatchCase/
|
||||
# MatchWholeWord), not persisted here — they're live search options, not
|
||||
# a durable preference, same idea as a browser's find bar.
|
||||
ShowSearch: bool = False
|
||||
SearchAlwaysVisible: bool = False
|
||||
|
||||
# Sidebar sort control ("[Sort: Name] v" + a separate ascending/
|
||||
# descending arrow button next to it — see Sidebar._BuildSortControl).
|
||||
# Directories always sort before files regardless of field/direction,
|
||||
# matching the existing list order; the field/direction only reorders
|
||||
# within each of those two groups.
|
||||
ShowSort: bool = False
|
||||
SortField: str = "Name" # "Name", "Size", "Modified", or "Type"
|
||||
SortAscending: bool = True
|
||||
|
||||
# Batch-rename pattern for multi-select rename, syntax modeled on
|
||||
# SaraSortd's own placeholder characters (see ~/Applications/SaraSortd's
|
||||
# GlobalConf.toml [Variables]): ~ = typed base name, ! = auto-incrementing
|
||||
|
||||
@@ -117,7 +117,10 @@ def _BuildWindow(Config, StartPath: Path, **BrowserHooks) -> tuple[ft.Row, Brows
|
||||
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
|
||||
OnNavigate=BrowserView.NavigateTo,
|
||||
Config=Config,
|
||||
OnFilesMoved=BrowserView.Refresh,
|
||||
OnSortChanged=BrowserView.Refresh,
|
||||
)
|
||||
SidebarDivider = _BuildSidebarWidthDivider()
|
||||
if Config.SidebarSide == "left":
|
||||
|
||||
+19
@@ -159,6 +159,12 @@ def BuildSettingsDialog(
|
||||
label="Open text/config/markup files in SaraFem's own viewer, not your default app",
|
||||
value=Config.OpenFilesInApp,
|
||||
)
|
||||
ShowSearchSwitch = ft.Switch(label="Enable search (Ctrl+F)", value=Config.ShowSearch)
|
||||
SearchAlwaysVisibleSwitch = ft.Switch(
|
||||
label="Always show the search bar (off = only on Ctrl+F, Escape to dismiss)",
|
||||
value=Config.SearchAlwaysVisible,
|
||||
)
|
||||
ShowSortSwitch = ft.Switch(label="Enable sort control in the sidebar", value=Config.ShowSort)
|
||||
BatchRenamePatternField = ft.TextField(
|
||||
value=Config.BatchRenamePattern,
|
||||
label="Batch rename pattern (~ = name, ! = counter, ^ = extension)",
|
||||
@@ -394,6 +400,16 @@ def BuildSettingsDialog(
|
||||
spacing=12,
|
||||
scroll=ft.ScrollMode.AUTO,
|
||||
),
|
||||
"Search & Sort": ft.Column(
|
||||
[
|
||||
ShowSearchSwitch,
|
||||
SearchAlwaysVisibleSwitch,
|
||||
ft.Divider(height=1, color=Palette.Surface1),
|
||||
ShowSortSwitch,
|
||||
_SectionLabel('The sort control ("[Sort: Name] v") sits above Bookmarks in the sidebar.'),
|
||||
],
|
||||
spacing=12,
|
||||
),
|
||||
"SaraTag": ft.Column([SaraTagSwitch, SaraTagPathField], spacing=12),
|
||||
"SaraSortd": ft.Column(
|
||||
[
|
||||
@@ -456,6 +472,9 @@ def BuildSettingsDialog(
|
||||
).Save()
|
||||
Config.ShowDotfiles = DotfilesSwitch.value
|
||||
Config.OpenFilesInApp = OpenFilesInAppSwitch.value
|
||||
Config.ShowSearch = ShowSearchSwitch.value
|
||||
Config.SearchAlwaysVisible = SearchAlwaysVisibleSwitch.value
|
||||
Config.ShowSort = ShowSortSwitch.value
|
||||
Config.BatchRenamePattern = BatchRenamePatternField.value.strip() or "~!^"
|
||||
Config.DeleteZipAfterExtract = DeleteZipSwitch.value
|
||||
Config.DefaultArchiveFormat = GetArchiveFormat()
|
||||
|
||||
+56
-1
@@ -29,10 +29,12 @@ class Sidebar(ft.Container):
|
||||
OnNavigate: Callable[[Path], None],
|
||||
Config: AppConfig,
|
||||
OnFilesMoved: Optional[Callable[[], None]] = None,
|
||||
OnSortChanged: Optional[Callable[[], None]] = None,
|
||||
):
|
||||
self.OnNavigate = OnNavigate
|
||||
self.Config = Config
|
||||
self.OnFilesMoved = OnFilesMoved
|
||||
self.OnSortChanged = OnSortChanged
|
||||
self.LocationTiles: dict[Path, Callable[[bool], None]] = {}
|
||||
self.BookmarksListView: Optional[ft.ReorderableListView] = None
|
||||
self.DevicesView: Optional[DevicesPanel] = None
|
||||
@@ -46,6 +48,11 @@ class Sidebar(ft.Container):
|
||||
BookmarksBox: Optional[ft.Container] = None
|
||||
DevicesBox: Optional[ft.Container] = None
|
||||
|
||||
if Config.ShowSort:
|
||||
Sections.append(self._BuildSortControl())
|
||||
if Config.ShowBookmarks or Config.ShowDevices or Config.ShowFiletree:
|
||||
Sections.append(ft.Divider(height=1, color=Palette.Surface1))
|
||||
|
||||
if Config.ShowBookmarks:
|
||||
self.BookmarksListView = ft.ReorderableListView(
|
||||
controls=self._BuildBookmarkTiles(),
|
||||
@@ -104,7 +111,55 @@ class Sidebar(ft.Container):
|
||||
border=Border,
|
||||
)
|
||||
|
||||
DefaultSectionHeight = 150 # starting point for a drag when a section had no explicit height yet
|
||||
SortFields = ("Name", "Size", "Modified", "Type")
|
||||
|
||||
def _BuildSortControl(self) -> ft.Control:
|
||||
""""[Sort: Name] v" — a dropdown for the field — plus a separate
|
||||
ascending/descending arrow button to its right that flips on click
|
||||
(Features/Major: search + sort). Directories always sort before
|
||||
files regardless of field/direction — see Browser._SortEntries."""
|
||||
FieldButton = ft.PopupMenuButton(
|
||||
content=ft.Row(
|
||||
[
|
||||
ft.Text(f"Sort: {self.Config.SortField}", size=13),
|
||||
ft.Icon(ft.Icons.ARROW_DROP_DOWN, size=18, color=Palette.Overlay0),
|
||||
],
|
||||
spacing=2,
|
||||
tight=True,
|
||||
),
|
||||
items=[
|
||||
ft.PopupMenuItem(content=Field, on_click=lambda e, F=Field: self._OnSortFieldChosen(F))
|
||||
for Field in self.SortFields
|
||||
],
|
||||
)
|
||||
self.SortFieldButton = FieldButton
|
||||
self.SortDirectionButton = ft.IconButton(
|
||||
icon=ft.Icons.ARROW_DROP_UP if self.Config.SortAscending else ft.Icons.ARROW_DROP_DOWN,
|
||||
tooltip="Ascending" if self.Config.SortAscending else "Descending",
|
||||
icon_size=20,
|
||||
on_click=self._OnSortDirectionToggled,
|
||||
)
|
||||
return ft.Container(
|
||||
content=ft.Row([FieldButton, self.SortDirectionButton], spacing=0),
|
||||
padding=ft.Padding.symmetric(horizontal=4, vertical=2),
|
||||
)
|
||||
|
||||
def _OnSortFieldChosen(self, Field: str) -> None:
|
||||
self.Config.SortField = Field
|
||||
self.Config.Save()
|
||||
self.SortFieldButton.content.controls[0].value = f"Sort: {Field}"
|
||||
self.SortFieldButton.update()
|
||||
if self.OnSortChanged:
|
||||
self.OnSortChanged()
|
||||
|
||||
def _OnSortDirectionToggled(self, Event: ft.ControlEvent) -> None:
|
||||
self.Config.SortAscending = not self.Config.SortAscending
|
||||
self.Config.Save()
|
||||
self.SortDirectionButton.icon = ft.Icons.ARROW_DROP_UP if self.Config.SortAscending else ft.Icons.ARROW_DROP_DOWN
|
||||
self.SortDirectionButton.tooltip = "Ascending" if self.Config.SortAscending else "Descending"
|
||||
self.SortDirectionButton.update()
|
||||
if self.OnSortChanged:
|
||||
self.OnSortChanged()
|
||||
|
||||
def _BuildSectionDivider(self, Box: ft.Container, MaxHeightAttr: str) -> ft.Control:
|
||||
"""A resize handle for the section above it (Box) — drags resize that
|
||||
|
||||
Reference in new issue
Block a user