Add search (Ctrl+F, wildcards, match case/whole word) and sort, both off by default

This commit is contained in:
sarah committed 2026-09-29 19:59:02 +02:00
1 parent 6c48796dde
commit 1c2acf3f16
5 files changed
+249 -5

No files matched your search

+152 -3
View File
@@ -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:
+18
View File
@@ -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
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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