mirror of
https://git.srhtnin.garden/sarah/SaraFem.git
synced 2026-10-13 18:18:11 +02:00
Fix in-app editor: typing was triggering Browser's global keybinds (Backspace navigated up, Enter reopened the file
This commit is contained in:
1 parent
117532e017
commit
65f5b89908
2 files changed
+125
-4
No files matched your search
+102
-4
@@ -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))
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in new issue
Block a user