diff --git a/Browser.py b/Browser.py index 5e5dd3f..1144e97 100644 --- a/Browser.py +++ b/Browser.py @@ -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"(? 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: diff --git a/Core.py b/Core.py index 95adfb0..91eaa18 100644 --- a/Core.py +++ b/Core.py @@ -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 diff --git a/Main.py b/Main.py index eb4fa80..4da51cf 100644 --- a/Main.py +++ b/Main.py @@ -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": diff --git a/Settings.py b/Settings.py index f1e4284..7dea024 100644 --- a/Settings.py +++ b/Settings.py @@ -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() diff --git a/Sidebar.py b/Sidebar.py index 53359ff..e4123d3 100644 --- a/Sidebar.py +++ b/Sidebar.py @@ -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