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: