From 65f5b89908c1e0f4bf785844eb04b0f6f10056a8 Mon Sep 17 00:00:00 2001 From: SRHTNIN Date: Tue, 29 Sep 2026 06:50:40 +0200 Subject: [PATCH] Fix in-app editor: typing was triggering Browser's global keybinds (Backspace navigated up, Enter reopened the file --- Browser.py | 106 +++++++++++++++++++++++++++++++++++++++++++++++++++-- Editor.py | 23 ++++++++++++ 2 files changed, 125 insertions(+), 4 deletions(-) diff --git a/Browser.py b/Browser.py index 40b00b0..0af6bb0 100644 --- a/Browser.py +++ b/Browser.py @@ -79,9 +79,18 @@ def _DirectorySize(Entry: Path) -> int: def _EntrySize(Entry: Path) -> str: + # A directory's "size" used to mean a full recursive rglob() walk of + # every descendant file — right here, once per directory, every single + # time the parent folder was listed. That was most of Bugs/Major's + # "load times for list view are horrid": a folder with a few large + # subdirectories (node_modules, Pictures, ...) could block the whole + # list for seconds on every navigation. Real size is still available + # on demand via Properties (_ShowPropertiesDialog), which already pays + # that cost just once, for the one directory actually asked about — + # matching how Nautilus/Dolphin handle this by default. try: if Entry.is_dir(): - return HumanSize(_DirectorySize(Entry)) + return "—" return HumanSize(Entry.stat().st_size) except OSError: return "" @@ -258,6 +267,11 @@ class Browser(ft.Row): # entries make the cache meaningless. self.ColumnCells: dict[str, list[ft.Container]] = {} self.MetadataCache: dict[Path, tuple[float, dict[str, str]]] = {} + # Bumped by _RefreshEntries every time it rebuilds the list, so a + # background _PopulateEntriesAsync pass from a superseded build can + # tell it's stale (navigated away, refreshed again) and stop instead + # of writing into a newer list's cells. + self.EntriesGeneration = 0 self.LastClickPath: Optional[Path] = None self.LastClickTime = 0.0 self.BackStack: list[Path] = [] @@ -522,6 +536,7 @@ class Browser(ft.Row): FontFamily=self.Config.FontFamily, OnClose=self._CloseEditor, OnNotify=self._Notify, + OnFocusChange=self._SetShortcutsPaused, ) Items.append(Viewer) if ShowBar and self.Config.BreadcrumbsPosition == "bottom": @@ -685,6 +700,7 @@ class Browser(ft.Row): self.MetadataCache = {} # a new directory's entries make the old cache meaningless if self.OpenFilePath is not None: # navigating away closes whatever's open in-app self.OpenFilePath = None + self.ShortcutsPaused = False # its TextField won't be around to fire on_blur self.FileListColumn.controls = self._BuildChrome() self.FileListColumn.update() self._Refresh() @@ -843,6 +859,7 @@ class Browser(ft.Row): def _CloseEditor(self) -> None: self.OpenFilePath = None + self.ShortcutsPaused = False # its TextField won't be around to fire on_blur self.FileListColumn.controls = self._BuildChrome() self.FileListColumn.update() @@ -876,8 +893,11 @@ class Browser(ft.Row): ) def _RefreshEntries(self) -> None: + self.EntriesGeneration += 1 self.EntryList.controls = self._BuildEntries(self.CurrentPath) self.update() + if self.Config.ViewMode == "list" and self.CurrentEntries: + self.page.run_task(self._PopulateEntriesAsync, self.EntriesGeneration, list(self.CurrentEntries)) def _BuildBreadcrumbs(self, DirPath: Path) -> list[ft.Control]: Parts = DirPath.parts @@ -918,6 +938,69 @@ class Browser(ft.Row): self.MetadataCache[Entry] = (Mtime, Values) return Values + def _PeekCachedColumnValues(self, Entry: Path) -> dict[str, str]: + """Like _CachedColumnValues but never computes on a cache miss — used + by the fast (synchronous) row-building pass, so a big/unvisited + directory's rows appear instantly with blank metadata rather than + blocking on it. _PopulateEntriesAsync fills the real values in + afterwards, off the UI thread. A cache hit (e.g. a same-directory + refresh after a rename) still shows immediately, no flicker.""" + try: + Mtime = Entry.stat().st_mtime + except OSError: + return {} + Cached = self.MetadataCache.get(Entry) + if Cached is not None and Cached[0] == Mtime: + return Cached[1] + return {} + + def _PlaceholderIcon(self, Entry: Path, Size: int) -> ft.Control: + """What _BuildListRow shows before _PopulateEntriesAsync's background + pass resolves the real icon. Identical to _LeadingFor's non- + thumbnail branches, so only images visibly change once their + thumbnail loads — everything else never flickers.""" + if Entry.is_dir(): + return ft.Icon(ft.Icons.FOLDER, size=Size, color=Palette.Mauve) + return ft.Icon(ft.Icons.INSERT_DRIVE_FILE_OUTLINED, size=Size, color=Palette.Overlay0) + + def _ComputeRowExtras(self, Entry: Path) -> tuple[ft.Control, dict[str, str]]: + """The actual work _PlaceholderIcon/_PeekCachedColumnValues defer — + run off the UI thread by _PopulateEntriesAsync via asyncio.to_thread, + one row at a time.""" + return self._LeadingFor(Entry, Size=self.Config.ListIconSize), self._CachedColumnValues(Entry) + + async def _PopulateEntriesAsync(self, Generation: int, Entries: list[Path]) -> None: + """Fills in real icons/metadata for a list-view row of entries that + _BuildEntries rendered with placeholders, batching UI updates rather + than doing one per row. Generation is this._EntriesGeneration at the + time _BuildEntries ran — checked before every write so a stale pass + (navigated away, refreshed again) bails out instead of corrupting a + newer list's cells, which _BuildEntries always rebuilds from scratch.""" + BatchSize = 12 + SinceUpdate = 0 + NameCells = self.ColumnCells.get("Name", []) + for Index, Entry in enumerate(Entries): + if Generation != self.EntriesGeneration: + return + try: + Icon, Values = await asyncio.to_thread(self._ComputeRowExtras, Entry) + except Exception: + continue # one unreadable/broken entry shouldn't kill the whole pass + if Generation != self.EntriesGeneration: + return + if Index < len(NameCells): + NameCells[Index].content.controls[0] = Icon + for ColumnName, Text in Values.items(): + Cells = self.ColumnCells.get(ColumnName, []) + if Index < len(Cells): + Cells[Index].content.value = Text + SinceUpdate += 1 + if SinceUpdate >= BatchSize: + self.EntryList.update() + SinceUpdate = 0 + if SinceUpdate: + self.EntryList.update() + def _BuildEntries(self, DirPath: Path) -> list[ft.Control]: self.EntryTiles = {} self.ColumnCells = {} @@ -991,7 +1074,7 @@ class Browser(ft.Row): overflow=ft.TextOverflow.ELLIPSIS, max_lines=1, ) NameCell = ft.Container( - content=ft.Row([self._LeadingFor(Entry, Size=self.Config.ListIconSize), NameLabel], spacing=12), + content=ft.Row([self._PlaceholderIcon(Entry, Size=self.Config.ListIconSize), NameLabel], spacing=12), padding=ft.Padding.symmetric(horizontal=12, vertical=8), bgcolor=Palette.Surface0 if Entry in self.SelectedPaths else None, width=self.Config.ListWidthName, @@ -999,11 +1082,15 @@ class Browser(ft.Row): self.ColumnCells.setdefault("Name", []).append(NameCell) HighlightCells = [NameCell] MetaCells: list[ft.Control] = [] - CachedValues = self._CachedColumnValues(Entry) + # Blank (or already-cached) here — _PopulateEntriesAsync fills the + # real icon/values in afterwards, off the UI thread, so a big or + # never-visited directory still paints its row list instantly + # (Bugs/Major: "Load times for list view are horrid"). + CachedValues = self._PeekCachedColumnValues(Entry) for ConfigFlag, ColumnName, _ValueFn in ListColumns: if getattr(self.Config, ConfigFlag): Width = getattr(self.Config, f"ListWidth{ColumnName}") - Cell, Wrapped = self._MetadataCell(CachedValues[ColumnName], Width) + Cell, Wrapped = self._MetadataCell(CachedValues.get(ColumnName, ""), Width) self.ColumnCells.setdefault(ColumnName, []).append(Cell) HighlightCells.append(Cell) MetaCells.append(Wrapped) @@ -1244,6 +1331,13 @@ class Browser(ft.Row): # --- Keybinds ----------------------------------------------------------- + def _SetShortcutsPaused(self, Paused: bool) -> None: + """Same mechanism Picker.py already uses for its own inline text + field (see EditorView's OnFocusChange): page.on_keyboard_event is + global, not focus-scoped, so without this, typing in the in-app + editor also triggered Backspace-navigates-up / Enter-reopens-file.""" + self.ShortcutsPaused = Paused + def _OnListKeyDown(self, Event) -> None: """Tracks live Shift/Ctrl-held state for click modifiers (see the KeyboardListener comment in _BuildChrome).""" @@ -1675,6 +1769,10 @@ class Browser(ft.Row): Rows.append(ft.Text(f"Contains: {ItemCount} item(s)", size=12, color=Palette.Text)) except OSError: Rows.append(ft.Text("Contains: can't read (permission denied)", size=12, color=Palette.Overlay0)) + # The one place a full recursive walk still happens — a single, + # deliberate "show me" click on one directory, not every row of + # every list render (see _EntrySize). + Rows.append(ft.Text(f"Size: {HumanSize(_DirectorySize(Entry))}", size=12, color=Palette.Text)) else: Rows.append(ft.Text(f"Size: {HumanSize(Stat.st_size)}", size=12, color=Palette.Text)) diff --git a/Editor.py b/Editor.py index b4a21a8..3dbf71d 100644 --- a/Editor.py +++ b/Editor.py @@ -48,10 +48,18 @@ class EditorView(ft.Container): FontFamily: str, OnClose: Callable[[], None], OnNotify: Callable[[str, str], None], + OnFocusChange: Optional[Callable[[bool], None]] = None, ): self.EntryPath = EntryPath self.OnClose = OnClose self.OnNotify = OnNotify + # Browser's Enter/Backspace/Delete/etc shortcuts are a page-level + # keydown listener (see Browser._OnKeyboardEvent), not scoped to + # whatever has focus — without this, typing in the TextField below + # was also navigating directories (Backspace) or re-opening the file + # (Enter) out from under you. Same ShortcutsPaused mechanism Picker.py + # already uses for its own inline (non-dialog) text field. + self.OnFocusChange = OnFocusChange self.Language = LanguageFor(EntryPath) try: @@ -73,6 +81,8 @@ class EditorView(ft.Container): border=ft.InputBorder.NONE, text_style=CodeFont, on_change=self._OnTextChanged, + on_focus=lambda e: self._SetFocused(True), + on_blur=lambda e: self._SetFocused(False), ) self.MarkdownView = ft.Markdown( value=FencedBlock(self.OriginalContent, self.Language), @@ -118,6 +128,10 @@ class EditorView(ft.Container): padding=12, ) + def _SetFocused(self, Focused: bool) -> None: + if self.OnFocusChange is not None: + self.OnFocusChange(Focused) + def _OnTextChanged(self, Event: ft.ControlEvent) -> None: if not self.IsDirty: self.IsDirty = True @@ -133,6 +147,11 @@ class EditorView(ft.Container): if self.IsEditing: self.Body.content = self.TextField self.ToggleButton.content = "View" + # Don't wait on Flet's own focus event for this — swapping the + # TextField into Body.content isn't guaranteed to fire on_focus + # (or fire it before the next keypress), so shortcuts would stay + # live for a keystroke or two right after clicking Edit. + self._SetFocused(True) else: # Re-render the highlighted view from whatever's currently typed # (even if unsaved), so Edit -> View -> Edit doesn't discard it. @@ -142,6 +161,10 @@ class EditorView(ft.Container): self.PlainView.value = self.TextField.value self.Body.content = self.PlainView if self.SkipHighlight else self.MarkdownView self.ToggleButton.content = "Edit" + # Same reasoning in reverse: the TextField leaving the tree isn't + # guaranteed to fire on_blur, so explicitly un-pause here rather + # than risk shortcuts staying dead after leaving Edit mode. + self._SetFocused(False) self.Body.update() self.ToggleButton.update()