Add fullscreen in-app image view, no stretch, sharing the editor's in-app/external toggle

This commit is contained in:
sarah committed 2026-09-26 18:50:38 +02:00
1 parent d588d445b4
commit 117532e017
5 files changed
+281 -2

No files matched your search

+69 -2
View File
@@ -28,11 +28,13 @@ from Core import (
AppConfig, ApplyHeightBounds, ChipButtonStyle, DragGroup, ExportSettings, FormatBatchName, ImportSettings,
NotificationStyle, Palette, ResizeHandle,
)
from Editor import EditorView
from ImageView import ImageView
from Preview import HumanSize, Preview, TextExtensions
from SaraSortdInfo import GetSortRules
from Settings import BuildSettingsDialog
from Tags import TagPanel
from Thumbnails import GetThumbnail, IsThumbnailable
from Thumbnails import GetThumbnail, ImageExtensions, IsThumbnailable
DoubleClickWindow = 0.4 # seconds
@@ -46,6 +48,13 @@ def _IsArchive(EntryPath: Path) -> bool:
return any(NameLower.endswith(Ext) for Ext in ArchiveExtensions)
def _IsInAppViewable(EntryPath: Path) -> bool:
"""Everything Config.OpenFilesInApp can route to (Editor.py's EditorView
for text/config/markup, ImageView.py's ImageView for images)."""
Suffix = EntryPath.suffix.lower()
return Suffix in TextExtensions or Suffix in ImageExtensions
def _EntryDescription(Entry: Path) -> str:
@@ -301,6 +310,9 @@ class Browser(ft.Row):
expand=True,
)
self.EntryList = self._MakeEntryListContainer()
# None: the file list shows. A Path: EditorView fills the file area
# instead (Features/Major: "Different display in middle").
self.OpenFilePath: Optional[Path] = None
# 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
@@ -497,6 +509,27 @@ class Browser(ft.Row):
# bottom toolbar sat right under the last entry. Inside that fixed
# height, the inner Container's bgcolor makes it paint, and so
# hit-test, everywhere, including below the last entry.
if self.OpenFilePath is not None:
# A file is open in-app (Features/Major: "Different display in
# middle") — it takes over the whole file area, SortBanner
# included, same as it takes over the KeyboardListener/entry list
# tree below.
if self.OpenFilePath.suffix.lower() in ImageExtensions:
Viewer: ft.Control = ImageView(self.OpenFilePath, OnClose=self._CloseEditor)
else:
Viewer = EditorView(
self.OpenFilePath,
FontFamily=self.Config.FontFamily,
OnClose=self._CloseEditor,
OnNotify=self._Notify,
)
Items.append(Viewer)
if ShowBar and self.Config.BreadcrumbsPosition == "bottom":
Items += [ft.Divider(height=1, color=Palette.Surface1), self._BuildToolbarBar()]
if ShowTagsAt("bottom"):
Items.append(TagsBar)
return Items
Items.append(self.SortBanner)
Items.append(
ft.Container(
@@ -650,6 +683,10 @@ class Browser(ft.Row):
self.SelectedPaths = set()
self.SelectionAnchor = None
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.FileListColumn.controls = self._BuildChrome()
self.FileListColumn.update()
self._Refresh()
if self.Config.ShowPreview:
self.PreviewPane.ShowPath(None)
@@ -779,18 +816,36 @@ class Browser(ft.Row):
return [P for P in self.CurrentEntries if P in self.SelectedPaths]
return [Entry]
def OpenEntry(self, EntryPath: Path) -> None:
def OpenEntry(self, EntryPath: Path, ForceInApp: Optional[bool] = None) -> None:
"""ForceInApp overrides Config.OpenFilesInApp for this one call — used
by the per-entry "Open" menu item, which always does the opposite of
whatever double-click currently does (Features/Major: "Different
display in middle")."""
if EntryPath.is_dir():
self.NavigateTo(EntryPath)
return
if self.OnActivateFile:
self.OnActivateFile(EntryPath)
return
WantInApp = self.Config.OpenFilesInApp if ForceInApp is None else ForceInApp
if WantInApp and _IsInAppViewable(EntryPath):
self._OpenInEditor(EntryPath)
return
try:
subprocess.Popen(["xdg-open", str(EntryPath)])
except OSError as Error:
self._Notify(f"Couldn't open {EntryPath.name}: {Error}")
def _OpenInEditor(self, EntryPath: Path) -> None:
self.OpenFilePath = EntryPath
self.FileListColumn.controls = self._BuildChrome()
self.FileListColumn.update()
def _CloseEditor(self) -> None:
self.OpenFilePath = None
self.FileListColumn.controls = self._BuildChrome()
self.FileListColumn.update()
# --- Rendering --------------------------------------------------------
def Refresh(self) -> None:
@@ -1095,6 +1150,18 @@ class Browser(ft.Row):
selection changes."""
Items = [
ft.PopupMenuItem(content="Rename", on_click=lambda e, p=Entry: self._HandleRename(p)),
]
if not Entry.is_dir():
if _IsInAppViewable(Entry):
# Always the opposite of what double-click currently does —
# see OpenEntry's ForceInApp and Config.OpenFilesInApp.
Label = "Open Externally" if self.Config.OpenFilesInApp else "Open in App"
OpenHandler = lambda e, p=Entry: self.OpenEntry(p, ForceInApp=not self.Config.OpenFilesInApp)
else:
Label = "Open"
OpenHandler = lambda e, p=Entry: self.OpenEntry(p)
Items.insert(0, ft.PopupMenuItem(content=Label, on_click=OpenHandler))
Items += [
ft.PopupMenuItem(
content="Copy", on_click=lambda e, p=Entry: self._SetClipboard(self._TargetSelection(p), "copy")
),
+5
View File
@@ -205,6 +205,11 @@ class AppConfig:
ListIconSize: int = 32
FontSize: int = 14 # entry name text, list and grid alike; metadata/header text scales with it
ToolbarHamburger: bool = False # collapse the toolbar's buttons into one overflow menu
# Double-click on a text/config/markup file: True opens it in SaraFem's own
# viewer/editor (Editor.py); False (matches pre-existing behaviour) hands it
# to xdg-open instead. Either way, the per-entry "Open" menu item does the
# opposite of this default, so both are always one click away.
OpenFilesInApp: bool = False
# Batch-rename pattern for multi-select rename, syntax modeled on
# SaraSortd's own placeholder characters (see ~/Applications/SaraSortd's
+160
View File
@@ -0,0 +1,160 @@
"""In-app text/config/markup viewer and editor (Features/Major: "Different
display in middle" — the code-editor part). Fills Browser's file-area
Container in place of the entry list while a file is open.
Flet 1.0.1 has no CodeMirror-equivalent rich editor widget and no embeddable
WebView, so this mirrors SaraView's View/Edit split as closely as Flet
actually allows: View mode gets real syntax highlighting by wrapping the
content in a fenced code block and rendering it read-only via ft.Markdown
(which ships GitHub-style highlight.js theming); Edit mode is a plain
monospace TextField — no highlighting while typing — with an explicit Save."""
import re
from pathlib import Path
from typing import Callable, Optional
import flet as ft
from Core import ChipButtonStyle, Palette
LanguageByExtension = {
".py": "python", ".json": "json", ".toml": "toml", ".yaml": "yaml", ".yml": "yaml",
".js": "javascript", ".ts": "typescript", ".css": "css", ".html": "xml", ".htm": "xml",
".xml": "xml", ".sh": "bash", ".fish": "bash", ".md": "markdown", ".ipynb": "json",
".cfg": "ini", ".ini": "ini", ".csv": "", ".log": "", ".scad": "",
}
# Above this, re-parsing the whole file into a highlighted markdown block on
# every render gets slow — large logs stay readable (unhighlighted) instead.
HighlightCharLimit = 500_000
def LanguageFor(EntryPath: Path) -> str:
return LanguageByExtension.get(EntryPath.suffix.lower(), "")
def FencedBlock(Content: str, Language: str) -> str:
"""A fence long enough that no run of backticks already in Content can
prematurely close it (CommonMark: closing fence >= opening fence length)."""
LongestRun = max((len(Run) for Run in re.findall(r"`+", Content)), default=0)
Fence = "`" * max(LongestRun + 1, 3)
return f"{Fence}{Language}\n{Content}\n{Fence}"
class EditorView(ft.Container):
def __init__(
self,
EntryPath: Path,
FontFamily: str,
OnClose: Callable[[], None],
OnNotify: Callable[[str, str], None],
):
self.EntryPath = EntryPath
self.OnClose = OnClose
self.OnNotify = OnNotify
self.Language = LanguageFor(EntryPath)
try:
self.OriginalContent = EntryPath.read_text(errors="replace")
self.ReadError: Optional[str] = None
except OSError as Error:
self.OriginalContent = ""
self.ReadError = str(Error)
self.IsEditing = False
self.IsDirty = False
self.SkipHighlight = len(self.OriginalContent) > HighlightCharLimit
CodeFont = ft.TextStyle(font_family=FontFamily, size=13)
self.TextField = ft.TextField(
value=self.OriginalContent,
multiline=True,
expand=True,
border=ft.InputBorder.NONE,
text_style=CodeFont,
on_change=self._OnTextChanged,
)
self.MarkdownView = ft.Markdown(
value=FencedBlock(self.OriginalContent, self.Language),
selectable=True,
extension_set=ft.MarkdownExtensionSet.GITHUB_FLAVORED,
code_theme=ft.MarkdownCodeTheme.ATOM_ONE_DARK,
)
self.PlainView = ft.Text(self.OriginalContent, selectable=True, style=CodeFont)
self.SaveButton = ft.OutlinedButton(
content="Save", style=ChipButtonStyle(), on_click=self._Save, disabled=True
)
self.ToggleButton = ft.OutlinedButton(content="Edit", style=ChipButtonStyle(), on_click=self._ToggleEdit)
self.StatusText = ft.Text("", color=Palette.Overlay0, size=12)
Toolbar = ft.Row(
[
ft.IconButton(
icon=ft.Icons.ARROW_BACK, tooltip="Back to file list", on_click=lambda e: self.OnClose()
),
ft.Text(EntryPath.name, weight=ft.FontWeight.BOLD, expand=True),
self.StatusText,
self.ToggleButton,
self.SaveButton,
],
spacing=8,
)
if self.ReadError:
BodyContent: ft.Control = ft.Text(f"Couldn't read this file: {self.ReadError}", color=Palette.Red)
elif self.SkipHighlight:
BodyContent = self.PlainView
else:
BodyContent = self.MarkdownView
self.Body = ft.Container(content=BodyContent, expand=True, padding=12)
super().__init__(
content=ft.Column(
[Toolbar, ft.Divider(height=1, color=Palette.Surface1), self.Body], expand=True, spacing=8
),
expand=True,
bgcolor=Palette.Base,
padding=12,
)
def _OnTextChanged(self, Event: ft.ControlEvent) -> None:
if not self.IsDirty:
self.IsDirty = True
self.SaveButton.disabled = False
self.StatusText.value = "Unsaved changes"
self.SaveButton.update()
self.StatusText.update()
def _ToggleEdit(self, Event: ft.ControlEvent) -> None:
if self.ReadError:
return
self.IsEditing = not self.IsEditing
if self.IsEditing:
self.Body.content = self.TextField
self.ToggleButton.content = "View"
else:
# Re-render the highlighted view from whatever's currently typed
# (even if unsaved), so Edit -> View -> Edit doesn't discard it.
if not self.SkipHighlight:
self.MarkdownView.value = FencedBlock(self.TextField.value, self.Language)
else:
self.PlainView.value = self.TextField.value
self.Body.content = self.PlainView if self.SkipHighlight else self.MarkdownView
self.ToggleButton.content = "Edit"
self.Body.update()
self.ToggleButton.update()
def _Save(self, Event: ft.ControlEvent) -> None:
try:
self.EntryPath.write_text(self.TextField.value)
except OSError as Error:
self.OnNotify(f"Couldn't save {self.EntryPath.name}: {Error}", "error")
return
self.OriginalContent = self.TextField.value
self.IsDirty = False
self.SaveButton.disabled = True
self.StatusText.value = "Saved."
self.SaveButton.update()
self.StatusText.update()
self.OnNotify(f"Saved {self.EntryPath.name}.", "success")
+41
View File
@@ -0,0 +1,41 @@
"""Fullscreen in-app image view (Features/Major: "Different display in
middle" — fullscreen image, no stretch). Fills Browser's file-area Container
in place of the entry list, same mechanism as Editor.py's EditorView. Just
fit-to-window display — no zoom/pan, matching what was actually asked for."""
from pathlib import Path
from typing import Callable
import flet as ft
from Core import Palette
class ImageView(ft.Container):
def __init__(self, EntryPath: Path, OnClose: Callable[[], None]):
self.EntryPath = EntryPath
self.OnClose = OnClose
Toolbar = ft.Row(
[
ft.IconButton(
icon=ft.Icons.ARROW_BACK, tooltip="Back to file list", on_click=lambda e: self.OnClose()
),
ft.Text(EntryPath.name, weight=ft.FontWeight.BOLD, expand=True),
],
spacing=8,
)
Body = ft.Container(
content=ft.Image(src=str(EntryPath), fit=ft.BoxFit.CONTAIN, expand=True),
expand=True,
padding=12,
)
super().__init__(
content=ft.Column(
[Toolbar, ft.Divider(height=1, color=Palette.Surface1), Body], expand=True, spacing=8
),
expand=True,
bgcolor=Palette.Base,
padding=12,
)
+6
View File
@@ -155,6 +155,10 @@ def BuildSettingsDialog(
UseSharedSwitch.on_change = OnUseSharedChanged
DotfilesSwitch = ft.Switch(label="Show dotfiles", value=Config.ShowDotfiles)
OpenFilesInAppSwitch = ft.Switch(
label="Open text/config/markup files in SaraFem's own viewer, not your default app",
value=Config.OpenFilesInApp,
)
BatchRenamePatternField = ft.TextField(
value=Config.BatchRenamePattern,
label="Batch rename pattern (~ = name, ! = counter, ^ = extension)",
@@ -288,6 +292,7 @@ def BuildSettingsDialog(
"General": ft.Column(
[
DotfilesSwitch,
OpenFilesInAppSwitch,
BatchRenamePatternField,
DeleteZipSwitch,
_SectionLabel("Default compress format"),
@@ -450,6 +455,7 @@ def BuildSettingsDialog(
HideWindowTitleBar=SharedTitleBarSwitch.value,
).Save()
Config.ShowDotfiles = DotfilesSwitch.value
Config.OpenFilesInApp = OpenFilesInAppSwitch.value
Config.BatchRenamePattern = BatchRenamePatternField.value.strip() or "~!^"
Config.DeleteZipAfterExtract = DeleteZipSwitch.value
Config.DefaultArchiveFormat = GetArchiveFormat()