Fix in-app editor: typing was triggering Browser's global keybinds (Backspace navigated up, Enter reopened the file

This commit is contained in:
sarah committed 2026-09-29 06:50:40 +02:00
1 parent 117532e017
commit 65f5b89908
2 files changed
+125 -4

No files matched your search

+102 -4
View File
@@ -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))
+23
View File
@@ -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()